feat: saved board views and richer board filters

The board now filters by text, type, priority and unassigned tickets as
well. Any combination can be saved as a named view, private or shared with
the project, and opened again with one click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:14:38 +02:00
parent 0606f53026
commit 298383f19f
46 changed files with 2922 additions and 26 deletions

View File

@@ -1007,6 +1007,58 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "type",
"required": false,
"in": "query",
"description": "Ticket type ids",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "priority",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssuePriority"
}
}
},
{
"name": "label",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "search",
"required": false,
"in": "query",
"description": "Matches key, title or description",
"schema": {
"type": "string"
}
},
{
"name": "unassigned",
"required": false,
"in": "query",
"description": "Only tickets without assignee",
"schema": {
"type": "boolean"
}
},
{ {
"name": "perColumn", "name": "perColumn",
"required": false, "required": false,
@@ -5933,6 +5985,293 @@
"Users" "Users"
] ]
} }
},
"/api/projects/{project}/filters": {
"get": {
"operationId": "SavedFiltersController_list",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SavedFilterDto"
}
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "My views of the project's board, then the shared ones",
"tags": [
"Saved filters"
]
},
"post": {
"description": "Project members only. `shared` makes it visible to the whole project (not for viewers).",
"operationId": "SavedFiltersController_save",
"parameters": [
{
"name": "project",
"required": true,
"in": "path",
"description": "Project id or key",
"schema": {
"example": "WEB",
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SaveFilterDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SavedFilterIdDto"
}
}
}
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Save board filters under a name",
"tags": [
"Saved filters"
]
}
},
"/api/filters/{id}": {
"delete": {
"operationId": "SavedFiltersController_delete",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"400": {
"description": "Invalid input",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"401": {
"description": "Missing or rejected Pireon token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"403": {
"description": "Not a member of the project (or not allowed)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Delete a saved view (whoever saved it, or an admin if shared)",
"tags": [
"Saved filters"
]
}
} }
}, },
"info": { "info": {
@@ -8267,6 +8606,174 @@
"required": [ "required": [
"email" "email"
] ]
},
"SavedFilterCriteriaResponseDto": {
"type": "object",
"properties": {
"types": {
"type": "array",
"items": {
"type": "string"
}
},
"areas": {
"type": "array",
"items": {
"type": "string"
}
},
"priorities": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssuePriority"
}
},
"assigneeId": {
"type": "string",
"nullable": true
},
"unassigned": {
"type": "boolean"
},
"releaseId": {
"type": "string",
"nullable": true,
"format": "uuid"
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"search": {
"type": "string",
"nullable": true
}
}
},
"SavedFilterDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"projectId": {
"type": "string",
"format": "uuid"
},
"owner": {
"$ref": "#/components/schemas/UserRefDto"
},
"name": {
"type": "string",
"example": "Bugs críticos de frontend"
},
"shared": {
"type": "boolean",
"description": "Visible to the whole project"
},
"criteria": {
"$ref": "#/components/schemas/SavedFilterCriteriaResponseDto"
},
"createdAt": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"projectId",
"owner",
"name",
"shared",
"criteria",
"createdAt"
]
},
"SavedFilterCriteriaDto": {
"type": "object",
"properties": {
"types": {
"description": "Ticket type ids",
"type": "array",
"items": {
"type": "string"
}
},
"areas": {
"description": "Area ids",
"type": "array",
"items": {
"type": "string"
}
},
"priorities": {
"type": "array",
"items": {
"$ref": "#/components/schemas/IssuePriority"
}
},
"assigneeId": {
"type": "string",
"nullable": true,
"description": "Pireon user id"
},
"unassigned": {
"type": "boolean"
},
"releaseId": {
"type": "string",
"nullable": true,
"format": "uuid"
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"search": {
"type": "string",
"nullable": true,
"maxLength": 200
}
}
},
"SaveFilterDto": {
"type": "object",
"properties": {
"name": {
"type": "string",
"example": "Bugs críticos de frontend",
"maxLength": 60
},
"shared": {
"type": "object",
"default": false,
"description": "Visible to the whole project (members, not viewers, share)"
},
"criteria": {
"$ref": "#/components/schemas/SavedFilterCriteriaDto"
}
},
"required": [
"name",
"criteria"
]
},
"SavedFilterIdDto": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
}
},
"required": [
"id"
]
} }
} }
} }

View File

@@ -399,6 +399,9 @@ type Mutation {
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key. Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
""" """
deleteProject(confirmKey: String!, projectId: ID!): CommandResult! deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
"""Delete a view: whoever saved it, or an admin if it is shared"""
deleteSavedFilter(id: ID!): Boolean!
editComment(body: String!, commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
@@ -422,6 +425,9 @@ type Mutation {
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
removeProjectMember(projectId: ID!, userId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
"""Save the current board filters under a name (members of the project)"""
saveFilter(input: SaveFilterInput!): ID!
""" """
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask. Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
""" """
@@ -501,7 +507,19 @@ type Project {
analytics(days: Int! = 90): ProjectAnalytics! analytics(days: Int! = 90): ProjectAnalytics!
"""Kanban board, one column per status""" """Kanban board, one column per status"""
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]! board(
area: String
assigneeId: ID
labels: [String!]
perColumn: Int! = 50
priorities: [IssuePriority!]
releaseId: ID
"""Matches key, title or description"""
search: String
types: [String!]
unassigned: Boolean
): [BoardColumn!]!
createdAt: String! createdAt: String!
description: String description: String
id: ID! id: ID!
@@ -648,6 +666,9 @@ type Query {
publicProjects: [Project!]! publicProjects: [Project!]!
release(id: ID!): Release! release(id: ID!): Release!
"""My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]!
"""Planned releases of all my projects, soonest target date first""" """Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]! upcomingReleases(limit: Int! = 10): [Release!]!
user(id: String!): User user(id: String!): User
@@ -718,6 +739,51 @@ enum ReleaseStatus {
RELEASED RELEASED
} }
input SaveFilterInput {
criteria: SavedFilterCriteriaInput!
name: String!
projectId: ID!
"""Visible to the whole project (members, not viewers, share)"""
shared: Boolean! = false
}
"""
A named view of the board: private to whoever saved it, or shared with the project
"""
type SavedFilter {
createdAt: String!
criteria: SavedFilterCriteria!
id: ID!
name: String!
owner: UserRef!
projectId: ID!
shared: Boolean!
}
"""What a saved view narrows the board to; empty: everything"""
type SavedFilterCriteria {
areas: [String!]
assigneeId: ID
labels: [String!]
priorities: [IssuePriority!]
releaseId: ID
search: String
types: [String!]
unassigned: Boolean
}
input SavedFilterCriteriaInput {
areas: [String!]
assigneeId: ID
labels: [String!]
priorities: [IssuePriority!]
releaseId: ID
search: String
types: [String!]
unassigned: Boolean
}
""" """
What a status means, whatever each project calls it. DONE and CANCELLED are finished. What a status means, whatever each project calls it. DONE and CANCELLED are finished.
""" """

View File

@@ -871,6 +871,44 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/projects/{project}/filters": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My views of the project's board, then the shared ones */
get: operations["SavedFiltersController_list"];
put?: never;
/**
* Save board filters under a name
* @description Project members only. `shared` makes it visible to the whole project (not for viewers).
*/
post: operations["SavedFiltersController_save"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/filters/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete a saved view (whoever saved it, or an admin if shared) */
delete: operations["SavedFiltersController_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -1616,6 +1654,59 @@ export interface components {
/** @description Copies by e-mail */ /** @description Copies by e-mail */
email: boolean; email: boolean;
}; };
SavedFilterCriteriaResponseDto: {
types?: string[];
areas?: string[];
priorities?: components["schemas"]["IssuePriority"][];
assigneeId?: string | null;
unassigned?: boolean;
/** Format: uuid */
releaseId?: string | null;
labels?: string[];
search?: string | null;
};
SavedFilterDto: {
/** Format: uuid */
id: string;
/** Format: uuid */
projectId: string;
owner: components["schemas"]["UserRefDto"];
/** @example Bugs críticos de frontend */
name: string;
/** @description Visible to the whole project */
shared: boolean;
criteria: components["schemas"]["SavedFilterCriteriaResponseDto"];
/** Format: date-time */
createdAt: string;
};
SavedFilterCriteriaDto: {
/** @description Ticket type ids */
types?: string[];
/** @description Area ids */
areas?: string[];
priorities?: components["schemas"]["IssuePriority"][];
/** @description Pireon user id */
assigneeId?: string | null;
unassigned?: boolean;
/** Format: uuid */
releaseId?: string | null;
labels?: string[];
search?: string | null;
};
SaveFilterDto: {
/** @example Bugs críticos de frontend */
name: string;
/**
* @description Visible to the whole project (members, not viewers, share)
* @default false
*/
shared: Record<string, never>;
criteria: components["schemas"]["SavedFilterCriteriaDto"];
};
SavedFilterIdDto: {
/** Format: uuid */
id: string;
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@@ -2339,6 +2430,14 @@ export interface operations {
assigneeId?: string; assigneeId?: string;
/** @description An area id */ /** @description An area id */
area?: string; area?: string;
/** @description Ticket type ids */
type?: string[];
priority?: components["schemas"]["IssuePriority"][];
label?: string[];
/** @description Matches key, title or description */
search?: string;
/** @description Only tickets without assignee */
unassigned?: boolean;
perColumn?: number; perColumn?: number;
}; };
header?: never; header?: never;
@@ -5702,4 +5801,215 @@ export interface operations {
}; };
}; };
}; };
SavedFiltersController_list: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SavedFilterDto"][];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SavedFiltersController_save: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Project id or key */
project: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SaveFilterDto"];
};
};
responses: {
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SavedFilterIdDto"];
};
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
SavedFiltersController_delete: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Invalid input */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Missing or rejected Pireon token */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not a member of the project (or not allowed) */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
} }

View File

@@ -14,6 +14,7 @@
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
| 🧩 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; quien comenta, los sigue solo |

View File

