feat: tickets I follow, and more news for followers
"Que sigo" on the home page lists the open tickets I follow, whoever works on them; `watching` filters my issues (REST, GraphQL, MCP) and a project's search (none without a session). Followers now also hear when a ticket's priority or due date changes, and when it enters or leaves a sprint. Smaller edits (title, labels…) still do not notify. Docs (with the REPLIED row the replies change missed), Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2018,6 +2018,15 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "watching",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only the tickets I follow (with assignedToMe=false, whoever they are assigned to)",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -2278,6 +2287,15 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "watching",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only the tickets I follow (none without a token)",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "dueFrom",
|
"name": "dueFrom",
|
||||||
"required": false,
|
"required": false,
|
||||||
|
|||||||
@@ -508,6 +508,9 @@ input IssueFilterInput {
|
|||||||
statuses: [String!]
|
statuses: [String!]
|
||||||
types: [String!]
|
types: [String!]
|
||||||
unassigned: Boolean
|
unassigned: Boolean
|
||||||
|
|
||||||
|
"""Only the tickets I follow (none when anonymous)"""
|
||||||
|
watching: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssueLink {
|
type IssueLink {
|
||||||
@@ -960,6 +963,9 @@ type Query {
|
|||||||
"""Matches key, title or description (global search)"""
|
"""Matches key, title or description (global search)"""
|
||||||
search: String
|
search: String
|
||||||
statuses: [String!]
|
statuses: [String!]
|
||||||
|
|
||||||
|
"""Only the tickets I follow"""
|
||||||
|
watching: Boolean! = false
|
||||||
): IssuePage!
|
): IssuePage!
|
||||||
|
|
||||||
"""My notifications, newest first, and how many are unread"""
|
"""My notifications, newest first, and how many are unread"""
|
||||||
|
|||||||
@@ -3933,6 +3933,8 @@ export interface operations {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
/** @description true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA) */
|
/** @description true: tickets assigned to me. false: any ticket of my projects (e.g. ?assignedToMe=false&category=QA) */
|
||||||
assignedToMe?: boolean;
|
assignedToMe?: boolean;
|
||||||
|
/** @description Only the tickets I follow (with assignedToMe=false, whoever they are assigned to) */
|
||||||
|
watching?: boolean;
|
||||||
/** @description Status ids. Repeat the parameter or separate with commas */
|
/** @description Status ids. Repeat the parameter or separate with commas */
|
||||||
status?: string[];
|
status?: string[];
|
||||||
/** @description Whatever each project calls its statuses */
|
/** @description Whatever each project calls its statuses */
|
||||||
@@ -4017,6 +4019,8 @@ export interface operations {
|
|||||||
sprintId?: string;
|
sprintId?: string;
|
||||||
/** @description Only tickets in no sprint */
|
/** @description Only tickets in no sprint */
|
||||||
noSprint?: boolean;
|
noSprint?: boolean;
|
||||||
|
/** @description Only the tickets I follow (none without a token) */
|
||||||
|
watching?: boolean;
|
||||||
/** @description Due on or after this day */
|
/** @description Due on or after this day */
|
||||||
dueFrom?: string;
|
dueFrom?: string;
|
||||||
/** @description Due on or before this day */
|
/** @description Due on or before this day */
|
||||||
|
|||||||
@@ -67,9 +67,15 @@ export class IssuesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'my_issues',
|
name: 'my_issues',
|
||||||
description:
|
description:
|
||||||
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).",
|
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing); watching: only the ones they follow.",
|
||||||
paramsSchema: z.object({
|
paramsSchema: z.object({
|
||||||
assignedToMe: z.boolean().default(true),
|
assignedToMe: z.boolean().default(true),
|
||||||
|
watching: z
|
||||||
|
.boolean()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
'Only the tickets the user follows (use with assignedToMe false)',
|
||||||
|
),
|
||||||
categories: z
|
categories: z
|
||||||
.array(CATEGORY)
|
.array(CATEGORY)
|
||||||
.optional()
|
.optional()
|
||||||
@@ -81,6 +87,7 @@ export class IssuesResolver {
|
|||||||
mine(
|
mine(
|
||||||
params: {
|
params: {
|
||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
|
watching: boolean;
|
||||||
categories?: z.infer<typeof CATEGORY>[];
|
categories?: z.infer<typeof CATEGORY>[];
|
||||||
limit: number;
|
limit: number;
|
||||||
},
|
},
|
||||||
@@ -92,6 +99,7 @@ export class IssuesResolver {
|
|||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
assignedToMe: params.assignedToMe,
|
assignedToMe: params.assignedToMe,
|
||||||
|
watching: params.watching,
|
||||||
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
},
|
},
|
||||||
@@ -127,6 +135,10 @@ export class IssuesResolver {
|
|||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Only tickets in no sprint (the backlog)'),
|
.describe('Only tickets in no sprint (the backlog)'),
|
||||||
|
watching: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Only the tickets the user follows'),
|
||||||
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
||||||
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
|
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
|
||||||
sort: z
|
sort: z
|
||||||
@@ -154,6 +166,7 @@ export class IssuesResolver {
|
|||||||
parent?: string;
|
parent?: string;
|
||||||
sprintId?: string;
|
sprintId?: string;
|
||||||
noSprint?: boolean;
|
noSprint?: boolean;
|
||||||
|
watching?: boolean;
|
||||||
dueFrom?: string;
|
dueFrom?: string;
|
||||||
dueTo?: string;
|
dueTo?: string;
|
||||||
sort: 'RECENT' | 'RANK';
|
sort: 'RECENT' | 'RANK';
|
||||||
@@ -181,6 +194,7 @@ export class IssuesResolver {
|
|||||||
parent: params.parent,
|
parent: params.parent,
|
||||||
sprintId: params.sprintId,
|
sprintId: params.sprintId,
|
||||||
noSprint: params.noSprint,
|
noSprint: params.noSprint,
|
||||||
|
watching: params.watching,
|
||||||
dueFrom: params.dueFrom,
|
dueFrom: params.dueFrom,
|
||||||
dueTo: params.dueTo,
|
dueTo: params.dueTo,
|
||||||
sort: params.sort,
|
sort: params.sort,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien comenta, los sigue solo |
|
||||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo |
|
||||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ ningún agregado, solo cada uno lee y descarta las suyas.
|
|||||||
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
|
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
|
||||||
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
|
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
|
||||||
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
||||||
|
| Alguien responde a un comentario | Quien escribió el comentario | `REPLIED` |
|
||||||
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
||||||
|
|
||||||
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
|
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
|
||||||
@@ -250,7 +251,11 @@ Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por event
|
|||||||
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
||||||
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
||||||
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
||||||
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). El id es
|
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). Quien
|
||||||
|
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||||
|
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||||
|
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||||
|
`?watching=true`; sin sesión, ninguno). El id es
|
||||||
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
||||||
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
||||||
por defecto); un correo que falla se registra y no bloquea el stream.
|
por defecto); un correo que falla se registra y no bloquea el stream.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ flowchart LR
|
|||||||
| | `get_project_stats` | Cifras de seguimiento |
|
| | `get_project_stats` | Cifras de seguimiento |
|
||||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
| | `get_project_activity` | Actividad reciente |
|
| | `get_project_activity` | Actividad reciente |
|
||||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
|
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo) |
|
||||||
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
|
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
|
||||||
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||||
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -3,7 +3,10 @@ import {
|
|||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
|
IssuePriority,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
|
IssueUpdatedEvent,
|
||||||
mentionedUserIds,
|
mentionedUserIds,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
ProjectMemberAddedEvent,
|
ProjectMemberAddedEvent,
|
||||||
@@ -14,11 +17,30 @@ import {
|
|||||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
import { UserDirectory } from '../ports/user-directory';
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
import { NotificationDraft, Notifier } from './notifier';
|
import { NotificationDraft, Notifier } from './notifier';
|
||||||
|
|
||||||
/* Read by people, like the activity feed: Spanish. */
|
/* Read by people, like the activity feed: Spanish. */
|
||||||
|
|
||||||
|
/** Same labels as the web app. */
|
||||||
|
const PRIORITY: Record<IssuePriority, string> = {
|
||||||
|
[IssuePriority.CRITICAL]: 'Crítica',
|
||||||
|
[IssuePriority.HIGH]: 'Alta',
|
||||||
|
[IssuePriority.MEDIUM]: 'Media',
|
||||||
|
[IssuePriority.LOW]: 'Baja',
|
||||||
|
[IssuePriority.LOWEST]: 'Mínima',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 2026-11-14 → 14 de noviembre de 2026. */
|
||||||
|
const day = (date: string) =>
|
||||||
|
new Date(`${date.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
|
||||||
const excerpt = (text: string, max = 120) => {
|
const excerpt = (text: string, max = 120) => {
|
||||||
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
|
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
|
||||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||||
@@ -35,6 +57,8 @@ const excerpt = (text: string, max = 120) => {
|
|||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
|
IssueUpdatedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
ProjectMemberAddedEvent,
|
ProjectMemberAddedEvent,
|
||||||
)
|
)
|
||||||
@@ -45,6 +69,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
private readonly findProjects: FindProjectsRepository,
|
private readonly findProjects: FindProjectsRepository,
|
||||||
private readonly users: UserDirectory,
|
private readonly users: UserDirectory,
|
||||||
private readonly findComments: FindCommentsRepository,
|
private readonly findComments: FindCommentsRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: TrackerEvent): Promise<void> {
|
async handle(event: TrackerEvent): Promise<void> {
|
||||||
@@ -183,6 +208,14 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Followers also hear of the changes that move their plans.
|
||||||
|
const news = await this.followerNews(event, actor, ticket);
|
||||||
|
if (news) {
|
||||||
|
for (const userId of watcherIds) {
|
||||||
|
drafts.push({ userId, kind: NotificationKind.WATCHED, summary: news });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.notifier.deliver(
|
await this.notifier.deliver(
|
||||||
event,
|
event,
|
||||||
{
|
{
|
||||||
@@ -194,4 +227,34 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
drafts,
|
drafts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a follower should hear of a change of priority, due date or
|
||||||
|
* sprint; null for the other edits (title, labels…), too small to tell.
|
||||||
|
*/
|
||||||
|
private async followerNews(
|
||||||
|
event: TrackerEvent,
|
||||||
|
actor: string,
|
||||||
|
ticket: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (event instanceof IssueUpdatedEvent) {
|
||||||
|
const { priority, dueDate } = event.changes;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (priority) parts.push(`prioridad ${PRIORITY[priority]}`);
|
||||||
|
if (dueDate !== undefined)
|
||||||
|
parts.push(
|
||||||
|
dueDate ? `fecha límite el ${day(dueDate)}` : 'sin fecha límite',
|
||||||
|
);
|
||||||
|
return parts.length
|
||||||
|
? `${actor} cambió ${ticket}: ${parts.join(', ')}`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (event instanceof IssueSprintChangedEvent) {
|
||||||
|
if (!event.sprintId)
|
||||||
|
return `${actor} sacó ${ticket} del sprint y lo devolvió al backlog`;
|
||||||
|
const sprint = await this.findSprints.findById(event.sprintId);
|
||||||
|
return `${actor} metió ${ticket} en ${sprint ? `el sprint ${sprint.name}` : 'un sprint'}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
|
IssuePriority,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
|
IssueUpdatedEvent,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
NotificationReadModel,
|
NotificationReadModel,
|
||||||
NotificationSettingsReadModel,
|
NotificationSettingsReadModel,
|
||||||
@@ -14,6 +17,7 @@ import { FakeUserDirectory } from '../../testing/in-memory';
|
|||||||
import { ChangeFeed } from '../ports/change-feed';
|
import { ChangeFeed } from '../ports/change-feed';
|
||||||
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { NotificationMailer } from '../ports/notification-mailer';
|
import { NotificationMailer } from '../ports/notification-mailer';
|
||||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||||
@@ -108,6 +112,10 @@ function setup(mailEnabled = false) {
|
|||||||
? { id, author: { id: 'pm', name: 'PM' }, deleted: false }
|
? { id, author: { id: 'pm', name: 'PM' }, deleted: false }
|
||||||
: null,
|
: null,
|
||||||
} as unknown as FindCommentsRepository,
|
} as unknown as FindCommentsRepository,
|
||||||
|
{
|
||||||
|
findById: async (id: string) =>
|
||||||
|
id === 's-14' ? { id, name: 'Sprint 14' } : null,
|
||||||
|
} as unknown as FindSprintsRepository,
|
||||||
);
|
);
|
||||||
return { repo, published, mailed, handler };
|
return { repo, published, mailed, handler };
|
||||||
}
|
}
|
||||||
@@ -236,6 +244,60 @@ describe('notifications', () => {
|
|||||||
expect(repo.all[0].summary).toContain('respondió a tu comentario');
|
expect(repo.all[0].summary).toContain('respondió a tu comentario');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tells followers of a new priority, due date or sprint, not of smaller edits', async () => {
|
||||||
|
const { handler, repo } = setup();
|
||||||
|
await handler.handle(
|
||||||
|
at(
|
||||||
|
new IssueUpdatedEvent('ana', 'i-1', 'p-1', {
|
||||||
|
priority: IssuePriority.HIGH,
|
||||||
|
dueDate: '2026-11-14',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await handler.handle(
|
||||||
|
at(new IssueUpdatedEvent('ana', 'i-1', 'p-1', { title: 'Login' }), 3),
|
||||||
|
);
|
||||||
|
await handler.handle(
|
||||||
|
at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', 's-14', null), 4),
|
||||||
|
);
|
||||||
|
await handler.handle(
|
||||||
|
at(new IssueSprintChangedEvent('ana', 'i-1', 'p-1', null, 's-14'), 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repo.all.map((n) => [n.userId, n.kind, n.summary])).toEqual([
|
||||||
|
[
|
||||||
|
'pm',
|
||||||
|
NotificationKind.WATCHED,
|
||||||
|
'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
NotificationKind.WATCHED,
|
||||||
|
'user ana cambió WEB-1: Login roto: prioridad Alta, fecha límite el 14 de noviembre de 2026',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pm',
|
||||||
|
NotificationKind.WATCHED,
|
||||||
|
'user ana metió WEB-1: Login roto en el sprint Sprint 14',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
NotificationKind.WATCHED,
|
||||||
|
'user ana metió WEB-1: Login roto en el sprint Sprint 14',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'pm',
|
||||||
|
NotificationKind.WATCHED,
|
||||||
|
'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'bob',
|
||||||
|
NotificationKind.WATCHED,
|
||||||
|
'user ana sacó WEB-1: Login roto del sprint y lo devolvió al backlog',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
||||||
const { handler, repo, mailed } = setup(true);
|
const { handler, repo, mailed } = setup(true);
|
||||||
repo.emailOn.add('bob');
|
repo.emailOn.add('bob');
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export interface IssueFilter {
|
|||||||
dueTo?: string;
|
dueTo?: string;
|
||||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||||
noSprint?: boolean;
|
noSprint?: boolean;
|
||||||
|
/** Only the ones the viewer follows (the query handler turns it into `watcherId`). */
|
||||||
|
watching?: boolean;
|
||||||
|
/** Only the ones this person follows. */
|
||||||
|
watcherId?: string;
|
||||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||||
sort?: IssueSort;
|
sort?: IssueSort;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -44,6 +48,8 @@ export interface IssueFilter {
|
|||||||
export interface CrossProjectIssueFilter {
|
export interface CrossProjectIssueFilter {
|
||||||
projectIds: string[];
|
projectIds: string[];
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
|
/** Only the ones this person follows. */
|
||||||
|
watcherId?: string;
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ export class SearchIssuesQueryHandler
|
|||||||
|
|
||||||
async execute(query: SearchIssuesQuery): Promise<Page<IssueReadModel>> {
|
async execute(query: SearchIssuesQuery): Promise<Page<IssueReadModel>> {
|
||||||
await this.access.assertCanView(query.filter.projectId, query.viewerId);
|
await this.access.assertCanView(query.filter.projectId, query.viewerId);
|
||||||
|
const { watching, ...filter } = query.filter;
|
||||||
|
// Someone anonymous follows nothing.
|
||||||
|
if (watching && !query.viewerId) return { items: [], total: 0 };
|
||||||
return this.findIssues.search({
|
return this.findIssues.search({
|
||||||
...query.filter,
|
...filter,
|
||||||
|
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
|
||||||
limit: Math.min(query.filter.limit, 200),
|
limit: Math.min(query.filter.limit, 200),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class SearchMyIssuesQueryHandler
|
|||||||
return this.findIssues.searchAcross({
|
return this.findIssues.searchAcross({
|
||||||
projectIds: projects.map((project) => project.id),
|
projectIds: projects.map((project) => project.id),
|
||||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||||
|
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||||
statuses: query.filter.statuses,
|
statuses: query.filter.statuses,
|
||||||
categories: query.filter.categories,
|
categories: query.filter.categories,
|
||||||
search: query.filter.search?.trim().slice(0, 200) || undefined,
|
search: query.filter.search?.trim().slice(0, 200) || undefined,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Page } from '../ports/find-issues.repository';
|
|||||||
export interface MyIssuesFilter {
|
export interface MyIssuesFilter {
|
||||||
/** true: assigned to the viewer. false: any ticket of the viewer's projects. */
|
/** true: assigned to the viewer. false: any ticket of the viewer's projects. */
|
||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
|
/** Only the tickets the viewer follows. */
|
||||||
|
watching?: boolean;
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
|||||||
trashedAt: null,
|
trashedAt: null,
|
||||||
};
|
};
|
||||||
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||||
|
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||||
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
||||||
if (filter.categories?.length)
|
if (filter.categories?.length)
|
||||||
query['statusCategory'] = { $in: filter.categories };
|
query['statusCategory'] = { $in: filter.categories };
|
||||||
@@ -431,6 +432,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
|||||||
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
||||||
if (filter.unassigned) query['assignee'] = null;
|
if (filter.unassigned) query['assignee'] = null;
|
||||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||||
|
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||||
if (filter.search?.trim()) {
|
if (filter.search?.trim()) {
|
||||||
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
|
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
|
||||||
query.$or = [
|
query.$or = [
|
||||||
|
|||||||
@@ -508,6 +508,13 @@ export class IssueFilterInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
noSprint?: boolean;
|
noSprint?: boolean;
|
||||||
|
@Field({
|
||||||
|
nullable: true,
|
||||||
|
description: 'Only the tickets I follow (none when anonymous)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
watching?: boolean;
|
||||||
@Field({
|
@Field({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
description: 'Due on or after this day (YYYY-MM-DD)',
|
description: 'Due on or after this day (YYYY-MM-DD)',
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export class IssueResolver {
|
|||||||
myIssues(
|
myIssues(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
|
@Args('assignedToMe', { defaultValue: true }) assignedToMe: boolean,
|
||||||
|
@Args('watching', {
|
||||||
|
defaultValue: false,
|
||||||
|
description: 'Only the tickets I follow',
|
||||||
|
})
|
||||||
|
watching: boolean,
|
||||||
@Args('statuses', { type: () => [String], nullable: true })
|
@Args('statuses', { type: () => [String], nullable: true })
|
||||||
statuses: string[] | undefined,
|
statuses: string[] | undefined,
|
||||||
@Args('categories', { type: () => [StatusCategory], nullable: true })
|
@Args('categories', { type: () => [StatusCategory], nullable: true })
|
||||||
@@ -113,6 +118,7 @@ export class IssueResolver {
|
|||||||
) {
|
) {
|
||||||
return this.issuesService.mine(me.id, {
|
return this.issuesService.mine(me.id, {
|
||||||
assignedToMe,
|
assignedToMe,
|
||||||
|
watching,
|
||||||
statuses: statuses ?? undefined,
|
statuses: statuses ?? undefined,
|
||||||
categories: categories ?? undefined,
|
categories: categories ?? undefined,
|
||||||
search: search ?? undefined,
|
search: search ?? undefined,
|
||||||
|
|||||||
@@ -389,6 +389,16 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
assignedToMe = true;
|
assignedToMe = true;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
|
description:
|
||||||
|
'Only the tickets I follow (with assignedToMe=false, whoever they are assigned to)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
watching?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: [String],
|
type: [String],
|
||||||
description: 'Status ids. Repeat the parameter or separate with commas',
|
description: 'Status ids. Repeat the parameter or separate with commas',
|
||||||
@@ -509,6 +519,15 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
noSprint?: boolean;
|
noSprint?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
|
description: 'Only the tickets I follow (none without a token)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
watching?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Due on or after this day',
|
description: 'Due on or after this day',
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export class IssuesController {
|
|||||||
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
|
async mine(@CurrentUser() me: User, @Query() query: MyIssuesQueryDto) {
|
||||||
const page = await this.issues.mine(me.id, {
|
const page = await this.issues.mine(me.id, {
|
||||||
assignedToMe: query.assignedToMe,
|
assignedToMe: query.assignedToMe,
|
||||||
|
watching: query.watching,
|
||||||
statuses: query.status,
|
statuses: query.status,
|
||||||
categories: query.category,
|
categories: query.category,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
@@ -164,6 +165,7 @@ export class IssuesController {
|
|||||||
labels: query.label,
|
labels: query.label,
|
||||||
sprintId: query.sprintId,
|
sprintId: query.sprintId,
|
||||||
noSprint: query.noSprint,
|
noSprint: query.noSprint,
|
||||||
|
watching: query.watching,
|
||||||
dueFrom: query.dueFrom,
|
dueFrom: query.dueFrom,
|
||||||
dueTo: query.dueTo,
|
dueTo: query.dueTo,
|
||||||
sort: query.sort,
|
sort: query.sort,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const homeApiEvents = eventGroup({
|
|||||||
loaded: type<{
|
loaded: type<{
|
||||||
mine: IssuePage;
|
mine: IssuePage;
|
||||||
awaitingQa: IssuePage;
|
awaitingQa: IssuePage;
|
||||||
|
following: IssuePage;
|
||||||
releases: Release[];
|
releases: Release[];
|
||||||
}>(),
|
}>(),
|
||||||
loadFailed: type<string>(),
|
loadFailed: type<string>(),
|
||||||
|
|||||||
@@ -390,9 +390,12 @@ describe('HomeFacade', () => {
|
|||||||
({ id: key, key, status, statusCategory, title: key }) as never;
|
({ id: key, key, status, statusCategory, title: key }) as never;
|
||||||
|
|
||||||
it('loads my work and groups it by category, QA failures first', () => {
|
it('loads my work and groups it by category, QA failures first', () => {
|
||||||
const mine = vi.fn((filter: { assignedToMe: boolean }) =>
|
const mine = vi.fn(
|
||||||
|
(filter: { assignedToMe: boolean; watching?: boolean }) =>
|
||||||
of(
|
of(
|
||||||
filter.assignedToMe
|
filter.watching
|
||||||
|
? { items: [issue('API-7', 'IN_REVIEW', 'IN_PROGRESS')], total: 1 }
|
||||||
|
: filter.assignedToMe
|
||||||
? {
|
? {
|
||||||
items: [
|
items: [
|
||||||
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
|
issue('WEB-1', 'IN_PROGRESS', 'IN_PROGRESS'),
|
||||||
@@ -437,6 +440,10 @@ describe('HomeFacade', () => {
|
|||||||
]);
|
]);
|
||||||
expect(home.qaFailed()).toBe(1);
|
expect(home.qaFailed()).toBe(1);
|
||||||
expect(home.awaitingQaTotal()).toBe(1);
|
expect(home.awaitingQaTotal()).toBe(1);
|
||||||
|
expect(mine).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ assignedToMe: false, watching: true }),
|
||||||
|
);
|
||||||
|
expect(home.following().map((i) => i.key)).toEqual(['API-7']);
|
||||||
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
expect(home.releases().map((r) => r.name)).toEqual(['v1.4.0']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export class HomeFacade {
|
|||||||
/** Tickets of my projects in a QA-category status (anyone's). */
|
/** Tickets of my projects in a QA-category status (anyone's). */
|
||||||
readonly awaitingQa = this.store.awaitingQa;
|
readonly awaitingQa = this.store.awaitingQa;
|
||||||
readonly awaitingQaTotal = this.store.awaitingQaTotal;
|
readonly awaitingQaTotal = this.store.awaitingQaTotal;
|
||||||
|
/** Open tickets I follow, whoever works on them. */
|
||||||
|
readonly following = this.store.following;
|
||||||
|
readonly followingTotal = this.store.followingTotal;
|
||||||
/** Planned releases of my projects, soonest first. */
|
/** Planned releases of my projects, soonest first. */
|
||||||
readonly releases = this.store.releases;
|
readonly releases = this.store.releases;
|
||||||
/** False until the first load: show skeletons, not empty states. */
|
/** False until the first load: show skeletons, not empty states. */
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ interface HomeState {
|
|||||||
mineTotal: number;
|
mineTotal: number;
|
||||||
awaitingQa: IssueSummary[];
|
awaitingQa: IssueSummary[];
|
||||||
awaitingQaTotal: number;
|
awaitingQaTotal: number;
|
||||||
|
following: IssueSummary[];
|
||||||
|
followingTotal: number;
|
||||||
releases: Release[];
|
releases: Release[];
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -40,7 +42,7 @@ interface HomeState {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* "My work" across every project I belong to: my open tickets, tickets
|
* "My work" across every project I belong to: my open tickets, tickets
|
||||||
* waiting for QA and upcoming releases. Root-scoped, so coming back to the
|
* waiting for QA, the open ones I follow and upcoming releases. Root-scoped, so coming back to the
|
||||||
* home page shows the last data at once while it refreshes.
|
* home page shows the last data at once while it refreshes.
|
||||||
*/
|
*/
|
||||||
export const HomeStore = signalStore(
|
export const HomeStore = signalStore(
|
||||||
@@ -50,6 +52,8 @@ export const HomeStore = signalStore(
|
|||||||
mineTotal: 0,
|
mineTotal: 0,
|
||||||
awaitingQa: [],
|
awaitingQa: [],
|
||||||
awaitingQaTotal: 0,
|
awaitingQaTotal: 0,
|
||||||
|
following: [],
|
||||||
|
followingTotal: 0,
|
||||||
releases: [],
|
releases: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -82,6 +86,8 @@ export const HomeStore = signalStore(
|
|||||||
mineTotal: payload.mine.total,
|
mineTotal: payload.mine.total,
|
||||||
awaitingQa: payload.awaitingQa.items,
|
awaitingQa: payload.awaitingQa.items,
|
||||||
awaitingQaTotal: payload.awaitingQa.total,
|
awaitingQaTotal: payload.awaitingQa.total,
|
||||||
|
following: payload.following.items,
|
||||||
|
followingTotal: payload.following.total,
|
||||||
releases: payload.releases,
|
releases: payload.releases,
|
||||||
loaded: true,
|
loaded: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -111,6 +117,12 @@ export const HomeStore = signalStore(
|
|||||||
categories: [StatusCategory.QA],
|
categories: [StatusCategory.QA],
|
||||||
limit: 20,
|
limit: 20,
|
||||||
}),
|
}),
|
||||||
|
following: issues.mine({
|
||||||
|
assignedToMe: false,
|
||||||
|
watching: true,
|
||||||
|
categories: [...OPEN_CATEGORIES],
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
releases: releases.upcoming(5),
|
releases: releases.upcoming(5),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
mapResponse({
|
mapResponse({
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface CreateReleaseInput {
|
|||||||
export interface MyIssuesFilter {
|
export interface MyIssuesFilter {
|
||||||
/** true: assigned to me. false: any ticket of my projects. */
|
/** true: assigned to me. false: any ticket of my projects. */
|
||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
|
/** Only the tickets I follow. */
|
||||||
|
watching?: boolean;
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
/** Matches key, title or description. */
|
/** Matches key, title or description. */
|
||||||
search?: string;
|
search?: string;
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
return this.gql
|
return this.gql
|
||||||
.query(MyIssuesDocument, {
|
.query(MyIssuesDocument, {
|
||||||
assignedToMe: filter.assignedToMe,
|
assignedToMe: filter.assignedToMe,
|
||||||
|
watching: filter.watching ?? false,
|
||||||
categories: filter.categories as never,
|
categories: filter.categories as never,
|
||||||
limit: filter.limit,
|
limit: filter.limit,
|
||||||
search: filter.search?.trim() || null,
|
search: filter.search?.trim() || null,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -132,12 +132,14 @@ mutation DeleteComment($commentId: ID!) {
|
|||||||
|
|
||||||
query MyIssues(
|
query MyIssues(
|
||||||
$assignedToMe: Boolean!
|
$assignedToMe: Boolean!
|
||||||
|
$watching: Boolean
|
||||||
$categories: [StatusCategory!]
|
$categories: [StatusCategory!]
|
||||||
$limit: Int!
|
$limit: Int!
|
||||||
$search: String
|
$search: String
|
||||||
) {
|
) {
|
||||||
myIssues(
|
myIssues(
|
||||||
assignedToMe: $assignedToMe
|
assignedToMe: $assignedToMe
|
||||||
|
watching: $watching
|
||||||
categories: $categories
|
categories: $categories
|
||||||
limit: $limit
|
limit: $limit
|
||||||
search: $search
|
search: $search
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ const VISIBLE = 12;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* "Mi trabajo" on the home page, across every project I belong to: my open
|
* "Mi trabajo" on the home page, across every project I belong to: my open
|
||||||
* tickets (QA failures first), tickets waiting for QA and upcoming releases.
|
* tickets (QA failures first), tickets waiting for QA, the open ones I follow
|
||||||
|
* and upcoming releases.
|
||||||
* Reloads when the tab becomes visible again.
|
* Reloads when the tab becomes visible again.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
@@ -147,6 +148,39 @@ const VISIBLE = 12;
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="ts-panel ts-work__panel"
|
||||||
|
aria-labelledby="following-title"
|
||||||
|
>
|
||||||
|
<header class="ts-work__head">
|
||||||
|
<h2 id="following-title">Que sigo</h2>
|
||||||
|
<span class="ts-muted ts-mono">{{ home.followingTotal() }}</span>
|
||||||
|
</header>
|
||||||
|
<ul class="ts-work__list">
|
||||||
|
@for (issue of home.following().slice(0, 6); track issue.id) {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="ts-work__row ts-work__row--qa"
|
||||||
|
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||||
|
[style.--type]="statusTone(issue)"
|
||||||
|
>
|
||||||
|
<span class="ts-key">{{ issue.key }}</span>
|
||||||
|
<span class="ts-work__title">{{ issue.title }}</span>
|
||||||
|
@if (issue.assignee; as assignee) {
|
||||||
|
<span class="ts-work__who" [attr.title]="assignee.name">{{
|
||||||
|
initialsOf(assignee.name)
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="ts-work__empty">
|
||||||
|
No sigues ningún ticket abierto. En la ficha de uno, «Seguir».
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="ts-panel ts-work__panel"
|
class="ts-panel ts-work__panel"
|
||||||
aria-labelledby="releases-title"
|
aria-labelledby="releases-title"
|
||||||
|
|||||||
Reference in New Issue
Block a user