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:
@@ -1641,4 +1641,63 @@ describe('task-stream (e2e)', () => {
|
||||
).status,
|
||||
).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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2654,6 +2654,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "team",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Team ids",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "priority",
|
||||
"required": false,
|
||||
@@ -11152,6 +11164,34 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -11189,6 +11229,12 @@
|
||||
"checklistBeforeDone": {
|
||||
"type": "boolean",
|
||||
"description": "Definition of done: tickets reach DONE only with their checklist ticked"
|
||||
},
|
||||
"teams": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TeamDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -11197,7 +11243,8 @@
|
||||
"issueTypes",
|
||||
"areas",
|
||||
"customFields",
|
||||
"checklistBeforeDone"
|
||||
"checklistBeforeDone",
|
||||
"teams"
|
||||
]
|
||||
},
|
||||
"ProjectDto": {
|
||||
@@ -11560,6 +11607,35 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -11595,6 +11671,13 @@
|
||||
"$ref": "#/components/schemas/CustomFieldDefinitionBodyDto"
|
||||
}
|
||||
},
|
||||
"teams": {
|
||||
"description": "Absent: unchanged. Teams cannot be removed: archive them.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TeamBodyDto"
|
||||
}
|
||||
},
|
||||
"checklistBeforeDone": {
|
||||
"type": "boolean",
|
||||
"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,
|
||||
"example": "FRONTEND"
|
||||
},
|
||||
"team": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "A team id"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -12088,6 +12176,7 @@
|
||||
"key",
|
||||
"type",
|
||||
"area",
|
||||
"team",
|
||||
"title",
|
||||
"description",
|
||||
"status",
|
||||
@@ -13199,6 +13288,12 @@
|
||||
"example": "FRONTEND",
|
||||
"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": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -13337,6 +13432,12 @@
|
||||
"example": "FRONTEND",
|
||||
"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": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -339,6 +339,9 @@ input CreateIssueInput {
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
|
||||
"""A team id"""
|
||||
team: String
|
||||
title: String!
|
||||
|
||||
"""A ticket type id"""
|
||||
@@ -607,6 +610,9 @@ type Issue {
|
||||
status: String!
|
||||
statusCategory: StatusCategory!
|
||||
storyPoints: Int
|
||||
|
||||
"""A team id"""
|
||||
team: String
|
||||
title: String!
|
||||
|
||||
"""In the trash since then; null when it is not"""
|
||||
@@ -669,6 +675,9 @@ input IssueFilterInput {
|
||||
sort: IssueSort = RECENT
|
||||
sprintId: ID
|
||||
statuses: [String!]
|
||||
|
||||
"""Team ids"""
|
||||
teams: [String!]
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
|
||||
@@ -1204,6 +1213,7 @@ type ProjectSettings {
|
||||
customFields: [CustomFieldDefinition!]!
|
||||
issueTypes: [IssueTypeOption!]!
|
||||
statuses: [WorkflowStatus!]!
|
||||
teams: [Team!]!
|
||||
transitions: [StatusTransitions!]!
|
||||
}
|
||||
|
||||
@@ -1222,6 +1232,9 @@ input ProjectSettingsInput {
|
||||
customFields: [CustomFieldDefinitionInput!]
|
||||
issueTypes: [IssueTypeOptionInput!]!
|
||||
statuses: [WorkflowStatusInput!]!
|
||||
|
||||
"""Absent: unchanged. Teams cannot be removed: archive them."""
|
||||
teams: [TeamInput!]
|
||||
transitions: [StatusTransitionsInput!]!
|
||||
}
|
||||
|
||||
@@ -1654,6 +1667,21 @@ type Subscription {
|
||||
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 {
|
||||
blocked: Int!
|
||||
openPoints: Int!
|
||||
@@ -1696,6 +1724,9 @@ input UpdateIssueInput {
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
|
||||
"""A team id"""
|
||||
team: String
|
||||
title: String
|
||||
type: String
|
||||
}
|
||||
|
||||
@@ -1777,6 +1777,14 @@ export interface components {
|
||||
options: string[];
|
||||
archived: boolean;
|
||||
};
|
||||
TeamDto: {
|
||||
/** @example FRONTEND */
|
||||
id: string;
|
||||
/** @example Frontend */
|
||||
name: string;
|
||||
memberIds: string[];
|
||||
archived: boolean;
|
||||
};
|
||||
ProjectSettingsDto: {
|
||||
/** @description In board order */
|
||||
statuses: components["schemas"]["WorkflowStatusDto"][];
|
||||
@@ -1786,6 +1794,7 @@ export interface components {
|
||||
customFields: components["schemas"]["CustomFieldDefinitionDto"][];
|
||||
/** @description Definition of done: tickets reach DONE only with their checklist ticked */
|
||||
checklistBeforeDone: boolean;
|
||||
teams: components["schemas"]["TeamDto"][];
|
||||
};
|
||||
ProjectDto: {
|
||||
/** Format: uuid */
|
||||
@@ -1931,6 +1940,16 @@ export interface components {
|
||||
/** @default false */
|
||||
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: {
|
||||
/** @description In board order. Values cannot be removed: archive them instead. */
|
||||
statuses: components["schemas"]["WorkflowStatusBodyDto"][];
|
||||
@@ -1939,6 +1958,8 @@ export interface components {
|
||||
areas: components["schemas"]["AreaOptionBodyDto"][];
|
||||
/** @description Absent: unchanged. Fields cannot be removed: archive them. */
|
||||
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 */
|
||||
checklistBeforeDone?: boolean;
|
||||
};
|
||||
@@ -2051,6 +2072,8 @@ export interface components {
|
||||
type: string;
|
||||
/** @example FRONTEND */
|
||||
area: string | null;
|
||||
/** @description A team id */
|
||||
team: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
/**
|
||||
@@ -2410,6 +2433,11 @@ export interface components {
|
||||
* @example FRONTEND
|
||||
*/
|
||||
area?: string | null;
|
||||
/**
|
||||
* @description A team id of the project's settings
|
||||
* @example FRONTEND
|
||||
*/
|
||||
team?: string | null;
|
||||
priority?: components["schemas"]["IssuePriority"];
|
||||
labels?: string[];
|
||||
/** @description Figma frame (https) for UI changes */
|
||||
@@ -2467,6 +2495,11 @@ export interface components {
|
||||
* @example FRONTEND
|
||||
*/
|
||||
area?: string | null;
|
||||
/**
|
||||
* @description A team id of the project's settings
|
||||
* @example FRONTEND
|
||||
*/
|
||||
team?: string | null;
|
||||
priority?: components["schemas"]["IssuePriority"];
|
||||
labels?: string[];
|
||||
/** @description Figma frame (https) for UI changes */
|
||||
@@ -4946,6 +4979,8 @@ export interface operations {
|
||||
type?: string[];
|
||||
/** @description Area ids */
|
||||
area?: string[];
|
||||
/** @description Team ids */
|
||||
team?: string[];
|
||||
priority?: components["schemas"]["IssuePriority"][];
|
||||
/** @description Pireon user id */
|
||||
assigneeId?: string;
|
||||
|
||||
@@ -37,6 +37,10 @@ const ids = (what: string) =>
|
||||
const issueFields = {
|
||||
description: z.string().max(50000).optional(),
|
||||
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(),
|
||||
labels: z.array(z.string().max(40)).max(20).optional(),
|
||||
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
||||
@@ -57,6 +61,7 @@ const issueFields = {
|
||||
type IssueFields = {
|
||||
description?: string;
|
||||
area?: string;
|
||||
team?: string;
|
||||
priority?: z.infer<typeof PRIORITY>;
|
||||
labels?: string[];
|
||||
figmaUrl?: string;
|
||||
@@ -144,6 +149,7 @@ export class IssuesResolver {
|
||||
categories: z.array(CATEGORY).optional(),
|
||||
types: ids('Ticket type'),
|
||||
areas: ids('Area'),
|
||||
teams: ids('Team'),
|
||||
priorities: z.array(PRIORITY).optional(),
|
||||
assigneeId: z.string().optional().describe('User id'),
|
||||
unassigned: z.boolean().optional(),
|
||||
@@ -187,6 +193,7 @@ export class IssuesResolver {
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
teams?: string[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
@@ -216,6 +223,7 @@ export class IssuesResolver {
|
||||
category: params.categories,
|
||||
type: params.types,
|
||||
area: params.areas,
|
||||
team: params.teams,
|
||||
priority: params.priorities,
|
||||
assigneeId: params.assigneeId,
|
||||
unassigned: params.unassigned,
|
||||
|
||||
@@ -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
|
||||
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»
|
||||
|
||||
`settings.checklistBeforeDone` (desactivada por defecto): con ella, un ticket no pasa a un estado de categoría `DONE`
|
||||
|
||||
@@ -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/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/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/members` | Miembros |
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5).
|
||||
vista.
|
||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||
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.
|
||||
- **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.
|
||||
@@ -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
|
||||
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.
|
||||
- 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.
@@ -86,6 +86,7 @@ const FIELD: Record<string, string> = {
|
||||
description: 'la descripción',
|
||||
type: 'el tipo',
|
||||
area: 'el área',
|
||||
team: 'el equipo',
|
||||
priority: 'la prioridad',
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
|
||||
@@ -41,6 +41,7 @@ export class IssueCreatedEventHandler
|
||||
key: issue.key,
|
||||
type: issue.type,
|
||||
area: issue.area,
|
||||
team: issue.team ?? null,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
|
||||
@@ -350,6 +350,7 @@ export class IssuesService {
|
||||
title: `${issue.title} (copia)`.slice(0, 255),
|
||||
description: issue.description,
|
||||
area: issue.area,
|
||||
team: issue.team,
|
||||
priority: issue.priority,
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
mentionedUserIds,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
ProjectWorkflow,
|
||||
TEAM_MENTION_PREFIX,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectRole,
|
||||
@@ -195,7 +198,12 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
if (event instanceof CommentAddedEvent) {
|
||||
const said = `«${excerpt(event.comment.body)}»`;
|
||||
// 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;
|
||||
drafts.push({
|
||||
userId,
|
||||
@@ -203,6 +211,19 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
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
|
||||
? await this.findComments.findById(event.comment.replyTo)
|
||||
: null;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface IssueFilter {
|
||||
categories?: StatusCategory[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
/** Team ids of the project's settings. */
|
||||
teams?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
|
||||
@@ -44,6 +44,7 @@ export class IssueFactory {
|
||||
const details = normalizeIssueDetails(props);
|
||||
workflow.assertActiveType(props.type);
|
||||
workflow.assertActiveArea(props.area);
|
||||
workflow.assertActiveTeam(props.team);
|
||||
const title = details.title ?? '';
|
||||
assertThat(title.length > 0, 'Issue title is required');
|
||||
if (details.startDate && details.dueDate)
|
||||
@@ -60,6 +61,7 @@ export class IssueFactory {
|
||||
key: `${props.projectKey}-${props.number}`,
|
||||
type: props.type,
|
||||
area: props.area ?? null,
|
||||
team: props.team ?? null,
|
||||
title,
|
||||
description: details.description ?? null,
|
||||
status: startingStatus(props.status, workflow),
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface IssueDetails {
|
||||
type?: string;
|
||||
/** Id of one of the project's areas. */
|
||||
area?: string | null;
|
||||
/** Id of one of the project's teams. */
|
||||
team?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -83,6 +85,8 @@ export interface IssueProps {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
/** Absent in tickets created before teams. */
|
||||
team?: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
/** Id of a status of the project's workflow. */
|
||||
@@ -113,6 +117,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public key!: string;
|
||||
public type!: string;
|
||||
public area: string | null = null;
|
||||
public team: string | null = null;
|
||||
public title!: string;
|
||||
public description: string | null = null;
|
||||
public status!: string;
|
||||
@@ -190,6 +195,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
assertThat(patch.title.length > 0, 'Issue title is required');
|
||||
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
|
||||
if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
|
||||
if (patch.team !== undefined) workflow.assertActiveTeam(patch.team);
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||
}
|
||||
|
||||
@@ -403,4 +403,55 @@ describe('Project settings', () => {
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,22 @@ export interface AreaOption {
|
||||
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. */
|
||||
export enum CustomFieldType {
|
||||
TEXT = 'TEXT',
|
||||
@@ -140,6 +156,8 @@ export interface ProjectSettings {
|
||||
* off; when saving, absent means unchanged.
|
||||
*/
|
||||
checklistBeforeDone?: boolean;
|
||||
/** Absent in projects that never defined any; when saving, absent means unchanged. */
|
||||
teams?: TeamDefinition[];
|
||||
}
|
||||
|
||||
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 {
|
||||
if (id === null || id === undefined) return;
|
||||
const area = this.settings.areas.find((a) => a.id === id);
|
||||
@@ -414,6 +460,20 @@ export function validateProjectSettings(
|
||||
name: normName(a.name),
|
||||
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(
|
||||
normCustomField,
|
||||
);
|
||||
@@ -434,6 +494,7 @@ export function validateProjectSettings(
|
||||
['tipo', issueTypes, previous.issueTypes],
|
||||
['área', areas, previous.areas],
|
||||
['campo', customFields, previous.customFields ?? []],
|
||||
['equipo', teams, previous.teams ?? []],
|
||||
] as const) {
|
||||
const ids = list.map((item) => item.id);
|
||||
const duplicate = ids.find((id, i) => ids.indexOf(id) !== i);
|
||||
@@ -517,6 +578,7 @@ export function validateProjectSettings(
|
||||
...((next.checklistBeforeDone ?? previous.checklistBeforeDone)
|
||||
? { checklistBeforeDone: true }
|
||||
: {}),
|
||||
...(teams.length ? { teams } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,17 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
/** Owners and admins customise statuses, transitions, types and areas. */
|
||||
updateSettings(actorId: string, next: ProjectSettings): void {
|
||||
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;
|
||||
this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings));
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export class IssueReadModel {
|
||||
/** Ids from the project's settings. */
|
||||
type!: string;
|
||||
area!: string | null;
|
||||
/** A team id of the project's settings. */
|
||||
team!: string | null;
|
||||
title!: string;
|
||||
description!: string | null;
|
||||
status!: string;
|
||||
|
||||
@@ -451,6 +451,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
if (filter.types?.length) query['type'] = { $in: filter.types };
|
||||
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.labels?.length)
|
||||
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
||||
|
||||
@@ -428,6 +428,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
||||
parent: rest.parent?.id ? rest.parent : null,
|
||||
rank: rest.rank ?? implicitRank(rest.number),
|
||||
startDate: rest.startDate ?? null,
|
||||
team: rest.team ?? null,
|
||||
customFields: rest.customFields ?? {},
|
||||
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||
// Links recorded before projects had several repositories name none.
|
||||
|
||||
@@ -27,6 +27,13 @@ export interface SettingsPayload {
|
||||
template?: { description?: string | null; checklist?: string[] } | null;
|
||||
}[];
|
||||
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. */
|
||||
checklistBeforeDone?: boolean;
|
||||
/** Absent when saving: unchanged. */
|
||||
@@ -44,6 +51,7 @@ export function toSettingsPayload(settings: ProjectSettings): SettingsPayload {
|
||||
...settings,
|
||||
customFields: settings.customFields ?? [],
|
||||
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||
teams: settings.teams ?? [],
|
||||
transitions: settings.statuses.map((s) => ({
|
||||
from: s.id,
|
||||
to: settings.transitions[s.id] ?? [],
|
||||
@@ -75,6 +83,7 @@ export function fromSettingsPayload(payload: SettingsPayload): ProjectSettings {
|
||||
: {}),
|
||||
})),
|
||||
areas: payload.areas,
|
||||
...(payload.teams ? { teams: payload.teams } : {}),
|
||||
...(payload.checklistBeforeDone != null
|
||||
? { checklistBeforeDone: payload.checklistBeforeDone }
|
||||
: {}),
|
||||
|
||||
@@ -128,6 +128,11 @@ abstract class IssueFieldsInput {
|
||||
@Matches(SETTING_ID)
|
||||
area?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'A team id' })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID)
|
||||
team?: string | null;
|
||||
|
||||
@Field(() => IssuePriority, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
@@ -515,6 +520,10 @@ export class IssueFilterInput {
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
areas?: string[];
|
||||
@Field(() => [String], { nullable: true, description: 'Team ids' })
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
teams?: string[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
@@ -712,6 +721,18 @@ export class AreaOptionInput {
|
||||
@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()
|
||||
export class CustomFieldDefinitionInput {
|
||||
@Field() @Matches(SETTING_ID) id!: string;
|
||||
@@ -773,6 +794,17 @@ export class ProjectSettingsInput {
|
||||
@Type(() => 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({
|
||||
nullable: true,
|
||||
description:
|
||||
|
||||
@@ -104,6 +104,17 @@ export class CustomFieldValueGql {
|
||||
@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', {
|
||||
description:
|
||||
'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)',
|
||||
})
|
||||
checklistBeforeDone!: boolean;
|
||||
@Field(() => [TeamGql]) teams!: TeamGql[];
|
||||
}
|
||||
|
||||
@ObjectType('Project')
|
||||
@@ -366,6 +378,8 @@ export class IssueGql {
|
||||
type!: string;
|
||||
@Field(() => String, { nullable: true, description: 'An area id' })
|
||||
area!: string | null;
|
||||
@Field(() => String, { nullable: true, description: 'A team id' })
|
||||
team!: string | null;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) description!: string | null;
|
||||
@Field({ description: "A status id of the project's settings" })
|
||||
|
||||
@@ -243,6 +243,28 @@ export class AreaOptionBodyDto {
|
||||
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 {
|
||||
@ApiProperty({ example: 'BROWSER' }) @Matches(SETTING_ID) id!: string;
|
||||
@ApiProperty({ example: 'Navegador', maxLength: 40 })
|
||||
@@ -319,6 +341,17 @@ export class ProjectSettingsBodyDto {
|
||||
@Type(() => 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({
|
||||
description:
|
||||
'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 })
|
||||
area?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Team ids' })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
team?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
@@ -729,6 +768,16 @@ class IssueFieldsDto {
|
||||
@Matches(SETTING_ID)
|
||||
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' })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority)
|
||||
|
||||
@@ -79,6 +79,13 @@ export class AreaOptionDto {
|
||||
@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 {
|
||||
@ApiProperty({ example: 'BROWSER' }) id!: string;
|
||||
@ApiProperty({ example: 'Navegador' }) name!: string;
|
||||
@@ -108,6 +115,7 @@ export class ProjectSettingsDto {
|
||||
'Definition of done: tickets reach DONE only with their checklist ticked',
|
||||
})
|
||||
checklistBeforeDone!: boolean;
|
||||
@ApiProperty({ type: [TeamDto] }) teams!: TeamDto[];
|
||||
}
|
||||
|
||||
export class ProjectDto {
|
||||
@@ -422,6 +430,8 @@ export class IssueDto {
|
||||
type!: string;
|
||||
@ApiProperty({ type: String, nullable: true, example: 'FRONTEND' })
|
||||
area!: string | null;
|
||||
@ApiProperty({ type: String, nullable: true, description: 'A team id' })
|
||||
team!: string | null;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ example: 'IN_PROGRESS', description: 'A status id' })
|
||||
|
||||
@@ -165,6 +165,7 @@ export class IssuesController {
|
||||
categories: query.category,
|
||||
types: query.type,
|
||||
areas: query.area,
|
||||
teams: query.team,
|
||||
priorities: query.priority,
|
||||
assigneeId: query.assigneeId,
|
||||
unassigned: query.unassigned,
|
||||
|
||||
@@ -234,6 +234,7 @@ describe('ProjectFacade', () => {
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
customFields: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
project.saveSettings(settings);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed, inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
TEAM_MENTION_PREFIX,
|
||||
CreateReleaseInput,
|
||||
CreateSprintInput,
|
||||
ProjectRole,
|
||||
@@ -25,6 +26,20 @@ export class ProjectFacade {
|
||||
|
||||
readonly project = this.store.project;
|
||||
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 releases = this.store.releases;
|
||||
/** Under way, planned, then closed; with their progress. */
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface CreateIssueInput {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
area?: string | null;
|
||||
team?: string | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
@@ -98,6 +99,7 @@ export interface IssueListFilter {
|
||||
search?: string;
|
||||
categories?: StatusCategory[];
|
||||
types?: string[];
|
||||
teams?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
|
||||
@@ -131,6 +131,8 @@ export interface IssueSummary {
|
||||
/** Ids of the project's settings. */
|
||||
type: string;
|
||||
area: string | null;
|
||||
/** A team id of the project's settings. */
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
|
||||
@@ -84,6 +84,17 @@ export interface CustomFieldDefinition {
|
||||
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. */
|
||||
export interface CustomFieldValue {
|
||||
fieldId: string;
|
||||
@@ -99,6 +110,7 @@ export interface ProjectSettings {
|
||||
customFields: CustomFieldDefinition[];
|
||||
/** Definition of done: a DONE status only with the checklist ticked. */
|
||||
checklistBeforeDone?: boolean;
|
||||
teams: TeamDefinition[];
|
||||
}
|
||||
|
||||
export const FINISHED_CATEGORIES: readonly StatusCategory[] = [
|
||||
@@ -134,6 +146,7 @@ const EMPTY: ProjectSettings = {
|
||||
issueTypes: [],
|
||||
areas: [],
|
||||
customFields: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -187,6 +200,15 @@ export class Workflow {
|
||||
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). */
|
||||
activeCustomFields(): CustomFieldDefinition[] {
|
||||
return (this.settings.customFields ?? []).filter((f) => !f.archived);
|
||||
|
||||
@@ -113,5 +113,11 @@ export function toSettingsInput(
|
||||
archived: f.archived,
|
||||
})),
|
||||
checklistBeforeDone: !!settings.checklistBeforeDone,
|
||||
teams: (settings.teams ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
memberIds: t.memberIds,
|
||||
archived: t.archived,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ fragment ProjectFields on Project {
|
||||
archived
|
||||
}
|
||||
checklistBeforeDone
|
||||
teams {
|
||||
id
|
||||
name
|
||||
memberIds
|
||||
archived
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +75,7 @@ fragment IssueSummaryFields on Issue {
|
||||
key
|
||||
type
|
||||
area
|
||||
team
|
||||
title
|
||||
status
|
||||
statusCategory
|
||||
|
||||
@@ -104,6 +104,8 @@ export type CreateIssueInput = {
|
||||
/** When work should start (roadmap), YYYY-MM-DD */
|
||||
startDate?: string | null | undefined;
|
||||
storyPoints?: number | null | undefined;
|
||||
/** A team id */
|
||||
team?: string | null | undefined;
|
||||
title: string;
|
||||
/** A ticket type id */
|
||||
type: string;
|
||||
@@ -194,6 +196,8 @@ export type IssueFilterInput = {
|
||||
sort?: IssueSort | null | undefined;
|
||||
sprintId?: string | null | undefined;
|
||||
statuses?: Array<string> | null | undefined;
|
||||
/** Team ids */
|
||||
teams?: Array<string> | null | undefined;
|
||||
types?: Array<string> | null | undefined;
|
||||
unassigned?: boolean | null | undefined;
|
||||
/** Only tickets not updated since this instant (ISO) */
|
||||
@@ -279,6 +283,8 @@ export type ProjectSettingsInput = {
|
||||
customFields?: Array<CustomFieldDefinitionInput> | null | undefined;
|
||||
issueTypes: Array<IssueTypeOptionInput>;
|
||||
statuses: Array<WorkflowStatusInput>;
|
||||
/** Absent: unchanged. Teams cannot be removed: archive them. */
|
||||
teams?: Array<TeamInput> | null | undefined;
|
||||
transitions: Array<StatusTransitionsInput>;
|
||||
};
|
||||
|
||||
@@ -339,6 +345,13 @@ export type StatusTransitionsInput = {
|
||||
to: Array<string>;
|
||||
};
|
||||
|
||||
export type TeamInput = {
|
||||
archived?: boolean;
|
||||
id: string;
|
||||
memberIds?: Array<string>;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UpdateChecklistItemInput = {
|
||||
done?: boolean | null | undefined;
|
||||
issueId: string;
|
||||
@@ -361,6 +374,8 @@ export type UpdateIssueInput = {
|
||||
/** When work should start (roadmap), YYYY-MM-DD */
|
||||
startDate?: string | null | undefined;
|
||||
storyPoints?: number | null | undefined;
|
||||
/** A team id */
|
||||
team?: string | null | undefined;
|
||||
title?: string | null | undefined;
|
||||
type?: string | null | undefined;
|
||||
};
|
||||
@@ -560,6 +575,12 @@ export type ProjectFieldsFragment = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -569,6 +590,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -602,6 +624,7 @@ export type IssueFieldsFragment = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -780,6 +803,7 @@ export type BoardQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -821,6 +845,7 @@ export type IssueQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -886,6 +911,7 @@ export type SearchIssuesQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1059,6 +1085,7 @@ export type MyIssuesQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1096,6 +1123,7 @@ export type IssueChildrenQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1133,6 +1161,7 @@ export type EpicsQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1218,6 +1247,7 @@ export type BacklogQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1289,6 +1319,7 @@ export type TrashQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1325,6 +1356,7 @@ export type DueIssuesQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1364,6 +1396,7 @@ export type IssueListQuery = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -1559,6 +1592,12 @@ export type MyProjectsQuery = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -1611,6 +1650,12 @@ export type ProjectQuery = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1773,6 +1818,12 @@ export type PublicProjectsQuery = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -2331,6 +2382,7 @@ export type IssueChangedSubscription = {
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
@@ -2466,6 +2518,12 @@ export type ProjectChangedSubscription = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2521,6 +2579,12 @@ export type MyProjectsChangedSubscription = {
|
||||
options: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberIds: Array<string>;
|
||||
archived: boolean;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2909,6 +2973,25 @@ export const ProjectFieldsFragmentDoc = {
|
||||
kind: 'Field',
|
||||
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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ 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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -9655,6 +9750,25 @@ export const MyProjectsDocument = {
|
||||
kind: 'Field',
|
||||
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',
|
||||
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',
|
||||
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: 'type' } },
|
||||
{ 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: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
@@ -14529,6 +14682,25 @@ export const ProjectChangedDocument = {
|
||||
kind: 'Field',
|
||||
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',
|
||||
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' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -477,9 +477,7 @@ export class BoardPage {
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
/** Who `@` offers in the new ticket's description. */
|
||||
protected readonly mentionable = computed(() =>
|
||||
this.project.members().map((m) => m.user),
|
||||
);
|
||||
protected readonly mentionable = this.project.mentionable;
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
|
||||
@@ -854,6 +854,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>
|
||||
<pi-label>Área</pi-label>
|
||||
</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) {
|
||||
<ts-custom-field
|
||||
[field]="field"
|
||||
@@ -1076,6 +1086,18 @@ export class IssuePage {
|
||||
.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>[]>(
|
||||
() => {
|
||||
const workflow = this.project.workflow();
|
||||
@@ -1164,9 +1186,7 @@ export class IssuePage {
|
||||
protected readonly pasteImage: ImageUploader = (file) =>
|
||||
this.facade.uploadImage(file);
|
||||
/** Who `@` offers in what is written here. */
|
||||
protected readonly mentionable = computed(() =>
|
||||
this.project.members().map((m) => m.user),
|
||||
);
|
||||
protected readonly mentionable = this.project.mentionable;
|
||||
/** Pull requests first, then the newest. */
|
||||
protected readonly devLinks = computed(() =>
|
||||
[...(this.facade.issue()?.devLinks ?? [])].sort(
|
||||
@@ -1332,6 +1352,7 @@ export class IssuePage {
|
||||
type?: string;
|
||||
priority?: IssuePriority;
|
||||
area?: string | null;
|
||||
team?: string | null;
|
||||
storyPoints?: number | null;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
|
||||
@@ -112,6 +112,9 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
[options]="releaseFilterOptions()"
|
||||
[(value)]="releaseFilter"
|
||||
/>
|
||||
@if (teamFilterOptions().length > 1) {
|
||||
<pi-select-field [options]="teamFilterOptions()" [(value)]="team" />
|
||||
}
|
||||
@for (field of listFields(); track field.id) {
|
||||
<pi-select-field
|
||||
[options]="field.options"
|
||||
@@ -527,6 +530,18 @@ export class ListPage {
|
||||
protected readonly priority = signal<string | null>(ALL);
|
||||
protected readonly assignee = 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(() => {
|
||||
const id = this.release();
|
||||
if (id) untracked(() => this.releaseFilter.set(id));
|
||||
@@ -626,6 +641,7 @@ export class ListPage {
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
teams: this.team() ? [this.team() as string] : undefined,
|
||||
noEstimate: this.gap() === 'estimate' || undefined,
|
||||
noArea: this.gap() === 'area' || undefined,
|
||||
updatedBefore: this.stale()
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
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 }[] = [
|
||||
{ id: CustomFieldType.TEXT, label: 'Texto' },
|
||||
@@ -530,6 +530,63 @@ function idFrom(name: string): string {
|
||||
</ol>
|
||||
</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 -->
|
||||
<section class="ts-panel ts-block" aria-labelledby="dod-title">
|
||||
<header class="ts-block__head">
|
||||
@@ -1035,6 +1092,11 @@ function idFrom(name: string): string {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
.ts-team-members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1rem;
|
||||
}
|
||||
.ts-empty-fields {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1277,7 +1339,7 @@ export class SettingsPage {
|
||||
const d = this.draft();
|
||||
if (!d) return [];
|
||||
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())))
|
||||
problems.push('Hay nombres vacíos');
|
||||
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 {
|
||||
this.patch((d) => {
|
||||
d.checklistBeforeDone = on;
|
||||
|
||||
Reference in New Issue
Block a user