@@ -157,6 +157,10 @@ stateDiagram-v2
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden. columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
y se borran con el proyecto.
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket. - **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
### Notificaciones ### Notificaciones
@@ -226,7 +230,7 @@ que funcionan también sin token.
### Queries ### Queries
| Query | Devuelve | | Query | Devuelve |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `me` | El usuario autenticado, con sus roles en Pireon | | `me` | El usuario autenticado, con sus roles en Pireon |
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | | `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
| `user(id)` | Un usuario | | `user(id)` | Un usuario |
@@ -234,8 +238,9 @@ que funcionan también sin token.
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) | | `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | | `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
| `publicProjects` | Proyectos públicos (sin token) | | `publicProjects` | Proyectos públicos (sin token) |
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | | `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | | `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | | `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | | `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
@@ -250,6 +255,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
| Comentarios | `addComment`, `editComment`, `deleteComment` | | Comentarios | `addComment`, `editComment`, `deleteComment` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
@@ -453,6 +459,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |

View File

@@ -9,4 +9,6 @@ export * from './lib/changes.service';
export * from './lib/workflows.service'; export * from './lib/workflows.service';
export * from './lib/notifications.service'; export * from './lib/notifications.service';
export * from './lib/notifications'; export * from './lib/notifications';
export * from './lib/saved-filters.service';
export * from './lib/saved-filters/saved-filters';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';

View File

@@ -20,3 +20,4 @@ export * from './tracker-streams.repository';
export * from './attachment-storage'; export * from './attachment-storage';
export * from './notifications.repository'; export * from './notifications.repository';
export * from './notification-mailer'; export * from './notification-mailer';
export * from './saved-filters.repository';

View File

@@ -0,0 +1,14 @@
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
/** Saved board views: per person (or shared with a project), no history. */
export abstract class SavedFiltersRepository {
/** Whoever's own views of the project, then the shared ones, by name. */
abstract findVisible(
projectId: string,
userId: string,
): Promise<SavedFilterReadModel[]>;
abstract findById(id: string): Promise<SavedFilterReadModel | null>;
abstract countOwned(projectId: string, userId: string): Promise<number>;
abstract insert(filter: SavedFilterReadModel): Promise<void>;
abstract delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
SavedFilterCriteriaReadModel,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import {
DeleteSavedFilterCommand,
GetSavedFiltersQuery,
SaveFilterCommand,
} from './saved-filters/saved-filters';
export interface SaveFilterInput {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteriaReadModel;
}
/** Saved board views: mine and the ones shared with the project. */
@Injectable()
export class SavedFiltersService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
list(viewerId: string, projectId: string): Promise<SavedFilterReadModel[]> {
return this.queryBus.execute(new GetSavedFiltersQuery(viewerId, projectId));
}
save(actorId: string, input: SaveFilterInput): Promise<{ id: string }> {
return this.commandBus.execute(
new SaveFilterCommand(
actorId,
input.projectId,
input.name,
input.shared,
input.criteria,
),
);
}
delete(actorId: string, filterId: string): Promise<void> {
return this.commandBus.execute(
new DeleteSavedFilterCommand(actorId, filterId),
);
}
}

View File

@@ -0,0 +1,145 @@
import { CommandBus, CqrsModule, QueryBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
BusinessRuleError,
ForbiddenError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
IssuePriority,
ProjectRole,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import { FakeUserDirectory } from '../../testing/in-memory';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { SavedFiltersRepository } from '../ports/saved-filters.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadAccess } from '../queries/read-access';
import {
DeleteSavedFilterCommand,
GetSavedFiltersQuery,
MAX_SAVED_FILTERS,
SAVED_FILTERS,
SaveFilterCommand,
normalizeCriteria,
} from './saved-filters';
class InMemorySavedFilters extends SavedFiltersRepository {
readonly rows = new Map<string, SavedFilterReadModel>();
async findVisible(projectId: string, userId: string) {
return [...this.rows.values()].filter(
(f) => f.projectId === projectId && (f.owner.id === userId || f.shared),
);
}
async findById(id: string) {
return this.rows.get(id) ?? null;
}
async countOwned(projectId: string, userId: string) {
return [...this.rows.values()].filter(
(f) => f.projectId === projectId && f.owner.id === userId,
).length;
}
async insert(filter: SavedFilterReadModel) {
this.rows.set(filter.id, filter);
}
async delete(id: string) {
this.rows.delete(id);
}
}
const ROLES: Record<string, ProjectRole> = {
owner: ProjectRole.OWNER,
dev: ProjectRole.MEMBER,
qa: ProjectRole.MEMBER,
pm: ProjectRole.VIEWER,
};
async function setup() {
const rows = new InMemorySavedFilters();
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...SAVED_FILTERS,
ReadAccess,
{ provide: SavedFiltersRepository, useValue: rows },
{
provide: UserDirectory,
useValue: new FakeUserDirectory().add('owner').add('dev').add('qa'),
},
{
provide: FindProjectsRepository,
useValue: {
findRole: async (_projectId: string, userId: string) =>
ROLES[userId] ?? null,
findById: async () => ({ isPublic: true }),
},
},
],
}).compile();
await moduleRef.init();
const commands = moduleRef.get(CommandBus);
const queries = moduleRef.get(QueryBus);
const save = (actor: string, name: string, shared = false) =>
commands.execute(
new SaveFilterCommand(actor, 'p1', name, shared, {
priorities: [IssuePriority.HIGH, IssuePriority.HIGH],
labels: [' UI ', 'ui'],
search: ' ',
}),
);
const list = (viewer: string) =>
queries.execute(new GetSavedFiltersQuery(viewer, 'p1'));
const remove = (actor: string, id: string) =>
commands.execute(new DeleteSavedFilterCommand(actor, id));
return { rows, save, list, remove };
}
describe('saved board views', () => {
it('keeps private views private and shares the shared ones', async () => {
const { save, list } = await setup();
await save('dev', 'Mis bugs');
await save('qa', 'Para QA', true);
expect((await list('dev')).map((f) => f.name).sort()).toEqual([
'Mis bugs',
'Para QA',
]);
expect((await list('qa')).map((f) => f.name)).toEqual(['Para QA']);
const [mine] = await list('dev');
expect(mine.criteria).toEqual({
priorities: [IssuePriority.HIGH],
labels: ['ui'],
});
});
it('lets viewers keep views only for themselves, and outsiders none', async () => {
const { save } = await setup();
await expect(save('pm', 'Mía')).resolves.toHaveProperty('id');
await expect(save('pm', 'De todos', true)).rejects.toThrow(ForbiddenError);
await expect(save('stranger', 'Nope')).rejects.toThrow(ForbiddenError);
});
it('deletes: the author always, an admin a shared one, nobody else', async () => {
const { save, remove, rows } = await setup();
const { id: priv } = await save('dev', 'Privada');
const { id: shared } = await save('dev', 'Compartida', true);
await expect(remove('qa', priv)).rejects.toThrow(NotFoundError);
await expect(remove('qa', shared)).rejects.toThrow(ForbiddenError);
await remove('owner', shared);
await remove('dev', priv);
expect(rows.rows.size).toBe(0);
});
it('caps how many views a person keeps per project', async () => {
const { save } = await setup();
for (let i = 0; i < MAX_SAVED_FILTERS; i++) await save('dev', `#${i}`);
await expect(save('dev', 'Una más')).rejects.toThrow(BusinessRuleError);
});
it('drops empty criteria', () => {
expect(
normalizeCriteria({ types: [], assigneeId: 'u', unassigned: true }),
).toEqual({ unassigned: true });
});
});

View File

@@ -0,0 +1,187 @@
import { randomUUID } from 'node:crypto';
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryHandler,
} from '@nestjs/cqrs';
import {
BusinessRuleError,
ForbiddenError,
NotFoundError,
assertThat,
} from '@task-stream/api/shared/domain';
import {
ProjectRole,
SavedFilterCriteriaReadModel,
SavedFilterReadModel,
} from '@task-stream/api/tracker/domain';
import { SavedFiltersRepository } from '../ports/saved-filters.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadAccess } from '../queries/read-access';
/*
* Saved board views. Preferences, like notification settings: they concern
* whoever saved them (or the project, when shared) and need no history.
*/
/** A person keeps at most this many views per project. */
export const MAX_SAVED_FILTERS = 50;
const EDITORS = [ProjectRole.OWNER, ProjectRole.ADMIN, ProjectRole.MEMBER];
const MANAGERS = [ProjectRole.OWNER, ProjectRole.ADMIN];
export class SaveFilterCommand extends Command<{ id: string }> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly name: string,
/** Visible to the whole project, or just to whoever saved it. */
public readonly shared: boolean,
public readonly criteria: SavedFilterCriteriaReadModel,
) {
super();
}
}
export class DeleteSavedFilterCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly filterId: string,
) {
super();
}
}
export class GetSavedFiltersQuery extends Query<SavedFilterReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}
/** Drops empty criteria, trims text and deduplicates lists. */
export function normalizeCriteria(
criteria: SavedFilterCriteriaReadModel,
): SavedFilterCriteriaReadModel {
const list = <T extends string>(values?: T[] | null) =>
values?.length ? [...new Set(values)] : undefined;
const out: SavedFilterCriteriaReadModel = {
types: list(criteria.types),
areas: list(criteria.areas),
priorities: list(criteria.priorities),
labels: list(
criteria.labels?.map((l) => l.trim().toLowerCase()).filter(Boolean),
),
assigneeId: criteria.unassigned
? undefined
: criteria.assigneeId || undefined,
unassigned: criteria.unassigned || undefined,
releaseId: criteria.releaseId || undefined,
search: criteria.search?.trim() || undefined,
};
return Object.fromEntries(
Object.entries(out).filter(([, value]) => value !== undefined),
) as SavedFilterCriteriaReadModel;
}
@CommandHandler(SaveFilterCommand)
export class SaveFilterCommandHandler
implements ICommandHandler<SaveFilterCommand>
{
constructor(
private readonly filters: SavedFiltersRepository,
private readonly access: ReadAccess,
private readonly users: UserDirectory,
) {}
async execute(command: SaveFilterCommand): Promise<{ id: string }> {
const role = await this.access.assertCanView(
command.projectId,
command.actorId,
);
if (!role)
throw new ForbiddenError('Only project members can save board views');
if (command.shared && !EDITORS.includes(role))
throw new ForbiddenError('Viewers can only save views for themselves');
const name = command.name.trim();
assertThat(
name.length > 0 && name.length <= 60,
'A view needs a name (up to 60 characters)',
);
const owned = await this.filters.countOwned(
command.projectId,
command.actorId,
);
if (owned >= MAX_SAVED_FILTERS)
throw new BusinessRuleError(
`You already keep ${MAX_SAVED_FILTERS} views in this project; delete one first`,
);
const users = await this.users.refs([command.actorId]);
const id = randomUUID();
await this.filters.insert({
id,
projectId: command.projectId,
owner: users.of(command.actorId),
name,
shared: command.shared,
criteria: normalizeCriteria(command.criteria),
createdAt: new Date().toISOString(),
});
return { id };
}
}
@CommandHandler(DeleteSavedFilterCommand)
export class DeleteSavedFilterCommandHandler
implements ICommandHandler<DeleteSavedFilterCommand>
{
constructor(
private readonly filters: SavedFiltersRepository,
private readonly access: ReadAccess,
) {}
async execute(command: DeleteSavedFilterCommand): Promise<void> {
const filter = await this.filters.findById(command.filterId);
const mine = filter?.owner.id === command.actorId;
// Someone else's private view: as if it did not exist.
if (!filter || (!mine && !filter.shared))
throw new NotFoundError('SavedFilter', command.filterId);
if (!mine) {
const role = await this.access.assertCanView(
filter.projectId,
command.actorId,
);
if (!role || !MANAGERS.includes(role))
throw new ForbiddenError(
'Only whoever saved a shared view, or a project admin, can delete it',
);
}
await this.filters.delete(filter.id);
}
}
@QueryHandler(GetSavedFiltersQuery)
export class GetSavedFiltersQueryHandler
implements IQueryHandler<GetSavedFiltersQuery, SavedFilterReadModel[]>
{
constructor(
private readonly filters: SavedFiltersRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetSavedFiltersQuery): Promise<SavedFilterReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.filters.findVisible(query.projectId, query.viewerId);
}
}
export const SAVED_FILTERS = [
SaveFilterCommandHandler,
DeleteSavedFilterCommandHandler,
GetSavedFiltersQueryHandler,
];

View File

@@ -29,6 +29,9 @@ import {
UpdateNotificationSettingsCommandHandler, UpdateNotificationSettingsCommandHandler,
} from './notifications/notifications.commands'; } from './notifications/notifications.commands';
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
/** Notifications: derived from events, kept and dismissed per person. */ /** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [ const NOTIFICATIONS = [
Notifier, Notifier,
@@ -47,6 +50,7 @@ const SERVICES = [
ChangesService, ChangesService,
WorkflowsService, WorkflowsService,
NotificationsService, NotificationsService,
SavedFiltersService,
]; ];
/** /**
@@ -68,6 +72,7 @@ const SERVICES = [
...EVENT_HANDLERS, ...EVENT_HANDLERS,
...SAGAS, ...SAGAS,
...NOTIFICATIONS, ...NOTIFICATIONS,
...SAVED_FILTERS,
], ],
exports: SERVICES, exports: SERVICES,
}) })

View File

@@ -7,3 +7,4 @@ export * from './activity.read-model';
export * from './project-stats.read-model'; export * from './project-stats.read-model';
export * from './analytics.read-model'; export * from './analytics.read-model';
export * from './notification.read-model'; export * from './notification.read-model';
export * from './saved-filter.read-model';

View File

@@ -0,0 +1,29 @@
import { IssuePriority } from '../issue/issue.enums';
import { UserRefReadModel } from './user-ref.read-model';
/** What a saved view of the board narrows the tickets to. Empty: everything. */
export class SavedFilterCriteriaReadModel {
/** Ticket type ids of the project's settings. */
types?: string[];
areas?: string[];
priorities?: IssuePriority[];
assigneeId?: string | null;
unassigned?: boolean;
releaseId?: string | null;
labels?: string[];
search?: string | null;
}
/**
* A named set of board filters. Private to whoever saved it, or shared with
* the whole project. Preferences, not history: kept outside the event store.
*/
export class SavedFilterReadModel {
id!: string;
projectId!: string;
owner!: UserRefReadModel;
name!: string;
shared!: boolean;
criteria!: SavedFilterCriteriaReadModel;
createdAt!: string;
}

View File

@@ -7,6 +7,7 @@ import {
IssueTimelineEntry, IssueTimelineEntry,
ProjectReadModel, ProjectReadModel,
ReleaseReadModel, ReleaseReadModel,
SavedFilterReadModel,
implicitRank, implicitRank,
} from '@task-stream/api/tracker/domain'; } from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose'; import { Schema } from 'mongoose';
@@ -26,6 +27,7 @@ export type ReleaseDoc = Doc<ReleaseReadModel>;
export type ActivityDoc = Doc<ActivityReadModel>; export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>; export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>; export type NotificationDoc = Doc<NotificationReadModel>;
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
export interface NotificationSettingsDoc { export interface NotificationSettingsDoc {
_id: string; _id: string;
email: boolean; email: boolean;
@@ -40,6 +42,7 @@ export const ReadModels = {
Timeline: 'IssueTimelineReadModel', Timeline: 'IssueTimelineReadModel',
Notification: 'NotificationReadModel', Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings', NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
} as const; } as const;
const userRef = { _id: false, id: String, name: String }; const userRef = { _id: false, id: String, name: String };
@@ -160,6 +163,20 @@ export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
{ ...loose, collection: 'notification_settings' }, { ...loose, collection: 'notification_settings' },
); );
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
{
_id: String,
projectId: String,
owner: userRef,
name: String,
shared: Boolean,
createdAt: String,
},
{ ...loose, collection: 'saved_filters' },
);
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
SavedFilterSchema.index({ projectId: 1, shared: 1 });
export const READ_MODEL_SCHEMAS = [ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Project, schema: ProjectSchema }, { name: ReadModels.Project, schema: ProjectSchema },
{ name: ReadModels.Issue, schema: IssueSchema }, { name: ReadModels.Issue, schema: IssueSchema },
@@ -169,6 +186,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema }, { name: ReadModels.Notification, schema: NotificationSchema },
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema }, { name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
]; ];
export function fromDoc<T extends { _id: string }>( export function fromDoc<T extends { _id: string }>(

View File

@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { SavedFiltersRepository } from '@task-stream/api/tracker/application';
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
import { Model } from 'mongoose';
import { ReadModels, SavedFilterDoc, fromDoc } from './read-model.schemas';
@Injectable()
export class MongoSavedFiltersRepository extends SavedFiltersRepository {
constructor(
@InjectModel(ReadModels.SavedFilter)
private readonly filters: Model<SavedFilterDoc>,
) {
super();
}
async findVisible(
projectId: string,
userId: string,
): Promise<SavedFilterReadModel[]> {
const docs = await this.filters
.find({ projectId, $or: [{ 'owner.id': userId }, { shared: true }] })
.sort({ shared: 1, name: 1 })
.lean<SavedFilterDoc[]>();
return docs.map((doc) => toFilter(doc));
}
async findById(id: string): Promise<SavedFilterReadModel | null> {
const doc = await this.filters.findById(id).lean<SavedFilterDoc>();
return doc ? toFilter(doc) : null;
}
countOwned(projectId: string, userId: string): Promise<number> {
return this.filters.countDocuments({ projectId, 'owner.id': userId });
}
async insert({ id, ...rest }: SavedFilterReadModel): Promise<void> {
await this.filters.create({ _id: id, ...rest });
}
async delete(id: string): Promise<void> {
await this.filters.deleteOne({ _id: id });
}
}
function toFilter(doc: SavedFilterDoc): SavedFilterReadModel {
const filter = fromDoc(doc) as SavedFilterReadModel;
return { ...filter, criteria: filter.criteria ?? {} };
}

View File

@@ -29,6 +29,7 @@ import {
ProjectDoc, ProjectDoc,
ReadModels, ReadModels,
ReleaseDoc, ReleaseDoc,
SavedFilterDoc,
TimelineDoc, TimelineDoc,
} from './read-model.schemas'; } from './read-model.schemas';
import { upsertIfNewer } from './upsert-if-newer'; import { upsertIfNewer } from './upsert-if-newer';
@@ -44,6 +45,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
private readonly activity: Model<ActivityDoc>, private readonly activity: Model<ActivityDoc>,
@InjectModel(ReadModels.Timeline) @InjectModel(ReadModels.Timeline)
private readonly timeline: Model<TimelineDoc>, private readonly timeline: Model<TimelineDoc>,
@InjectModel(ReadModels.SavedFilter)
private readonly savedFilters: Model<SavedFilterDoc>,
) { ) {
super(); super();
} }
@@ -53,6 +56,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
this.projects.deleteOne({ _id: projectId }), this.projects.deleteOne({ _id: projectId }),
this.activity.deleteMany({ projectId }), this.activity.deleteMany({ projectId }),
this.timeline.deleteMany({ projectId }), this.timeline.deleteMany({ projectId }),
// Views of the board of a project that is gone.
this.savedFilters.deleteMany({ projectId }),
]); ]);
} }

View File

@@ -7,6 +7,7 @@ import {
ChangeFeed, ChangeFeed,
NotificationMailer, NotificationMailer,
NotificationsRepository, NotificationsRepository,
SavedFiltersRepository,
FindActivityRepository, FindActivityRepository,
FindCommentsRepository, FindCommentsRepository,
FindIssuesRepository, FindIssuesRepository,
@@ -56,6 +57,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository'; import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
import { RedisChangeFeed } from './realtime/redis-change-feed'; import { RedisChangeFeed } from './realtime/redis-change-feed';
import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer'; import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage'; import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
@@ -107,6 +109,7 @@ const PORTS = [
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage }, { provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional) // Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository }, { provide: NotificationsRepository, useClass: MongoNotificationsRepository },
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
{ provide: NotificationMailer, useClass: SmtpNotificationMailer }, { provide: NotificationMailer, useClass: SmtpNotificationMailer },
// Realtime → Redis pub/sub // Realtime → Redis pub/sub
{ provide: ChangeFeed, useClass: RedisChangeFeed }, { provide: ChangeFeed, useClass: RedisChangeFeed },

View File

@@ -181,6 +181,57 @@ export class ChangeIssueStatusInput {
reason?: string | null; reason?: string | null;
} }
@InputType()
export class SavedFilterCriteriaInput {
@Field(() => [String], { nullable: true })
@IsOptional()
@Matches(SETTING_ID, { each: true })
types?: string[];
@Field(() => [String], { nullable: true })
@IsOptional()
@Matches(SETTING_ID, { each: true })
areas?: string[];
@Field(() => [IssuePriority], { nullable: true })
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@Field(() => ID, { nullable: true })
@IsOptional()
@IsString()
assigneeId?: string | null;
@Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsUUID()
releaseId?: string | null;
@Field(() => [String], { nullable: true })
@IsOptional()
@ArrayMaxSize(20)
@IsString({ each: true })
labels?: string[];
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string | null;
}
@InputType()
export class SaveFilterInput {
@Field(() => ID) @IsUUID() projectId!: string;
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
@Field({
defaultValue: false,
description: 'Visible to the whole project (members, not viewers, share)',
})
@IsBoolean()
shared!: boolean;
@Field(() => SavedFilterCriteriaInput)
@ValidateNested()
@Type(() => SavedFilterCriteriaInput)
criteria!: SavedFilterCriteriaInput;
}
@InputType() @InputType()
export class RankIssueInput { export class RankIssueInput {
@Field(() => ID) @IsUUID() issueId!: string; @Field(() => ID) @IsUUID() issueId!: string;

View File

@@ -15,7 +15,10 @@ import {
OptionalAuth, OptionalAuth,
} from '@task-stream/api/identity/presenters'; } from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application'; import { ProjectsService } from '@task-stream/api/tracker/application';
import { ProjectReadModel } from '@task-stream/api/tracker/domain'; import {
IssuePriority,
ProjectReadModel,
} from '@task-stream/api/tracker/domain';
import { viewerOf } from '../common/viewer'; import { viewerOf } from '../common/viewer';
import { fromSettingsPayload, toSettingsPayload } from '../common/settings'; import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
import { import {
@@ -108,6 +111,20 @@ export class ProjectResolver {
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string, @Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
@Args('area', { type: () => String, nullable: true }) area?: string, @Args('area', { type: () => String, nullable: true }) area?: string,
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50, @Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
@Args('types', { type: () => [String], nullable: true })
types?: string[] | null,
@Args('priorities', { type: () => [IssuePriority], nullable: true })
priorities?: IssuePriority[] | null,
@Args('labels', { type: () => [String], nullable: true })
labels?: string[] | null,
@Args('search', {
type: () => String,
nullable: true,
description: 'Matches key, title or description',
})
search?: string | null,
@Args('unassigned', { type: () => Boolean, nullable: true })
unassigned?: boolean | null,
) { ) {
return this.projects.board( return this.projects.board(
viewerOf(me), viewerOf(me),
@@ -116,6 +133,11 @@ export class ProjectResolver {
releaseId, releaseId,
assigneeId, assigneeId,
areas: area ? [area] : undefined, areas: area ? [area] : undefined,
types: types ?? undefined,
priorities: priorities ?? undefined,
labels: labels ?? undefined,
search: search?.slice(0, 200) ?? undefined,
unassigned: unassigned ?? undefined,
}, },
perColumn, perColumn,
); );

View File

@@ -0,0 +1,44 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterInput } from './inputs';
import { SavedFilterGql } from './types';
/** Saved views of a project's board. */
@Resolver(() => SavedFilterGql)
export class SavedFilterResolver {
constructor(private readonly filters: SavedFiltersService) {}
@Query(() => [SavedFilterGql], {
description: "My views of the project's board, then the shared ones",
})
savedFilters(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.filters.list(me.id, projectId);
}
@Mutation(() => ID, {
description:
'Save the current board filters under a name (members of the project)',
})
async saveFilter(
@CurrentUser() me: User,
@Args('input') input: SaveFilterInput,
) {
return (await this.filters.save(me.id, input)).id;
}
@Mutation(() => Boolean, {
description: 'Delete a view: whoever saved it, or an admin if it is shared',
})
async deleteSavedFilter(
@CurrentUser() me: User,
@Args('id', { type: () => ID }) id: string,
) {
await this.filters.delete(me.id, id);
return true;
}
}

View File

@@ -198,6 +198,35 @@ export class EpicGql {
@Field(() => ChildProgressGql) progress!: ChildProgressGql; @Field(() => ChildProgressGql) progress!: ChildProgressGql;
} }
@ObjectType('SavedFilterCriteria', {
description: 'What a saved view narrows the board to; empty: everything',
})
export class SavedFilterCriteriaGql {
@Field(() => [String], { nullable: true }) types?: string[];
@Field(() => [String], { nullable: true }) areas?: string[];
@Field(() => [IssuePriority], { nullable: true })
priorities?: IssuePriority[];
@Field(() => ID, { nullable: true }) assigneeId?: string | null;
@Field({ nullable: true }) unassigned?: boolean;
@Field(() => ID, { nullable: true }) releaseId?: string | null;
@Field(() => [String], { nullable: true }) labels?: string[];
@Field(() => String, { nullable: true }) search?: string | null;
}
@ObjectType('SavedFilter', {
description:
'A named view of the board: private to whoever saved it, or shared with the project',
})
export class SavedFilterGql {
@Field(() => ID) id!: string;
@Field(() => ID) projectId!: string;
@Field(() => UserRefGql) owner!: UserRefGql;
@Field() name!: string;
@Field() shared!: boolean;
@Field(() => SavedFilterCriteriaGql) criteria!: SavedFilterCriteriaGql;
@Field() createdAt!: string;
}
@ObjectType('IssuePage') @ObjectType('IssuePage')
export class IssuePageGql { export class IssuePageGql {
@Field(() => [IssueGql]) items!: IssueGql[]; @Field(() => [IssueGql]) items!: IssueGql[];

View File

@@ -251,6 +251,40 @@ export class BoardQueryDto {
@Matches(SETTING_ID) @Matches(SETTING_ID)
area?: string; area?: string;
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
@IsOptional()
@Transform(toList)
@Matches(SETTING_ID, { each: true })
type?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
@IsOptional()
@Transform(toList)
@IsEnum(IssuePriority, { each: true })
priority?: IssuePriority[];
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@Transform(toList)
@IsString({ each: true })
label?: string[];
@ApiPropertyOptional({ description: 'Matches key, title or description' })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@ApiPropertyOptional({ description: 'Only tickets without assignee' })
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
unassigned?: boolean;
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 }) @ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@@ -589,6 +623,76 @@ export class SetIssueReleaseDto {
releaseId!: string | null; releaseId!: string | null;
} }
export class SavedFilterCriteriaDto {
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
@IsOptional()
@Matches(SETTING_ID, { each: true })
types?: string[];
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
@IsOptional()
@Matches(SETTING_ID, { each: true })
areas?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@ApiPropertyOptional({
type: String,
nullable: true,
description: 'Pireon user id',
})
@IsOptional()
@IsString()
assigneeId?: string | null;
@ApiPropertyOptional() @IsOptional() @IsBoolean() unassigned?: boolean;
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
@IsOptional()
@IsString()
releaseId?: string | null;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@ArrayMaxSize(20)
@IsString({ each: true })
labels?: string[];
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 200 })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string | null;
}
export class SaveFilterDto {
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
@IsString()
@MinLength(1)
@MaxLength(60)
name!: string;
@ApiPropertyOptional({
default: false,
description: 'Visible to the whole project (members, not viewers, share)',
})
@IsOptional()
@IsBoolean()
shared = false;
@ApiProperty({ type: SavedFilterCriteriaDto })
@ValidateNested()
@Type(() => SavedFilterCriteriaDto)
criteria!: SavedFilterCriteriaDto;
}
export class RankIssueDto { export class RankIssueDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String, type: String,

View File

@@ -191,6 +191,41 @@ export class IssueDto {
@ApiProperty() version!: number; @ApiProperty() version!: number;
} }
export class SavedFilterCriteriaResponseDto {
@ApiPropertyOptional({ type: [String] }) types?: string[];
@ApiPropertyOptional({ type: [String] }) areas?: string[];
@ApiPropertyOptional({
enum: IssuePriority,
enumName: 'IssuePriority',
isArray: true,
})
priorities?: IssuePriority[];
@ApiPropertyOptional({ type: String, nullable: true })
assigneeId?: string | null;
@ApiPropertyOptional() unassigned?: boolean;
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
releaseId?: string | null;
@ApiPropertyOptional({ type: [String] }) labels?: string[];
@ApiPropertyOptional({ type: String, nullable: true })
search?: string | null;
}
export class SavedFilterDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) projectId!: string;
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
@ApiProperty({ example: 'Bugs críticos de frontend' }) name!: string;
@ApiProperty({ description: 'Visible to the whole project' })
shared!: boolean;
@ApiProperty({ type: SavedFilterCriteriaResponseDto })
criteria!: SavedFilterCriteriaResponseDto;
@ApiProperty({ format: 'date-time' }) createdAt!: string;
}
export class SavedFilterIdDto {
@ApiProperty({ format: 'uuid' }) id!: string;
}
export class IssuePageDto { export class IssuePageDto {
@ApiProperty({ type: [IssueDto] }) items!: IssueDto[]; @ApiProperty({ type: [IssueDto] }) items!: IssueDto[];
@ApiProperty({ description: 'Total matches (for pagination)' }) @ApiProperty({ description: 'Total matches (for pagination)' })

View File

@@ -182,6 +182,11 @@ export class ProjectsController {
releaseId: query.releaseId, releaseId: query.releaseId,
assigneeId: query.assigneeId, assigneeId: query.assigneeId,
areas: query.area ? [query.area] : undefined, areas: query.area ? [query.area] : undefined,
types: query.type,
priorities: query.priority,
labels: query.label,
search: query.search,
unassigned: query.unassigned,
}, },
query.perColumn, query.perColumn,
); );

View File

@@ -0,0 +1,96 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
} from '@nestjs/common';
import {
ApiCreatedResponse,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
import {
ApiCommandErrors,
ApiReadErrors,
} from '@task-stream/api/shared/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterDto } from './dto/requests.dto';
import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto';
import { Refs } from './refs';
/** Saved views of a project's board: mine, and the ones shared with it. */
@ApiTags('Saved filters')
@ApiPireonAuth()
@Controller()
export class SavedFiltersController {
constructor(
private readonly filters: SavedFiltersService,
private readonly refs: Refs,
) {}
@Get('projects/:project/filters')
@ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
})
@ApiOperation({
summary: "My views of the project's board, then the shared ones",
})
@ApiOkResponse({ type: [SavedFilterDto] })
@ApiReadErrors()
async list(@CurrentUser() me: User, @Param('project') project: string) {
return this.filters.list(me.id, await this.refs.projectId(me.id, project));
}
@Post('projects/:project/filters')
@ApiParam({
name: 'project',
description: 'Project id or key',
example: 'WEB',
})
@ApiOperation({
summary: 'Save board filters under a name',
description:
'Project members only. `shared` makes it visible to the whole project (not for viewers).',
})
@ApiCreatedResponse({ type: SavedFilterIdDto })
@ApiCommandErrors()
async save(
@CurrentUser() me: User,
@Param('project') project: string,
@Body() body: SaveFilterDto,
) {
return this.filters.save(me.id, {
...body,
projectId: await this.refs.projectId(me.id, project),
});
}
@Delete('filters/:id')
@HttpCode(204)
@ApiParam({ name: 'id', format: 'uuid' })
@ApiOperation({
summary: 'Delete a saved view (whoever saved it, or an admin if shared)',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async delete(
@CurrentUser() me: User,
@Param('id', ParseUUIDPipe) id: string,
) {
await this.filters.delete(me.id, id);
}
}

View File

@@ -16,6 +16,8 @@ import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs'; import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller'; import { ReleasesController } from './http/releases.controller';
import { NotificationsController } from './http/notifications.controller'; import { NotificationsController } from './http/notifications.controller';
import { SavedFiltersController } from './http/saved-filters.controller';
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
/** /**
* Driving adapters of the tracker context: GraphQL (queries, mutations, * Driving adapters of the tracker context: GraphQL (queries, mutations,
@@ -45,6 +47,7 @@ import { NotificationsController } from './http/notifications.controller';
ReleasesController, ReleasesController,
ExploreController, ExploreController,
NotificationsController, NotificationsController,
SavedFiltersController,
], ],
providers: [ providers: [
Refs, Refs,
@@ -54,6 +57,7 @@ import { NotificationsController } from './http/notifications.controller';
ReleaseResolver, ReleaseResolver,
SubscriptionResolver, SubscriptionResolver,
NotificationResolver, NotificationResolver,
SavedFilterResolver,
], ],
}) })
export class TrackerPresentersModule { export class TrackerPresentersModule {

View File

@@ -6,6 +6,7 @@ import {
CommandResult, CommandResult,
CreateIssueInput, CreateIssueInput,
IssueChanged, IssueChanged,
SavedFilter,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
export const boardPageEvents = eventGroup({ export const boardPageEvents = eventGroup({
@@ -20,6 +21,9 @@ export const boardPageEvents = eventGroup({
reason?: string | null; reason?: string | null;
}>(), }>(),
issueCreateRequested: type<CreateIssueInput>(), issueCreateRequested: type<CreateIssueInput>(),
/** Save the current filter under a name. */
viewSaveRequested: type<{ name: string; shared: boolean }>(),
viewDeleteRequested: type<{ id: string }>(),
}, },
}); });
@@ -31,6 +35,10 @@ export const boardApiEvents = eventGroup({
issueMoveFailed: type<string>(), issueMoveFailed: type<string>(),
issueCreated: type<CommandResult>(), issueCreated: type<CommandResult>(),
issueCreateFailed: type<string>(), issueCreateFailed: type<string>(),
viewsLoaded: type<SavedFilter[]>(),
viewSaved: type<string>(),
viewDeleted: type<string>(),
viewFailed: type<string>(),
}, },
}); });

View File

@@ -1,6 +1,11 @@
import { inject, Injectable, Provider } from '@angular/core'; import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events'; import { injectDispatch } from '@ngrx/signals/events';
import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain'; import {
BoardFilter,
criteriaOf,
CreateIssueInput,
sameCriteria,
} from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events'; import { boardPageEvents } from '../events/board.events';
import { BoardStore } from '../stores/board.store'; import { BoardStore } from '../stores/board.store';
@@ -17,6 +22,17 @@ export class BoardFacade {
readonly loading = this.store.loading; readonly loading = this.store.loading;
readonly creating = this.store.creating; readonly creating = this.store.creating;
readonly error = this.store.error; readonly error = this.store.error;
/** Saved views of this board: mine, then the shared ones. */
readonly views = this.store.views;
/** The saved view that shows exactly what the board shows now, if any. */
readonly activeView = computed(() => {
const filter = this.store.filter();
if (!filter) return null;
const criteria = criteriaOf(filter);
return (
this.store.views().find((v) => sameCriteria(v.criteria, criteria)) ?? null
);
});
/** Shows the board with this filter: opens it for a new project, refilters otherwise. */ /** Shows the board with this filter: opens it for a new project, refilters otherwise. */
show(filter: BoardFilter): void { show(filter: BoardFilter): void {
@@ -33,6 +49,16 @@ export class BoardFacade {
createIssue(input: CreateIssueInput): void { createIssue(input: CreateIssueInput): void {
this.dispatch.issueCreateRequested(input); this.dispatch.issueCreateRequested(input);
} }
/** Saves the current filter as a view: private, or shared with the project. */
saveView(name: string, shared: boolean): void {
if (name.trim())
this.dispatch.viewSaveRequested({ name: name.trim(), shared });
}
deleteView(id: string): void {
this.dispatch.viewDeleteRequested({ id });
}
} }
/** Store + facade of the board, for the board route's component. */ /** Store + facade of the board, for the board route's component. */

View File

@@ -12,6 +12,7 @@ import {
ProjectRole, ProjectRole,
ProjectsGateway, ProjectsGateway,
ReleasesGateway, ReleasesGateway,
SavedFiltersGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
UsersGateway, UsersGateway,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
@@ -69,6 +70,11 @@ function gateways() {
download: vi.fn(() => of(new Blob(['x']))), download: vi.fn(() => of(new Blob(['x']))),
remove: vi.fn(() => of(ok)), remove: vi.fn(() => of(ok)),
}, },
views: {
list: vi.fn(() => of([])),
save: vi.fn(() => of('v1')),
remove: vi.fn(() => of(undefined)),
},
realtime: { realtime: {
issueChanges: () => NEVER, issueChanges: () => NEVER,
commentChanges: () => NEVER, commentChanges: () => NEVER,
@@ -106,6 +112,7 @@ function setup() {
{ provide: AttachmentsGateway, useValue: fakes.attachments }, { provide: AttachmentsGateway, useValue: fakes.attachments },
{ provide: UsersGateway, useValue: { search: () => of([]) } }, { provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime }, { provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{ provide: SavedFiltersGateway, useValue: fakes.views },
{ {
provide: Notifications, provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() }, useValue: { success: vi.fn(), error: vi.fn() },
@@ -141,6 +148,35 @@ describe('BoardFacade', () => {
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' }); expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
expect(board.filter()).toEqual({ projectId: 'p2' }); expect(board.filter()).toEqual({ projectId: 'p2' });
}); });
it('saves the current filter as a view and knows when it is showing one', () => {
const { board, fakes } = setup();
const view = {
id: 'v1',
projectId: 'p1',
name: 'Bugs',
shared: true,
owner: { id: 'me', name: 'Me' },
createdAt: '',
criteria: { types: ['BUG'] },
};
fakes.views.list.mockReturnValue(of([view]) as never);
board.show({ projectId: 'p1', types: ['BUG'], search: ' ' });
board.saveView(' Bugs ', true);
expect(fakes.views.save).toHaveBeenCalledWith({
projectId: 'p1',
name: 'Bugs',
shared: true,
criteria: { types: ['BUG'] },
});
expect(board.activeView()?.id).toBe('v1');
board.show({ projectId: 'p1' });
expect(board.activeView()).toBeNull();
board.deleteView('v1');
expect(fakes.views.remove).toHaveBeenCalledWith('v1');
});
}); });
describe('ProjectFacade', () => { describe('ProjectFacade', () => {

View File

@@ -22,10 +22,29 @@ export function matchesFilter(
filter: BoardFilter | null, filter: BoardFilter | null,
): boolean { ): boolean {
if (!filter) return true; if (!filter) return true;
if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId) if (filter.unassigned) {
if (issue.assignee) return false;
} else if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
return false; return false;
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false; if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
if (filter.area && issue.area !== filter.area) return false; if (filter.area && issue.area !== filter.area) return false;
if (filter.types?.length && !filter.types.includes(issue.type)) return false;
if (filter.priorities?.length && !filter.priorities.includes(issue.priority))
return false;
if (
filter.labels?.length &&
!filter.labels.every((l) => issue.labels.includes(l.toLowerCase()))
)
return false;
// Cards carry no description: a change that matched only by it drops off
// until the next load, which is the safe side.
const text = filter.search?.trim().toLowerCase();
if (
text &&
!issue.key.toLowerCase().includes(text) &&
!issue.title.toLowerCase().includes(text)
)
return false;
return true; return true;
} }

View File

@@ -10,11 +10,15 @@ import {
import { import {
BoardColumn, BoardColumn,
BoardFilter, BoardFilter,
criteriaOf,
IssuesGateway, IssuesGateway,
SavedFilter,
SavedFiltersGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { import {
distinctUntilChanged, distinctUntilChanged,
EMPTY,
exhaustMap, exhaustMap,
map, map,
merge, merge,
@@ -36,6 +40,8 @@ interface BoardState {
loading: boolean; loading: boolean;
creating: boolean; creating: boolean;
error: string | null; error: string | null;
/** Saved views of this board: mine, then the shared ones. */
views: SavedFilter[];
} }
/** Kanban board of a project, live. Scoped to the board route. */ /** Kanban board of a project, live. Scoped to the board route. */
@@ -46,6 +52,7 @@ export const BoardStore = signalStore(
loading: false, loading: false,
creating: false, creating: false,
error: null, error: null,
views: [],
}), }),
withComputed(({ columns }) => ({ withComputed(({ columns }) => ({
total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)), total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)),
@@ -70,6 +77,7 @@ export const BoardStore = signalStore(
on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({ on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({
columns: applyIssueChange(state.columns, payload.issue, state.filter), columns: applyIssueChange(state.columns, payload.issue, state.filter),
})), })),
on(boardApiEvents.viewsLoaded, ({ payload }) => ({ views: payload })),
on(boardPageEvents.issueCreateRequested, () => ({ creating: true })), on(boardPageEvents.issueCreateRequested, () => ({ creating: true })),
on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({ on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({
creating: false, creating: false,
@@ -82,7 +90,16 @@ export const BoardStore = signalStore(
issues = inject(IssuesGateway), issues = inject(IssuesGateway),
realtime = inject(TrackerRealtimeGateway), realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications), notifications = inject(Notifications),
savedFilters = inject(SavedFiltersGateway),
) => { ) => {
const loadViews = (projectId: string) =>
savedFilters.list(projectId).pipe(
mapResponse({
next: (views) => boardApiEvents.viewsLoaded(views),
// Views are a convenience: without them the board still works.
error: () => boardApiEvents.viewsLoaded([]),
}),
);
const load = (filter: BoardFilter) => const load = (filter: BoardFilter) =>
issues.board(filter).pipe( issues.board(filter).pipe(
mapResponse({ mapResponse({
@@ -101,6 +118,50 @@ export const BoardStore = signalStore(
switchMap((projectId) => realtime.issueChanges(projectId)), switchMap((projectId) => realtime.issueChanges(projectId)),
map((change) => boardRealtimeEvents.issueChanged(change)), map((change) => boardRealtimeEvents.issueChanged(change)),
), ),
views$: merge(
events.on(boardPageEvents.opened).pipe(
map(({ payload }) => payload.projectId),
distinctUntilChanged(),
),
events
.on(boardApiEvents.viewSaved, boardApiEvents.viewDeleted)
.pipe(map(() => store.filter()?.projectId ?? null)),
).pipe(
switchMap((projectId) => (projectId ? loadViews(projectId) : EMPTY)),
),
saveView$: events.on(boardPageEvents.viewSaveRequested).pipe(
exhaustMap(({ payload }) => {
const filter = store.filter();
if (!filter) return EMPTY;
return savedFilters
.save({
projectId: filter.projectId,
name: payload.name,
shared: payload.shared,
criteria: criteriaOf(filter),
})
.pipe(
mapResponse({
next: () => boardApiEvents.viewSaved(payload.name),
error: (error) =>
boardApiEvents.viewFailed(describeError(error)),
}),
);
}),
),
deleteView$: events.on(boardPageEvents.viewDeleteRequested).pipe(
mergeMap(({ payload }) => {
const name =
store.views().find((v) => v.id === payload.id)?.name ?? '';
return savedFilters.remove(payload.id).pipe(
mapResponse({
next: () => boardApiEvents.viewDeleted(name),
error: (error) =>
boardApiEvents.viewFailed(describeError(error)),
}),
);
}),
),
move$: events.on(boardPageEvents.issueMoved).pipe( move$: events.on(boardPageEvents.issueMoved).pipe(
mergeMap(({ payload }) => mergeMap(({ payload }) =>
issues issues
@@ -146,7 +207,25 @@ export const BoardStore = signalStore(
), ),
), ),
events events
.on(boardApiEvents.issueCreateFailed, boardApiEvents.loadFailed) .on(boardApiEvents.viewSaved)
.pipe(
tap(({ payload }) =>
notifications.success(`Vista «${payload}» guardada`),
),
),
events
.on(boardApiEvents.viewDeleted)
.pipe(
tap(({ payload }) =>
notifications.success(`Vista «${payload}» eliminada`),
),
),
events
.on(
boardApiEvents.issueCreateFailed,
boardApiEvents.loadFailed,
boardApiEvents.viewFailed,
)
.pipe(tap(({ payload }) => notifications.error(payload))), .pipe(tap(({ payload }) => notifications.error(payload))),
), ),
}; };

View File

@@ -5,3 +5,4 @@ export * from './lib/models/workflow';
export * from './lib/ports/gateways'; export * from './lib/ports/gateways';
export * from './lib/models/mentions'; export * from './lib/models/mentions';
export * from './lib/models/hierarchy'; export * from './lib/models/hierarchy';
export * from './lib/models/views';

View File

@@ -76,6 +76,14 @@ export interface MyIssuesFilter {
export interface BoardFilter { export interface BoardFilter {
projectId: string; projectId: string;
assigneeId?: string; assigneeId?: string;
/** Only tickets nobody has (wins over `assigneeId`). */
unassigned?: boolean;
releaseId?: string; releaseId?: string;
area?: string; area?: string;
/** Ticket type ids. */
types?: string[];
priorities?: IssuePriority[];
labels?: string[];
/** Matches key, title or description. */
search?: string;
} }

View File

@@ -70,6 +70,29 @@ export interface Epic {
progress: ChildProgress; progress: ChildProgress;
} }
/** What a saved view narrows the board to; empty: everything. */
export interface SavedFilterCriteria {
types?: string[] | null;
areas?: string[] | null;
priorities?: IssuePriority[] | null;
assigneeId?: string | null;
unassigned?: boolean | null;
releaseId?: string | null;
labels?: string[] | null;
search?: string | null;
}
/** A named view of the board: private, or shared with the project. */
export interface SavedFilter {
id: string;
projectId: string;
owner: UserRef;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
createdAt: string;
}
/** What boards and lists show. */ /** What boards and lists show. */
export interface IssueSummary { export interface IssueSummary {
id: string; id: string;

View File

@@ -0,0 +1,43 @@
import { IssuePriority } from './enums';
import { boardFilterOf, criteriaOf, sameCriteria } from './views';
describe('saved views', () => {
it('turns a board filter into criteria and back', () => {
const filter = {
projectId: 'p1',
assigneeId: 'u1',
area: 'FRONTEND',
types: ['BUG'],
priorities: [IssuePriority.HIGH],
search: ' login ',
};
const criteria = criteriaOf(filter);
expect(criteria).toEqual({
assigneeId: 'u1',
areas: ['FRONTEND'],
types: ['BUG'],
priorities: [IssuePriority.HIGH],
search: 'login',
});
expect(boardFilterOf('p1', criteria)).toEqual({
...filter,
search: 'login',
});
});
it('prefers "nobody" over a person', () => {
expect(
criteriaOf({ projectId: 'p', unassigned: true, assigneeId: 'u' }),
).toEqual({ unassigned: true });
});
it('compares criteria regardless of list order and empty values', () => {
expect(
sameCriteria(
{ types: ['BUG', 'QA'], labels: [], search: null },
{ types: ['QA', 'BUG'] },
),
).toBe(true);
expect(sameCriteria({ types: ['BUG'] }, {})).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import { BoardFilter } from './inputs';
import { SavedFilterCriteria } from './models';
const has = <T>(values?: T[] | null): values is T[] => !!values?.length;
/** The board filter as saved criteria (no project, nothing empty). */
export function criteriaOf(filter: BoardFilter): SavedFilterCriteria {
const criteria: SavedFilterCriteria = {};
if (filter.unassigned) criteria.unassigned = true;
else if (filter.assigneeId) criteria.assigneeId = filter.assigneeId;
if (filter.releaseId) criteria.releaseId = filter.releaseId;
if (filter.area) criteria.areas = [filter.area];
if (has(filter.types)) criteria.types = [...filter.types];
if (has(filter.priorities)) criteria.priorities = [...filter.priorities];
if (has(filter.labels)) criteria.labels = [...filter.labels];
if (filter.search?.trim()) criteria.search = filter.search.trim();
return criteria;
}
/** Saved criteria as the board filter of a project. */
export function boardFilterOf(
projectId: string,
criteria: SavedFilterCriteria,
): BoardFilter {
const filter: BoardFilter = { projectId };
if (criteria.unassigned) filter.unassigned = true;
else if (criteria.assigneeId) filter.assigneeId = criteria.assigneeId;
if (criteria.releaseId) filter.releaseId = criteria.releaseId;
if (has(criteria.areas)) filter.area = criteria.areas[0];
if (has(criteria.types)) filter.types = [...criteria.types];
if (has(criteria.priorities)) filter.priorities = [...criteria.priorities];
if (has(criteria.labels)) filter.labels = [...criteria.labels];
if (criteria.search?.trim()) filter.search = criteria.search.trim();
return filter;
}
/** Whether two filters show the same tickets (order of lists aside). */
export function sameCriteria(
a: SavedFilterCriteria,
b: SavedFilterCriteria,
): boolean {
const norm = (c: SavedFilterCriteria) =>
JSON.stringify(
Object.entries(c)
.filter(
([, v]) =>
v !== null &&
v !== undefined &&
v !== '' &&
v !== false &&
!(Array.isArray(v) && !v.length),
)
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
.sort(([x], [y]) => String(x).localeCompare(String(y))),
);
return norm(a) === norm(b);
}

View File

@@ -5,6 +5,8 @@ import {
BoardColumn, BoardColumn,
Comment, Comment,
Epic, Epic,
SavedFilter,
SavedFilterCriteria,
CommandResult, CommandResult,
Issue, Issue,
IssuePage, IssuePage,
@@ -124,6 +126,18 @@ export abstract class IssuesGateway {
): Observable<CommandResult>; ): Observable<CommandResult>;
} }
/** Saved views of a project's board (mine and the shared ones). */
export abstract class SavedFiltersGateway {
abstract list(projectId: string): Observable<SavedFilter[]>;
abstract save(input: {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
}): Observable<string>;
abstract remove(id: string): Observable<void>;
}
export abstract class CommentsGateway { export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>; abstract list(issueId: string): Observable<Comment[]>;
abstract add(issueId: string, body: string): Observable<CommandResult>; abstract add(issueId: string, body: string): Observable<CommandResult>;

View File

@@ -52,6 +52,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
releaseId: filter.releaseId ?? null, releaseId: filter.releaseId ?? null,
assigneeId: filter.assigneeId ?? null, assigneeId: filter.assigneeId ?? null,
area: filter.area ?? null, area: filter.area ?? null,
types: filter.types?.length ? filter.types : null,
priorities: filter.priorities?.length
? (filter.priorities as never)
: null,
labels: filter.labels?.length ? filter.labels : null,
search: filter.search?.trim() || null,
unassigned: filter.unassigned ?? null,
}) })
.pipe( .pipe(
map((d) => map((d) =>

View File

@@ -0,0 +1,41 @@
import { inject, Injectable } from '@angular/core';
import {
SavedFilter,
SavedFilterCriteria,
SavedFiltersGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
DeleteSavedFilterDocument,
SavedFiltersDocument,
SaveFilterDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<SavedFilter[]> {
return this.gql
.query(SavedFiltersDocument, { projectId })
.pipe(map((d) => d.savedFilters as SavedFilter[]));
}
save(input: {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
}): Observable<string> {
return this.gql
.mutate(SaveFilterDocument, { input: input as never })
.pipe(map((d) => d.saveFilter));
}
remove(id: string): Observable<void> {
return this.gql
.mutate(DeleteSavedFilterDocument, { id })
.pipe(map(() => undefined));
}
}

View File

@@ -147,6 +147,25 @@ export type RankIssueInput = {
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED'; export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
export type SaveFilterInput = {
criteria: SavedFilterCriteriaInput;
name: string;
projectId: string;
/** Visible to the whole project (members, not viewers, share) */
shared?: boolean;
};
export type SavedFilterCriteriaInput = {
areas?: Array<string> | null | undefined;
assigneeId?: string | null | undefined;
labels?: Array<string> | null | undefined;
priorities?: Array<IssuePriority> | null | undefined;
releaseId?: string | null | undefined;
search?: string | null | undefined;
types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined;
};
/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */ /** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */
export type StatusCategory = export type StatusCategory =
| 'CANCELLED' | 'CANCELLED'
@@ -335,6 +354,11 @@ export type BoardQueryVariables = Exact<{
releaseId?: string | null | undefined; releaseId?: string | null | undefined;
assigneeId?: string | null | undefined; assigneeId?: string | null | undefined;
area?: string | null | undefined; area?: string | null | undefined;
types?: Array<string> | string | null | undefined;
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
labels?: Array<string> | string | null | undefined;
search?: string | null | undefined;
unassigned?: boolean | null | undefined;
}>; }>;
export type BoardQuery = { export type BoardQuery = {
@@ -1138,6 +1162,62 @@ export type ReleaseBurnupQuery = {
}; };
}; };
export type SavedFilterFieldsFragment = {
id: string;
projectId: string;
name: string;
shared: boolean;
createdAt: string;
owner: { id: string; name: string };
criteria: {
types: Array<string> | null;
areas: Array<string> | null;
priorities: Array<IssuePriority> | null;
assigneeId: string | null;
unassigned: boolean | null;
releaseId: string | null;
labels: Array<string> | null;
search: string | null;
};
};
export type SavedFiltersQueryVariables = Exact<{
projectId: string;
}>;
export type SavedFiltersQuery = {
savedFilters: Array<{
id: string;
projectId: string;
name: string;
shared: boolean;
createdAt: string;
owner: { id: string; name: string };
criteria: {
types: Array<string> | null;
areas: Array<string> | null;
priorities: Array<IssuePriority> | null;
assigneeId: string | null;
unassigned: boolean | null;
releaseId: string | null;
labels: Array<string> | null;
search: string | null;
};
}>;
};
export type SaveFilterMutationVariables = Exact<{
input: SaveFilterInput;
}>;
export type SaveFilterMutation = { saveFilter: string };
export type DeleteSavedFilterMutationVariables = Exact<{
id: string;
}>;
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
export type IssueChangedSubscriptionVariables = Exact<{ export type IssueChangedSubscriptionVariables = Exact<{
projectId: string; projectId: string;
}>; }>;
@@ -1949,6 +2029,74 @@ export const NotificationFieldsFragmentDoc = {
}, },
], ],
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>; } as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
export const SavedFilterFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'SavedFilterFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SavedFilter' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'shared' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'owner' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'criteria' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
{ kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'unassigned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'search' } },
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
export const BoardDocument = { export const BoardDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -1992,6 +2140,73 @@ export const BoardDocument = {
variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } }, variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
}, },
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'types' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'IssuePriority' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'labels' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'unassigned' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
], ],
selectionSet: { selectionSet: {
kind: 'SelectionSet', kind: 'SelectionSet',
@@ -2041,6 +2256,46 @@ export const BoardDocument = {
name: { kind: 'Name', value: 'area' }, name: { kind: 'Name', value: 'area' },
}, },
}, },
{
kind: 'Argument',
name: { kind: 'Name', value: 'types' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'types' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'priorities' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'labels' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'labels' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'search' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'unassigned' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'unassigned' },
},
},
{ {
kind: 'Argument', kind: 'Argument',
name: { kind: 'Name', value: 'perColumn' }, name: { kind: 'Name', value: 'perColumn' },
@@ -7168,6 +7423,207 @@ export const ReleaseBurnupDocument = {
}, },
], ],
} as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>; } as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>;
export const SavedFiltersDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'SavedFilters' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'savedFilters' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'SavedFilterFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'SavedFilterFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SavedFilter' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'shared' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'owner' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'criteria' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
{ kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'unassigned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'search' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<SavedFiltersQuery, SavedFiltersQueryVariables>;
export const SaveFilterDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SaveFilter' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SaveFilterInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'saveFilter' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<SaveFilterMutation, SaveFilterMutationVariables>;
export const DeleteSavedFilterDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DeleteSavedFilter' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'deleteSavedFilter' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'id' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'id' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
DeleteSavedFilterMutation,
DeleteSavedFilterMutationVariables
>;
export const IssueChangedDocument = { export const IssueChangedDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [

View File

@@ -1,10 +1,25 @@
query Board($idOrKey: String!, $releaseId: ID, $assigneeId: ID, $area: String) { query Board(
$idOrKey: String!
$releaseId: ID
$assigneeId: ID
$area: String
$types: [String!]
$priorities: [IssuePriority!]
$labels: [String!]
$search: String
$unassigned: Boolean
) {
project(idOrKey: $idOrKey) { project(idOrKey: $idOrKey) {
id id
board( board(
releaseId: $releaseId releaseId: $releaseId
assigneeId: $assigneeId assigneeId: $assigneeId
area: $area area: $area
types: $types
priorities: $priorities
labels: $labels
search: $search
unassigned: $unassigned
perColumn: 100 perColumn: 100
) { ) {
status status

View File

@@ -0,0 +1,34 @@
fragment SavedFilterFields on SavedFilter {
id
projectId
name
shared
createdAt
owner {
...UserRefFields
}
criteria {
types
areas
priorities
assigneeId
unassigned
releaseId
labels
search
}
}
query SavedFilters($projectId: ID!) {
savedFilters(projectId: $projectId) {
...SavedFilterFields
}
}
mutation SaveFilter($input: SaveFilterInput!) {
saveFilter(input: $input)
}
mutation DeleteSavedFilter($id: ID!) {
deleteSavedFilter(id: $id)
}

View File

@@ -10,6 +10,7 @@ import {
NotificationsGateway, NotificationsGateway,
ProjectsGateway, ProjectsGateway,
ReleasesGateway, ReleasesGateway,
SavedFiltersGateway,
TrackerRealtimeGateway, TrackerRealtimeGateway,
UsersGateway, UsersGateway,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
@@ -24,6 +25,7 @@ import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.ga
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
/** Ports → GraphQL adapters (and REST for files). */ /** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders { export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -37,5 +39,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: UsersGateway, useClass: GraphqlUsersGateway }, { provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
]); ]);
} }

View File

@@ -6,6 +6,14 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.ts-views {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.ts-filter { .ts-filter {
width: 13rem; width: 13rem;

View File

@@ -23,11 +23,13 @@ import {
PiSegmentedControlComponent, PiSegmentedControlComponent,
PiSelectFieldComponent, PiSelectFieldComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiSlideToggleComponent,
PiTextareaComponent, PiTextareaComponent,
PiTextFieldComponent, PiTextFieldComponent,
type PiSelectFieldOption, type PiSelectFieldOption,
type SegmentedControlOption, type SegmentedControlOption,
} from '@pi/ui'; } from '@pi/ui';
import { AuthFacade } from '@task-stream/web/identity/application';
import { DrawerService } from '@task-stream/web/shared/ui'; import { DrawerService } from '@task-stream/web/shared/ui';
import { import {
BoardFacade, BoardFacade,
@@ -36,14 +38,19 @@ import {
} from '@task-stream/web/tracker/application'; } from '@task-stream/web/tracker/application';
import { import {
BoardColumn, BoardColumn,
BoardFilter,
boardFilterOf,
IssuePriority, IssuePriority,
IssueSummary, IssueSummary,
ReleaseStatus, ReleaseStatus,
SavedFilter,
} from '@task-stream/web/tracker/domain'; } from '@task-stream/web/tracker/domain';
import { IssueCardComponent } from '../ui/issue-card'; import { IssueCardComponent } from '../ui/issue-card';
import { options, PRIORITY, statusLook } from '../ui/copy'; import { options, PRIORITY, statusLook } from '../ui/copy';
const ALL = ''; const ALL = '';
/** "Sin asignar" in the person filter. */
const NOBODY = '__nobody__';
@Component({ @Component({
selector: 'ts-board-page', selector: 'ts-board-page',
@@ -58,6 +65,7 @@ const ALL = '';
PiSelectFieldComponent, PiSelectFieldComponent,
PiSegmentedControlComponent, PiSegmentedControlComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiSlideToggleComponent,
PiTextFieldComponent, PiTextFieldComponent,
PiTextareaComponent, PiTextareaComponent,
PiLabelDirective, PiLabelDirective,
@@ -65,13 +73,70 @@ const ALL = '';
providers: [provideBoardFacade()], providers: [provideBoardFacade()],
styleUrl: './board.page.scss', styleUrl: './board.page.scss',
template: ` template: `
@if (project.myRole()) {
<div class="ts-views" role="group" aria-label="Vistas guardadas">
<pi-select-field
class="ts-filter"
placeholder="Vistas guardadas"
[options]="viewOptions()"
[value]="board.activeView()?.id ?? ''"
(valueChange)="applyView($event)"
/>
@if (!board.activeView() && isFiltered()) {
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Guardar vista"
(execute)="openSaveView()"
/>
}
@if (board.activeView(); as view) {
@if (canDeleteView(view)) {
<pi-action-button
size="small"
color="grey"
appearance="ghost"
[text]="'Borrar «' + view.name + '»'"
(execute)="board.deleteView(view.id)"
/>
}
}
@if (isFiltered()) {
<pi-action-button
size="small"
color="grey"
appearance="ghost"
text="Quitar filtros"
(execute)="clearFilters()"
/>
}
</div>
}
<div class="ts-board-toolbar"> <div class="ts-board-toolbar">
<pi-text-field
class="ts-filter"
placeholder="Buscar en el tablero…"
[(value)]="searchInput"
/>
<pi-select-field <pi-select-field
class="ts-filter" class="ts-filter"
placeholder="Persona" placeholder="Persona"
[options]="assigneeOptions()" [options]="assigneeFilterOptions()"
[(value)]="assigneeFilter" [(value)]="assigneeFilter"
/> />
<pi-select-field
class="ts-filter"
placeholder="Tipo"
[options]="typeFilterOptions()"
[(value)]="typeFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Prioridad"
[options]="priorityFilterOptions"
[(value)]="priorityFilter"
/>
<pi-select-field <pi-select-field
class="ts-filter" class="ts-filter"
placeholder="Release" placeholder="Release"
@@ -208,6 +273,37 @@ const ALL = '';
</form> </form>
</ng-template> </ng-template>
<ng-template #viewForm>
<form
class="ts-form"
(submit)="$event.preventDefault(); saveView()"
novalidate
>
<p class="ts-muted">
Guarda los filtros de ahora para volver a ellos con un clic.
</p>
<pi-text-field
placeholder="Bugs críticos de frontend"
[(value)]="viewName"
>
<pi-label [isRequired]="true">Nombre</pi-label>
</pi-text-field>
@if (project.canContribute()) {
<pi-slide-toggle
label="Compartir con todo el proyecto"
[(checked)]="viewShared"
/>
}
<pi-action-button
size="w100"
color="primary"
text="Guardar vista"
[disabled]="!viewName().trim()"
(execute)="saveView()"
/>
</form>
</ng-template>
<ng-template #reasonForm> <ng-template #reasonForm>
<form <form
class="ts-form" class="ts-form"
@@ -243,15 +339,72 @@ export class BoardPage {
protected readonly board = inject(BoardFacade); protected readonly board = inject(BoardFacade);
protected readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
private readonly drawer = inject(DrawerService); private readonly drawer = inject(DrawerService);
private readonly auth = inject(AuthFacade);
private readonly me = computed(() => this.auth.user()?.id ?? null);
private readonly createForm = private readonly createForm =
viewChild.required<TemplateRef<unknown>>('createForm'); viewChild.required<TemplateRef<unknown>>('createForm');
private readonly reasonForm = private readonly reasonForm =
viewChild.required<TemplateRef<unknown>>('reasonForm'); viewChild.required<TemplateRef<unknown>>('reasonForm');
private readonly viewForm =
viewChild.required<TemplateRef<unknown>>('viewForm');
/* Filters */ /* Filters */
protected readonly assigneeFilter = signal<string | null>(ALL); protected readonly assigneeFilter = signal<string | null>(ALL);
protected readonly releaseFilter = signal<string | null>(ALL); protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly area = signal<string | null>(ALL); protected readonly area = signal<string | null>(ALL);
protected readonly typeFilter = signal<string | null>(ALL);
protected readonly priorityFilter = signal<string | null>(ALL);
/** What is typed; `search` follows it once typing pauses. */
protected readonly searchInput = signal('');
private readonly search = signal('');
/** Labels only come from a saved view (no picker on the toolbar). */
private readonly labels = signal<string[]>([]);
protected readonly isFiltered = computed(
() =>
!!(
this.assigneeFilter() ||
this.releaseFilter() ||
this.area() ||
this.typeFilter() ||
this.priorityFilter() ||
this.search() ||
this.labels().length
),
);
/* Saved views */
protected readonly viewName = signal('');
protected readonly viewShared = signal(false);
protected readonly viewOptions = computed<PiSelectFieldOption<string>[]>(
() => [
{ value: ALL, label: 'Sin vista guardada' },
...this.board.views().map((v) => ({
value: v.id,
label: v.shared ? `${v.name} · compartida` : v.name,
})),
],
);
protected readonly assigneeFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todas las personas' },
{ value: NOBODY, label: 'Sin asignar' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
]);
protected readonly typeFilterOptions = computed<
PiSelectFieldOption<string>[]
>(() => [
{ value: ALL, label: 'Todos los tipos' },
...this.project
.workflow()
.settings.issueTypes.map((t) => ({ value: t.id, label: t.name })),
]);
protected readonly priorityFilterOptions: PiSelectFieldOption<string>[] = [
{ value: ALL, label: 'Todas las prioridades' },
...options(PRIORITY),
];
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>( protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
() => [ () => [
@@ -332,16 +485,36 @@ export class BoardPage {
const assignee = this.assignee(); const assignee = this.assignee();
if (assignee) untracked(() => this.assigneeFilter.set(assignee)); if (assignee) untracked(() => this.assigneeFilter.set(assignee));
}); });
effect((onCleanup) => {
const text = this.searchInput().trim();
const timer = setTimeout(() => this.search.set(text), 300);
onCleanup(() => clearTimeout(timer));
});
// (Re)load whenever the project or a filter changes. // (Re)load whenever the project or a filter changes.
effect(() => { effect(() => {
const projectId = this.project.project()?.id; const projectId = this.project.project()?.id;
const filter = { const assignee = this.assigneeFilter();
assigneeId: this.assigneeFilter() || undefined, const type = this.typeFilter();
const priority = this.priorityFilter() as IssuePriority | null;
const filter: Omit<BoardFilter, 'projectId'> = {
unassigned: assignee === NOBODY || undefined,
assigneeId: (assignee !== NOBODY && assignee) || undefined,
releaseId: this.releaseFilter() || undefined, releaseId: this.releaseFilter() || undefined,
area: this.area() || undefined, area: this.area() || undefined,
types: type ? [type] : undefined,
priorities: priority ? [priority] : undefined,
labels: this.labels().length ? this.labels() : undefined,
search: this.search() || undefined,
}; };
if (!projectId) return; if (!projectId) return;
untracked(() => this.board.show({ projectId, ...filter })); untracked(() =>
this.board.show({
projectId,
...(Object.fromEntries(
Object.entries(filter).filter(([, v]) => v !== undefined),
) as Omit<BoardFilter, 'projectId'>),
}),
);
}); });
} }
@@ -383,6 +556,62 @@ export class BoardPage {
this.drawer.closeAll(); this.drawer.closeAll();
} }
/** Shows what a saved view shows (the toolbar picks the first of each list). */
protected applyView(id: string | null): void {
const view = this.board.views().find((v) => v.id === id);
if (!view) {
if (!id) this.clearFilters();
return;
}
const projectId = this.project.project()?.id ?? '';
const filter = boardFilterOf(projectId, view.criteria);
this.assigneeFilter.set(
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
);
this.releaseFilter.set(filter.releaseId ?? ALL);
this.area.set(filter.area ?? ALL);
this.typeFilter.set(filter.types?.[0] ?? ALL);
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
this.labels.set(filter.labels ?? []);
this.searchInput.set(filter.search ?? '');
this.search.set(filter.search ?? '');
}
protected clearFilters(): void {
for (const filter of [
this.assigneeFilter,
this.releaseFilter,
this.area,
this.typeFilter,
this.priorityFilter,
])
filter.set(ALL);
this.labels.set([]);
this.searchInput.set('');
this.search.set('');
}
protected openSaveView(): void {
this.viewName.set('');
this.viewShared.set(false);
this.drawer.open('Guardar vista', this.viewForm());
}
protected saveView(): void {
if (!this.viewName().trim()) return;
this.board.saveView(
this.viewName(),
this.viewShared() && this.project.canContribute(),
);
this.drawer.closeAll();
}
protected canDeleteView(view: SavedFilter): boolean {
return (
view.owner.id === this.me() || (view.shared && this.project.canManage())
);
}
protected openCreate(): void { protected openCreate(): void {
const types = this.typeOptions(); const types = this.typeOptions();
if (!types.some((t) => t.value === this.newType())) if (!types.some((t) => t.value === this.newType()))