Compare commits
4 Commits
429b52e6f5
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f81cd2b0 | |||
| f36c31e66d | |||
| 425db24eea | |||
| 285bd5c454 |
@@ -3683,6 +3683,108 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/issues/{issue}/visit": {
|
||||
"put": {
|
||||
"description": "Answers when I had opened it before (null: never), to show what others did since. Private to me.",
|
||||
"operationId": "IssuesController_visit",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "issue",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Ticket id or key",
|
||||
"schema": {
|
||||
"example": "WEB-12",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IssueVisitDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "I opened this ticket now",
|
||||
"tags": [
|
||||
"Issues"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/issues/{issue}/follow-tree": {
|
||||
"put": {
|
||||
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.",
|
||||
@@ -8436,6 +8538,116 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}/issue": {
|
||||
"post": {
|
||||
"description": "Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.",
|
||||
"operationId": "CommentsController_turnIntoIssue",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "commentId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TurnCommentIntoIssueDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CommandResultDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Make a ticket of a comment",
|
||||
"tags": [
|
||||
"Comments"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/comments/{commentId}/resolution": {
|
||||
"put": {
|
||||
"description": "Whoever can change the ticket. A conclusion is added as a reply marked as a decision. The web folds a settled conversation.",
|
||||
@@ -17708,6 +17920,20 @@
|
||||
"verdict"
|
||||
]
|
||||
},
|
||||
"IssueVisitDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"previousVisit": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
"description": "My visit before this one (null: first time)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"previousVisit"
|
||||
]
|
||||
},
|
||||
"IssuePageDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18190,6 +18416,22 @@
|
||||
"winner"
|
||||
]
|
||||
},
|
||||
"CommentSpawnDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"example": "WEB-45"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"key"
|
||||
]
|
||||
},
|
||||
"CommentDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18272,6 +18514,13 @@
|
||||
"nullable": true,
|
||||
"format": "date-time"
|
||||
},
|
||||
"spawned": {
|
||||
"description": "Tickets made from it («Convertir en ticket»), oldest first",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CommentSpawnDto"
|
||||
}
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -18300,6 +18549,7 @@
|
||||
"resolved",
|
||||
"resolvedBy",
|
||||
"resolvedAt",
|
||||
"spawned",
|
||||
"createdAt",
|
||||
"editedAt",
|
||||
"version"
|
||||
@@ -18476,6 +18726,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TurnCommentIntoIssueDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"example": "También falla en Android",
|
||||
"maxLength": 200
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "One of the project's ticket type ids"
|
||||
},
|
||||
"asSubtask": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"ResolveThreadDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -318,6 +318,9 @@ type Comment {
|
||||
|
||||
"""Who settled it"""
|
||||
resolvedBy: UserRef
|
||||
|
||||
"""Tickets made from it («Convertir en ticket»), oldest first"""
|
||||
spawned: [CommentSpawn!]!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
@@ -354,6 +357,12 @@ type CommentReaction {
|
||||
users: [UserRef!]!
|
||||
}
|
||||
|
||||
"""A ticket made from a comment"""
|
||||
type CommentSpawn {
|
||||
id: ID!
|
||||
key: String!
|
||||
}
|
||||
|
||||
type CountByAssignee {
|
||||
count: Int!
|
||||
|
||||
@@ -1209,6 +1218,18 @@ type Mutation {
|
||||
"""
|
||||
trashIssue(issueId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets
|
||||
"""
|
||||
turnCommentIntoIssue(
|
||||
asSubtask: Boolean! = false
|
||||
commentId: ID!
|
||||
title: String!
|
||||
|
||||
"""One of the project's ticket type ids"""
|
||||
type: String!
|
||||
): CommandResult!
|
||||
|
||||
"""
|
||||
I am writing on this ticket (send every few seconds while typing): those with it open see it at once via `typing`
|
||||
"""
|
||||
@@ -1258,6 +1279,11 @@ type Mutation {
|
||||
updateSprint(input: UpdateSprintInput!): CommandResult!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
|
||||
"""
|
||||
I opened this ticket now: answers when I had opened it before (null: never), to show what is new since
|
||||
"""
|
||||
visitIssue(issueId: ID!): String
|
||||
|
||||
"""
|
||||
Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one
|
||||
"""
|
||||
|
||||
@@ -632,6 +632,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/issues/{issue}/visit": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* I opened this ticket now
|
||||
* @description Answers when I had opened it before (null: never), to show what others did since. Private to me.
|
||||
*/
|
||||
put: operations["IssuesController_visit"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/issues/{issue}/follow-tree": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1241,6 +1261,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/comments/{commentId}/issue": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Make a ticket of a comment
|
||||
* @description Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.
|
||||
*/
|
||||
post: operations["CommentsController_turnIntoIssue"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/comments/{commentId}/resolution": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3181,6 +3221,13 @@ export interface components {
|
||||
verdict: components["schemas"]["ReviewVerdict"];
|
||||
answer?: string;
|
||||
};
|
||||
IssueVisitDto: {
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description My visit before this one (null: first time)
|
||||
*/
|
||||
previousVisit: string | null;
|
||||
};
|
||||
IssuePageDto: {
|
||||
items: components["schemas"]["IssueDto"][];
|
||||
/** @description Total matches (for pagination) */
|
||||
@@ -3377,6 +3424,11 @@ export interface components {
|
||||
/** @description Index of the winning option once closed (null on a tie) */
|
||||
winner: number | null;
|
||||
};
|
||||
CommentSpawnDto: {
|
||||
id: string;
|
||||
/** @example WEB-45 */
|
||||
key: string;
|
||||
};
|
||||
CommentDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -3407,6 +3459,8 @@ export interface components {
|
||||
resolvedBy: components["schemas"]["UserRefDto"] | null;
|
||||
/** Format: date-time */
|
||||
resolvedAt: string | null;
|
||||
/** @description Tickets made from it («Convertir en ticket»), oldest first */
|
||||
spawned: components["schemas"]["CommentSpawnDto"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
@@ -3490,6 +3544,17 @@ export interface components {
|
||||
*/
|
||||
decide: boolean;
|
||||
};
|
||||
TurnCommentIntoIssueDto: {
|
||||
/** @example También falla en Android */
|
||||
title: string;
|
||||
/** @description One of the project's ticket type ids */
|
||||
type: string;
|
||||
/**
|
||||
* @description Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)
|
||||
* @default false
|
||||
*/
|
||||
asSubtask: boolean;
|
||||
};
|
||||
ResolveThreadDto: {
|
||||
/** @description What was settled: added as a reply marked as a decision before it closes */
|
||||
conclusion?: string;
|
||||
@@ -6732,6 +6797,82 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
IssuesController_visit: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Ticket id or key */
|
||||
issue: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["IssueVisitDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
IssuesController_followTree: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -9935,6 +10076,85 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_turnIntoIssue: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
commentId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TurnCommentIntoIssueDto"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CommandResultDto"];
|
||||
};
|
||||
};
|
||||
/** @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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
CommentsController_resolveThread: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -599,6 +599,37 @@ export class IssuesResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'turn_comment_into_issue',
|
||||
description:
|
||||
"Make a ticket of a comment that turned out to be work of its own: its text becomes the description, as a subtask of the comment's ticket (asSubtask) or linked to it. Read type ids with get_project. Answers the new ticket's key.",
|
||||
paramsSchema: z.object({
|
||||
commentId: z.string().min(1),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.string().min(1).describe("One of the project's ticket type ids"),
|
||||
asSubtask: z.boolean().default(false),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
turnCommentIntoIssue(
|
||||
{
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
}: { commentId: string; title: string; type: string; asSubtask: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/comments/{commentId}/issue', {
|
||||
params: { path: { commentId } },
|
||||
body: { title, type, asSubtask },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'link_issues',
|
||||
description:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
@@ -14,7 +14,7 @@
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar; y una página pública por release para los clientes, con enlace secreto |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@menciones`; el autor edita, el admin modera |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar), conversaciones que se resuelven con su conclusión, convertir un comentario en ticket, lo nuevo desde tu última visita marcado, encuestas rápidas que acaban en decisión, «está escribiendo…» en vivo y `@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 |
|
||||
| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar |
|
||||
| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria |
|
||||
|
||||
@@ -245,6 +245,11 @@ stateDiagram-v2
|
||||
admin, la cierra (`closePoll(commentId, decide)`, `POST …/poll/close`; `CommentPollClosedEvent`): gana la más votada
|
||||
(con empate, ninguna) y, con `decide`, se añade una respuesta «Decidido: …» marcada como decisión. El comentario da
|
||||
`poll { options { text voters } closed winner }`.
|
||||
- **Convertir en ticket**: un comentario que resulta ser trabajo propio se convierte en ticket
|
||||
(`turnCommentIntoIssue(commentId, title, type, asSubtask)`, `POST /api/comments/{id}/issue`), como subtarea del ticket
|
||||
del comentario o enlazado a él (`RELATES_TO`), por quien puede cambiar tickets. El texto del comentario es la
|
||||
descripción (sin la plantilla del tipo), con una línea de dónde sale; el comentario lo recuerda
|
||||
(`CommentTurnedIntoIssueEvent`) y da `spawned { id key }`. Devuelve el ticket nuevo.
|
||||
- **Conversaciones**: un comentario raíz y sus respuestas se resuelven (`resolveThread(commentId, resolved, conclusion)`,
|
||||
`PUT/DELETE /api/comments/{id}/resolution`; evento `CommentThreadResolvedEvent`) por quien puede cambiar el ticket;
|
||||
una respuesta no se resuelve sola. Si trae `conclusion`, antes de cerrarla se añade como respuesta marcada como
|
||||
@@ -454,7 +459,10 @@ historial (`my_days`). **Recordármelo**: cada persona pone en un ticket un reco
|
||||
/api/issues/{issue}/reminder`, `GET /api/me/reminders`): uno por ticket, en el futuro y como mucho a un año. Cada minuto
|
||||
`IssueReminderScheduler` avisa de los que tocan (`REMINDER`, por la campana, el navegador y el correo; una vez, por su
|
||||
id) y olvida los de tickets en la papelera o fuera de alcance. Privados y sin historial (`issue_reminders`);
|
||||
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
|
||||
`REMINDERS_ENABLED=false` los apaga junto con los de fecha límite. **Lo nuevo desde tu última visita**: al abrir un
|
||||
ticket, `visitIssue(issueId)` (`PUT /api/issues/{issue}/visit`) apunta la visita y devuelve la anterior (null la
|
||||
primera vez); con ella el cliente marca lo que otros han hecho desde entonces. Personal y sin historial (colección
|
||||
`issue_visits`). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
|
||||
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
|
||||
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
|
||||
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
|
||||
@@ -574,10 +582,10 @@ que funcionan también sin token.
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `visitIssue`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||
| Comentarios | `addComment(replyTo, poll)`, `votePoll`, `closePoll`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `turnCommentIntoIssue(commentId, title, type, asSubtask)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||
@@ -786,7 +794,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`, `POST …/poll/vote`, `POST …/poll/close`, `POST …/issue` (convertir en ticket); `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||
| 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` |
|
||||
|
||||
@@ -301,14 +301,14 @@ visualización de la plataforma:
|
||||
## 4.6 Rutas
|
||||
|
||||
| Ruta | Página |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos. Cada proyecto lleva su semáforo de salud (`portfolioHealth`), con las causas al pasar por encima |
|
||||
| `/explore` | Proyectos públicos (sin sesión) |
|
||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («📊 Añadir una encuesta»): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo |
|
||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). A la vista, «Votar», «Seguir» y «Editar»; el resto (pedir revisión, mi día, recordármelo, seguir todo, fijar y duplicar) está en el menú «Más» (menú del CDK: se recorre con las flechas y Escape lo cierra). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones. Un comentario puede llevar una encuesta («Encuesta»; con su pregunta y de 2 a 6 opciones; el botón pasa a «Publicar encuesta» y el texto del comentario es opcional): se vota pulsando una opción y, al cerrarla, la ganadora queda como decisión. «🎥 Grabar pantalla» (con la voz, si se da permiso al micrófono) y «🎙 Grabar audio» graban en el navegador hasta 3 minutos y lo adjuntan al comentario, que puede ir sin texto; los vídeos y audios adjuntos se reproducen ahí mismo. Al abrirlo, lo que otros han hecho desde tu última visita: un aviso arriba («Desde tu última visita, hace 2 días: 3 comentarios y 1 cambio de Ana y Bob», con «Ver lo nuevo»), los comentarios nuevos marcados («Nuevo», también en una conversación plegada) y los cambios del historial resaltados. Cada comentario se puede «Convertir en ticket» (subtarea de este o enlazado, con el título sacado de su primera línea): el comentario enlaza al ticket nuevo («→ Convertido en WEB-45») |
|
||||
| `/p/:key/releases` | Releases. «Página pública» (quien gestiona): un enlace secreto con las notas para clientes, que se copia, renueva o retira |
|
||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea. Los bloqueos con tickets de otros proyectos (en los dos sentidos, `externalDependencies`) salen en «De otros proyectos» con barra discontinua, sus flechas y el mismo aviso si las fechas no encajan; los que no tienen fechas o no se pueden leer se nombran debajo |
|
||||
|
||||
@@ -74,6 +74,7 @@ flowchart LR
|
||||
| | `answer_review` | Responder una revisión: todo bien o cambios |
|
||||
| | `vote_poll` | Votar en la encuesta de un comentario (o retirar el voto) |
|
||||
| | `close_poll` | Cerrar una encuesta; la ganadora queda como decisión |
|
||||
| | `turn_comment_into_issue` | Convertir un comentario en ticket: subtarea del suyo o enlazado; su texto es la descripción |
|
||||
| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos |
|
||||
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||
|
||||
@@ -43,6 +43,9 @@ export * from './lib/alerts.service';
|
||||
export * from './lib/follows/tree-follows';
|
||||
export * from './lib/ports/tree-follows.repository';
|
||||
export * from './lib/tree-follows.service';
|
||||
export * from './lib/visits/issue-visits';
|
||||
export * from './lib/ports/issue-visits.repository';
|
||||
export * from './lib/issue-visits.service';
|
||||
export * from './lib/reminders/issue-reminders';
|
||||
export * from './lib/ports/issue-reminders.repository';
|
||||
export * from './lib/issue-reminders.service';
|
||||
@@ -51,6 +54,7 @@ export * from './lib/ports/my-days.repository';
|
||||
export * from './lib/my-day.service';
|
||||
export * from './lib/commands/reviews';
|
||||
export * from './lib/commands/polls';
|
||||
export * from './lib/commands/turn-comment-into-issue';
|
||||
export * from './lib/sharing/release-notes-shares';
|
||||
export * from './lib/ports/release-note-shares.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MarkDecisionCommandHandler } from './mark-decision.command-handler';
|
||||
import { ResolveThreadCommandHandler } from './resolve-thread.command-handler';
|
||||
import { REVIEW_HANDLERS } from './reviews';
|
||||
import { POLL_HANDLERS } from './polls';
|
||||
import { TURN_INTO_ISSUE_HANDLERS } from './turn-comment-into-issue';
|
||||
import { VoteIssueCommandHandler } from './vote-issue.command-handler';
|
||||
import { CreateProjectCommandHandler } from './create-project.command-handler';
|
||||
import { UpdateProjectCommandHandler } from './update-project.command-handler';
|
||||
@@ -54,6 +55,7 @@ export const COMMAND_HANDLERS = [
|
||||
ResolveThreadCommandHandler,
|
||||
...REVIEW_HANDLERS,
|
||||
...POLL_HANDLERS,
|
||||
...TURN_INTO_ISSUE_HANDLERS,
|
||||
CreateProjectCommandHandler,
|
||||
UpdateProjectCommandHandler,
|
||||
UpdateProjectSettingsCommandHandler,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
IssueLinkType,
|
||||
IssueStatus,
|
||||
Project,
|
||||
IssuePriority,
|
||||
@@ -81,6 +82,7 @@ import { ReactToCommentCommand } from './react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './mark-decision.command';
|
||||
import { ResolveThreadCommand } from './resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './polls';
|
||||
import { TurnCommentIntoIssueCommand } from './turn-comment-into-issue';
|
||||
import {
|
||||
AnswerReviewCommand,
|
||||
CancelReviewCommand,
|
||||
@@ -951,6 +953,65 @@ describe('tracker commands (event sourced)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('makes a ticket of a comment, as a subtask or linked, and marks the comment', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Pago', {}),
|
||||
);
|
||||
const comment = await run(
|
||||
new AddCommentCommand('qa', issue.id, 'También falla en **Android**'),
|
||||
);
|
||||
// Viewers comment and react; making tickets is for the team.
|
||||
await expect(
|
||||
run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'pm',
|
||||
comment.id,
|
||||
'Android',
|
||||
IssueType.BUG,
|
||||
false,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
const linked = await run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'dev',
|
||||
comment.id,
|
||||
'Falla en Android',
|
||||
IssueType.BUG,
|
||||
false,
|
||||
),
|
||||
);
|
||||
const sub = await run(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
'dev',
|
||||
comment.id,
|
||||
'Probar en Android',
|
||||
IssueType.TASK,
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
const fromLink = await rehydrator.rehydrate(linked.id, Issue);
|
||||
expect(fromLink.title).toBe('Falla en Android');
|
||||
expect(fromLink.description).toBe(
|
||||
'También falla en **Android**\n\n---\n_Sale de un comentario de user qa en TS-1._',
|
||||
);
|
||||
expect(fromLink.links).toEqual([
|
||||
expect.objectContaining({
|
||||
type: IssueLinkType.RELATES_TO,
|
||||
targetIssueId: issue.id,
|
||||
}),
|
||||
]);
|
||||
expect((await rehydrator.rehydrate(sub.id, Issue)).parentId).toBe(issue.id);
|
||||
expect((await rehydrator.rehydrate(comment.id, Comment)).spawned).toEqual([
|
||||
{ id: linked.id, key: 'TS-2' },
|
||||
{ id: sub.id, key: 'TS-3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes votes in a comment poll and keeps the winner as a decision', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { IssueLinkType } from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { CommandResult } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { LinkIssuesCommand } from './link-issues.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/*
|
||||
* «Convertir en ticket»: a comment that turns out to be work of its own
|
||||
* («también falla en Android») becomes a ticket, as a subtask of the one it
|
||||
* was said on or linked to it. Its text is the new description, and the
|
||||
* comment shows where it went («→ WEB-45»).
|
||||
*/
|
||||
|
||||
export class TurnCommentIntoIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly commentId: string,
|
||||
public readonly title: string,
|
||||
/** Ticket type id of the project's settings. */
|
||||
public readonly type: string,
|
||||
/** Hang it from the ticket of the comment; otherwise, link them. */
|
||||
public readonly asSubtask: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers the new ticket (id, key, version). */
|
||||
@CommandHandler(TurnCommentIntoIssueCommand)
|
||||
export class TurnCommentIntoIssueCommandHandler
|
||||
implements ICommandHandler<TurnCommentIntoIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(command: TurnCommentIntoIssueCommand): Promise<CommandResult> {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
const source = await this.access.issue(comment.issueId);
|
||||
const project = await this.access.project(comment.projectId);
|
||||
this.access.assertSees(project, source, command.actorId);
|
||||
// Creating and linking tickets is for the team: those commands check it.
|
||||
project.assertCanContribute(command.actorId);
|
||||
if (comment.deleted) throw new BusinessRuleError('Comment was deleted');
|
||||
|
||||
const author = (await this.users.refs([comment.authorId])).of(
|
||||
comment.authorId,
|
||||
);
|
||||
const created = await this.commandBus.execute(
|
||||
new CreateIssueCommand(
|
||||
command.actorId,
|
||||
project.id,
|
||||
command.type,
|
||||
command.title,
|
||||
{
|
||||
description: `${comment.body}\n\n---\n_Sale de un comentario de ${author.name} en ${source.key}._`,
|
||||
},
|
||||
null,
|
||||
null,
|
||||
command.asSubtask ? source.id : null,
|
||||
null,
|
||||
// The comment is the content: no template on top of it.
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (!command.asSubtask)
|
||||
await this.commandBus.execute(
|
||||
new LinkIssuesCommand(
|
||||
command.actorId,
|
||||
created.id,
|
||||
IssueLinkType.RELATES_TO,
|
||||
source.id,
|
||||
),
|
||||
);
|
||||
comment.turnedInto(command.actorId, {
|
||||
id: created.id,
|
||||
key: created.key ?? '',
|
||||
});
|
||||
await comment.commitAndWait();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
export const TURN_INTO_ISSUE_HANDLERS = [TurnCommentIntoIssueCommandHandler];
|
||||
@@ -11,6 +11,7 @@ import { DeleteCommentCommand } from './commands/delete-comment.command';
|
||||
import { EditCommentCommand } from './commands/edit-comment.command';
|
||||
import { ReactToCommentCommand } from './commands/react-to-comment.command';
|
||||
import { MarkDecisionCommand } from './commands/mark-decision.command';
|
||||
import { TurnCommentIntoIssueCommand } from './commands/turn-comment-into-issue';
|
||||
import { ResolveThreadCommand } from './commands/resolve-thread.command';
|
||||
import { ClosePollCommand, VotePollCommand } from './commands/polls';
|
||||
import { Page } from './ports/find-issues.repository';
|
||||
@@ -109,6 +110,25 @@ export class CommentsService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Makes a ticket of a comment: a subtask of its ticket, or linked to it. */
|
||||
turnIntoIssue(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commands.execute(
|
||||
new TurnCommentIntoIssueCommand(
|
||||
actorId,
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** A ticket's decisions, oldest first. */
|
||||
decisions(
|
||||
viewerId: string | null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export class CommentAddedEventHandler
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
spawned: [],
|
||||
createdAt: comment.createdAt,
|
||||
editedAt: null,
|
||||
version: versionOf(event),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { CommentTurnedIntoIssueEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(CommentTurnedIntoIssueEvent)
|
||||
export class CommentTurnedIntoIssueEventHandler
|
||||
implements IEventHandler<CommentTurnedIntoIssueEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: CommentTurnedIntoIssueEvent) {
|
||||
await this.upsertMaterializedCommentRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
spawned: event.spawned,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.comment(event.aggregateId, event.issueId, event);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler';
|
||||
import { CommentReactedEventHandler } from './comment-reacted.event-handler';
|
||||
import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler';
|
||||
import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler';
|
||||
import { CommentTurnedIntoIssueEventHandler } from './comment-turned-into-issue.event-handler';
|
||||
import { IssueReviewsEventHandler } from './issue-reviews.event-handler';
|
||||
import { CommentPollEventHandler } from './comment-poll.event-handler';
|
||||
import { IssueVotedEventHandler } from './issue-voted.event-handler';
|
||||
@@ -56,6 +57,7 @@ export const EVENT_HANDLERS = [
|
||||
CommentReactedEventHandler,
|
||||
CommentDecisionMarkedEventHandler,
|
||||
CommentThreadResolvedEventHandler,
|
||||
CommentTurnedIntoIssueEventHandler,
|
||||
IssueReviewsEventHandler,
|
||||
CommentPollEventHandler,
|
||||
IssueVotedEventHandler,
|
||||
|
||||
14
libs/api/tracker/application/src/lib/issue-visits.service.ts
Normal file
14
libs/api/tracker/application/src/lib/issue-visits.service.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { VisitIssueCommand } from './visits/issue-visits';
|
||||
|
||||
/** When I last opened a ticket, to tell what is new since. */
|
||||
@Injectable()
|
||||
export class IssueVisitsService {
|
||||
constructor(private readonly commands: CommandRunner) {}
|
||||
|
||||
/** Notes that I opened it now; returns my visit before (null: never). */
|
||||
visit(userId: string, issueId: string): Promise<string | null> {
|
||||
return this.commands.execute(new VisitIssueCommand(userId, issueId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** When someone last opened a ticket: personal, no history. */
|
||||
export interface IssueVisit {
|
||||
userId: string;
|
||||
issueId: string;
|
||||
seenAt: string;
|
||||
}
|
||||
|
||||
export abstract class IssueVisitsRepository {
|
||||
/** Notes this visit; returns when the one before it was (null: never). */
|
||||
abstract visit(visit: IssueVisit): Promise<string | null>;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||
import { ISSUE_VISITS } from './visits/issue-visits';
|
||||
import { IssueVisitsService } from './issue-visits.service';
|
||||
import { ISSUE_REMINDERS } from './reminders/issue-reminders';
|
||||
import { MY_DAY } from './my-day/my-day';
|
||||
import { RELEASE_NOTES_SHARES } from './sharing/release-notes-shares';
|
||||
@@ -92,6 +94,7 @@ const SERVICES = [
|
||||
PinsService,
|
||||
AlertsService,
|
||||
TreeFollowsService,
|
||||
IssueVisitsService,
|
||||
IssueRemindersService,
|
||||
MyDayService,
|
||||
SprintsService,
|
||||
@@ -127,6 +130,7 @@ const SERVICES = [
|
||||
...PINS,
|
||||
...ALERT_HANDLERS,
|
||||
...TREE_FOLLOWS,
|
||||
...ISSUE_VISITS,
|
||||
...ISSUE_REMINDERS,
|
||||
...MY_DAY,
|
||||
...RELEASE_NOTES_SHARES,
|
||||
|
||||
51
libs/api/tracker/application/src/lib/visits/issue-visits.ts
Normal file
51
libs/api/tracker/application/src/lib/visits/issue-visits.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
QueryBus,
|
||||
} from '@nestjs/cqrs';
|
||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { IssueVisitsRepository } from '../ports/issue-visits.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
|
||||
/*
|
||||
* «Lo nuevo desde tu última visita»: opening a ticket notes the moment, and
|
||||
* says when the visit before was, so the client can tell what others did
|
||||
* meanwhile. Personal, no history (like pinned tickets).
|
||||
*/
|
||||
|
||||
export class VisitIssueCommand extends Command<string | null> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(VisitIssueCommand)
|
||||
export class VisitIssueCommandHandler
|
||||
implements ICommandHandler<VisitIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly visits: IssueVisitsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(command: VisitIssueCommand): Promise<string | null> {
|
||||
const { userId, issueId } = command;
|
||||
const [issue]: IssueReadModel[] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(userId, [issueId]),
|
||||
);
|
||||
// A ticket I cannot read does not exist, as far as I know.
|
||||
if (!issue) throw new NotFoundError('Issue', issueId);
|
||||
return this.visits.visit({
|
||||
userId,
|
||||
issueId,
|
||||
seenAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const ISSUE_VISITS = [VisitIssueCommandHandler];
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { CommentAddedEvent } from '../events/comment-added.event';
|
||||
import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event';
|
||||
import { CommentThreadResolvedEvent } from '../events/comment-thread-resolved.event';
|
||||
import { CommentTurnedIntoIssueEvent } from '../events/comment-turned-into-issue.event';
|
||||
import {
|
||||
CommentPollClosedEvent,
|
||||
CommentPollVotedEvent,
|
||||
@@ -82,6 +83,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public pollVotes: Record<string, number> = {};
|
||||
public pollClosed = false;
|
||||
public pollWinner: number | null = null;
|
||||
/** Tickets made from it, oldest first. */
|
||||
public spawned: { id: string; key: string }[] = [];
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
@@ -277,10 +280,32 @@ export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
this.resolved = event.resolved;
|
||||
}
|
||||
|
||||
onCommentTurnedIntoIssueEvent(event: CommentTurnedIntoIssueEvent): void {
|
||||
this.spawned = event.spawned;
|
||||
}
|
||||
|
||||
onCommentDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes a ticket made from it (its text became the ticket's description).
|
||||
* Who may create it is the caller's check.
|
||||
*/
|
||||
turnedInto(actorId: string, issue: { id: string; key: string }): void {
|
||||
this.assertAlive();
|
||||
if (this.spawned.some((s) => s.id === issue.id)) return;
|
||||
this.apply(
|
||||
new CommentTurnedIntoIssueEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
this.issueId,
|
||||
[...this.spawned, issue],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private assertAlive(): void {
|
||||
if (this.deleted) throw new BusinessRuleError('Comment was deleted');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket of its own was made from a comment («→ WEB-45» on it). */
|
||||
@AutowiredEvent
|
||||
export class CommentTurnedIntoIssueEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
commentId: string,
|
||||
projectId: string,
|
||||
readonly issueId: string,
|
||||
/** Every ticket made from it so far, this one last. */
|
||||
readonly spawned: { id: string; key: string }[],
|
||||
) {
|
||||
super(actorId, commentId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export * from './comment-edited.event';
|
||||
export * from './comment-reacted.event';
|
||||
export * from './comment-decision-marked.event';
|
||||
export * from './comment-thread-resolved.event';
|
||||
export * from './comment-turned-into-issue.event';
|
||||
export * from './issue-review.events';
|
||||
export * from './comment-poll.events';
|
||||
export * from './comment-deleted.event';
|
||||
|
||||
@@ -41,6 +41,8 @@ export class CommentReadModel {
|
||||
/** Who settled it and when (null while open). */
|
||||
resolvedBy!: UserRefReadModel | null;
|
||||
resolvedAt!: string | null;
|
||||
/** Tickets made from it («Convertir en ticket»), oldest first. */
|
||||
spawned!: { id: string; key: string }[];
|
||||
createdAt!: string;
|
||||
editedAt!: string | null;
|
||||
version!: number;
|
||||
|
||||
@@ -58,6 +58,7 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
resolved: doc.resolved ?? false,
|
||||
resolvedBy: doc.resolvedBy ?? null,
|
||||
resolvedAt: doc.resolvedAt ?? null,
|
||||
spawned: doc.spawned ?? [],
|
||||
});
|
||||
|
||||
const escapeRegex = (text: string) =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
IssueVisit,
|
||||
IssueVisitsRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoIssueVisitsRepository extends IssueVisitsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.IssueVisit)
|
||||
private readonly visits: Model<IssueVisit>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async visit(visit: IssueVisit): Promise<string | null> {
|
||||
// One round trip: the document as it was before this visit.
|
||||
const before = await this.visits
|
||||
.findOneAndUpdate(
|
||||
{ userId: visit.userId, issueId: visit.issueId },
|
||||
{ $set: { seenAt: visit.seenAt } },
|
||||
{ upsert: true, new: false },
|
||||
)
|
||||
.lean<IssueVisit>();
|
||||
return before?.seenAt ?? null;
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const ReadModels = {
|
||||
AlertIntegration: 'AlertIntegration',
|
||||
AlertTrace: 'AlertTrace',
|
||||
TreeFollow: 'TreeFollow',
|
||||
IssueVisit: 'IssueVisit',
|
||||
IssueReminder: 'IssueReminder',
|
||||
MyDay: 'MyDay',
|
||||
ReleaseNotesShare: 'ReleaseNotesShare',
|
||||
@@ -373,6 +374,16 @@ export const TreeFollowSchema =
|
||||
SchemaFactory.createForClass(TreeFollowDocument);
|
||||
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'issue_visits' })
|
||||
class IssueVisitDocument {
|
||||
@Prop({ type: String }) userId!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
@Prop({ type: String }) seenAt!: string;
|
||||
}
|
||||
export const IssueVisitSchema =
|
||||
SchemaFactory.createForClass(IssueVisitDocument);
|
||||
IssueVisitSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'issue_reminders' })
|
||||
class IssueReminderDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@@ -511,6 +522,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||
{ name: ReadModels.IssueVisit, schema: IssueVisitSchema },
|
||||
{ name: ReadModels.IssueReminder, schema: IssueReminderSchema },
|
||||
{ name: ReadModels.MyDay, schema: MyDaySchema },
|
||||
{ name: ReadModels.ReleaseNotesShare, schema: ReleaseNotesShareSchema },
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PinsRepository,
|
||||
AlertIntegrationsRepository,
|
||||
TreeFollowsRepository,
|
||||
IssueVisitsRepository,
|
||||
IssueRemindersRepository,
|
||||
MyDaysRepository,
|
||||
ReleaseNoteSharesRepository,
|
||||
@@ -87,6 +88,7 @@ import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||
import { MongoIssueVisitsRepository } from './mongo/issue-visits.repository';
|
||||
import { MongoIssueRemindersRepository } from './mongo/issue-reminders.repository';
|
||||
import { MongoMyDaysRepository } from './mongo/my-days.repository';
|
||||
import { MongoReleaseNoteSharesRepository } from './mongo/release-note-shares.repository';
|
||||
@@ -172,6 +174,7 @@ const PORTS = [
|
||||
useClass: MongoAlertIntegrationsRepository,
|
||||
},
|
||||
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
||||
{ provide: IssueVisitsRepository, useClass: MongoIssueVisitsRepository },
|
||||
{
|
||||
provide: IssueRemindersRepository,
|
||||
useClass: MongoIssueRemindersRepository,
|
||||
|
||||
@@ -113,6 +113,27 @@ export class CommentResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Make a ticket of a comment: its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO). Answers the new ticket. Whoever can change tickets',
|
||||
})
|
||||
turnCommentIntoIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('commentId', { type: () => ID }) commentId: string,
|
||||
@Args('title') title: string,
|
||||
@Args('type', { description: "One of the project's ticket type ids" })
|
||||
type: string,
|
||||
@Args('asSubtask', { defaultValue: false }) asSubtask: boolean,
|
||||
) {
|
||||
return this.comments.turnIntoIssue(
|
||||
me.id,
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
);
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [DecisionGql], {
|
||||
description:
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
IssueVisitsService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
@@ -73,6 +74,7 @@ export class IssueResolver {
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
private readonly visits: IssueVisitsService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
@@ -452,6 +454,18 @@ export class IssueResolver {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
nullable: true,
|
||||
description:
|
||||
'I opened this ticket now: answers when I had opened it before (null: never), to show what is new since',
|
||||
})
|
||||
visitIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
): Promise<string | null> {
|
||||
return this.visits.visit(me.id, issueId);
|
||||
}
|
||||
|
||||
@Query(() => MyDayGql, {
|
||||
description:
|
||||
'«Mi día»: the tickets I picked for today, and what was left on my last day',
|
||||
|
||||
@@ -572,6 +572,12 @@ export class CommentPollGql {
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('CommentSpawn', { description: 'A ticket made from a comment' })
|
||||
export class CommentSpawnGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() key!: string;
|
||||
}
|
||||
|
||||
@ObjectType('Comment')
|
||||
export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@@ -615,6 +621,10 @@ export class CommentGql {
|
||||
})
|
||||
resolvedBy!: UserRefGql | null;
|
||||
@Field(() => String, { nullable: true }) resolvedAt!: string | null;
|
||||
@Field(() => [CommentSpawnGql], {
|
||||
description: 'Tickets made from it («Convertir en ticket»), oldest first',
|
||||
})
|
||||
spawned!: CommentSpawnGql[];
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||
@Field(() => Int) version!: number;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ClosePollDto,
|
||||
ReactToCommentDto,
|
||||
ResolveThreadDto,
|
||||
TurnCommentIntoIssueDto,
|
||||
VotePollDto,
|
||||
} from './dto/requests.dto';
|
||||
import { CommandResultDto } from './dto/responses.dto';
|
||||
@@ -117,6 +118,28 @@ export class CommentsController {
|
||||
return this.comments.closePoll(me.id, commentId, body.decide ?? true);
|
||||
}
|
||||
|
||||
@Post(':commentId/issue')
|
||||
@ApiOperation({
|
||||
summary: 'Make a ticket of a comment',
|
||||
description:
|
||||
'Its text becomes the description, as a subtask of its ticket or linked to it (RELATES_TO); the comment shows where it went. Answers the new ticket. Whoever can change tickets.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
turnIntoIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Param('commentId') commentId: string,
|
||||
@Body() body: TurnCommentIntoIssueDto,
|
||||
) {
|
||||
return this.comments.turnIntoIssue(
|
||||
me.id,
|
||||
commentId,
|
||||
body.title,
|
||||
body.type,
|
||||
body.asSubtask ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':commentId/resolution')
|
||||
@ApiOperation({
|
||||
summary: 'Settle a conversation (a top-level comment and its replies)',
|
||||
|
||||
@@ -1640,6 +1640,27 @@ export class SetReminderDto {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class TurnCommentIntoIssueDto {
|
||||
@ApiProperty({ example: 'También falla en Android', maxLength: 200 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: "One of the project's ticket type ids" })
|
||||
@IsString()
|
||||
type!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description:
|
||||
'Hang it from the ticket of the comment; otherwise it is linked to it (RELATES_TO)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
asSubtask?: boolean;
|
||||
}
|
||||
|
||||
export class ResolveThreadDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
|
||||
@@ -628,6 +628,10 @@ export class CommentPollDto {
|
||||
winner!: number | null;
|
||||
}
|
||||
|
||||
export class CommentSpawnDto {
|
||||
@ApiProperty() id!: string;
|
||||
@ApiProperty({ example: 'WEB-45' }) key!: string;
|
||||
}
|
||||
export class CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@@ -679,6 +683,11 @@ export class CommentDto {
|
||||
resolvedBy!: UserRefDto | null;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
resolvedAt!: string | null;
|
||||
@ApiProperty({
|
||||
type: () => [CommentSpawnDto],
|
||||
description: 'Tickets made from it («Convertir en ticket»), oldest first',
|
||||
})
|
||||
spawned!: CommentSpawnDto[];
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
editedAt!: string | null;
|
||||
@@ -1447,6 +1456,16 @@ export class IssueReminderDto {
|
||||
@ApiProperty({ type: String, nullable: true }) note!: string | null;
|
||||
}
|
||||
|
||||
export class IssueVisitDto {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
description: 'My visit before this one (null: first time)',
|
||||
})
|
||||
previousVisit!: string | null;
|
||||
}
|
||||
|
||||
export class ReminderOnIssueDto {
|
||||
@ApiProperty({ type: IssueReminderDto }) reminder!: IssueReminderDto;
|
||||
@ApiProperty({ type: () => IssueDto }) issue!: IssueDto;
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
PinsService,
|
||||
TreeFollowsService,
|
||||
IssueRemindersService,
|
||||
IssueVisitsService,
|
||||
MyDayService,
|
||||
PresenceService,
|
||||
ProjectsService,
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
CommentPageDto,
|
||||
DecisionDto,
|
||||
IssueReminderDto,
|
||||
IssueVisitDto,
|
||||
MyDayDto,
|
||||
ReminderOnIssueDto,
|
||||
EpicDto,
|
||||
@@ -112,6 +114,7 @@ export class IssuesController {
|
||||
private readonly treeFollows: TreeFollowsService,
|
||||
private readonly reminders: IssueRemindersService,
|
||||
private readonly myDayService: MyDayService,
|
||||
private readonly visits: IssueVisitsService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@@ -382,6 +385,27 @@ export class IssuesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/visit')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'I opened this ticket now',
|
||||
description:
|
||||
'Answers when I had opened it before (null: never), to show what others did since. Private to me.',
|
||||
})
|
||||
@ApiOkResponse({ type: IssueVisitDto })
|
||||
@ApiCommandErrors()
|
||||
async visit(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
): Promise<IssueVisitDto> {
|
||||
return {
|
||||
previousVisit: await this.visits.visit(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Put('issues/:issue/follow-tree')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
|
||||
@@ -110,6 +110,12 @@ export const issuePageEvents = eventGroup({
|
||||
resolved: boolean;
|
||||
conclusion: string | null;
|
||||
}>(),
|
||||
commentToIssueRequested: type<{
|
||||
commentId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
asSubtask: boolean;
|
||||
}>(),
|
||||
voteRequested: type<{ issueId: string; on: boolean }>(),
|
||||
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
||||
imagePasted: type<{ issueId: string }>(),
|
||||
|
||||
@@ -316,6 +316,21 @@ export class IssueFacade {
|
||||
});
|
||||
}
|
||||
|
||||
/** Makes a ticket of a comment: a subtask of this one, or linked to it. */
|
||||
turnCommentIntoIssue(
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): void {
|
||||
this.dispatch.commentToIssueRequested({
|
||||
commentId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
asSubtask,
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||
markDecision(commentId: string, on: boolean): void {
|
||||
this.dispatch.decisionRequested({ commentId, on });
|
||||
|
||||
@@ -608,6 +608,28 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.commentToIssueRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments
|
||||
.turnIntoIssue(
|
||||
payload.commentId,
|
||||
payload.title,
|
||||
payload.type,
|
||||
payload.asSubtask,
|
||||
)
|
||||
.pipe(
|
||||
mapResponse({
|
||||
next: (result) =>
|
||||
done(
|
||||
result.key
|
||||
? `Creado ${result.key}`
|
||||
: 'Ticket creado',
|
||||
),
|
||||
error: (error) => failed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.reactRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/my-day';
|
||||
export * from './lib/models/health';
|
||||
export * from './lib/models/news';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
|
||||
@@ -385,6 +385,8 @@ export interface Comment {
|
||||
resolved: boolean;
|
||||
/** Who settled it (null while open). */
|
||||
resolvedBy: UserRef | null;
|
||||
/** Tickets made from it («Convertir en ticket»), oldest first. */
|
||||
spawned: { id: string; key: string }[];
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
86
libs/web/tracker/domain/src/lib/models/news.spec.ts
Normal file
86
libs/web/tracker/domain/src/lib/models/news.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Activity, Comment } from './models';
|
||||
import { newsSince, titleFrom } from './news';
|
||||
|
||||
const user = (id: string, name: string) => ({ id, name });
|
||||
const comment = (
|
||||
id: string,
|
||||
by: string,
|
||||
at: string,
|
||||
deleted = false,
|
||||
): Comment =>
|
||||
({
|
||||
id,
|
||||
author: user(by, by === 'me' ? 'Yo' : by === 'ana' ? 'Ana' : 'Bob'),
|
||||
createdAt: at,
|
||||
deleted,
|
||||
}) as Comment;
|
||||
const change = (id: string, by: string, at: string, type: string): Activity =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
actor: user(by, by === 'ana' ? 'Ana' : 'Bob'),
|
||||
occurredAt: at,
|
||||
}) as Activity;
|
||||
|
||||
describe('news since my last visit', () => {
|
||||
const since = '2026-10-20T10:00:00.000Z';
|
||||
|
||||
it('counts what others did since, not mine nor what came before', () => {
|
||||
const news = newsSince(
|
||||
[
|
||||
comment('old', 'ana', '2026-10-19T09:00:00.000Z'),
|
||||
comment('mine', 'me', '2026-10-21T09:00:00.000Z'),
|
||||
comment('c1', 'bob', '2026-10-21T09:30:00.000Z'),
|
||||
comment('gone', 'ana', '2026-10-21T09:40:00.000Z', true),
|
||||
comment('c2', 'ana', '2026-10-21T10:00:00.000Z'),
|
||||
],
|
||||
[
|
||||
change(
|
||||
'h1',
|
||||
'ana',
|
||||
'2026-10-21T08:00:00.000Z',
|
||||
'IssueStatusChangedEvent',
|
||||
),
|
||||
// The comment itself is already counted as a comment.
|
||||
change('h2', 'bob', '2026-10-21T09:30:00.000Z', 'CommentAddedEvent'),
|
||||
change('h3', 'bob', '2026-10-19T08:00:00.000Z', 'IssueAssignedEvent'),
|
||||
],
|
||||
since,
|
||||
'me',
|
||||
);
|
||||
expect(news).toEqual({
|
||||
since,
|
||||
what: '2 comentarios y 1 cambio',
|
||||
who: 'Bob y Ana',
|
||||
firstComment: 'c1',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing the first time, or when nothing happened', () => {
|
||||
const comments = [comment('c1', 'ana', '2026-10-21T09:00:00.000Z')];
|
||||
expect(newsSince(comments, [], null, 'me')).toBeNull();
|
||||
expect(
|
||||
newsSince(comments, [], '2026-10-22T00:00:00.000Z', 'me'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('titleFrom', () => {
|
||||
it('takes the first line without Markdown', () => {
|
||||
expect(
|
||||
titleFrom(
|
||||
'\n## También falla en **Android** ([log](https://x.dev))\nMás',
|
||||
),
|
||||
).toBe('También falla en Android (log)');
|
||||
expect(titleFrom('- `npm ci` rompe en CI')).toBe('npm ci rompe en CI');
|
||||
expect(titleFrom('También falla en Android.\nAdjunto el log.')).toBe(
|
||||
'También falla en Android',
|
||||
);
|
||||
});
|
||||
|
||||
it('cuts long lines at a word', () => {
|
||||
const title = titleFrom('palabra '.repeat(30));
|
||||
expect(title.length).toBeLessThanOrEqual(121);
|
||||
expect(title.endsWith('palabra…')).toBe(true);
|
||||
});
|
||||
});
|
||||
102
libs/web/tracker/domain/src/lib/models/news.ts
Normal file
102
libs/web/tracker/domain/src/lib/models/news.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Activity, Comment } from './models';
|
||||
|
||||
/*
|
||||
* «Lo nuevo desde tu última visita»: what others did on a ticket since I
|
||||
* last opened it. My own comments and changes are not news to me.
|
||||
*/
|
||||
|
||||
/** A comment someone else wrote since (deleted ones say nothing). */
|
||||
export function isNewComment(
|
||||
comment: Comment,
|
||||
since: string | null,
|
||||
me: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
!!since &&
|
||||
!comment.deleted &&
|
||||
comment.createdAt > since &&
|
||||
comment.author.id !== me
|
||||
);
|
||||
}
|
||||
|
||||
/** A change someone else made since; comments count on their own. */
|
||||
export function isNewChange(
|
||||
entry: Activity,
|
||||
since: string | null,
|
||||
me: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
!!since &&
|
||||
entry.occurredAt > since &&
|
||||
entry.actor.id !== me &&
|
||||
!entry.type.startsWith('Comment')
|
||||
);
|
||||
}
|
||||
|
||||
export interface IssueNews {
|
||||
since: string;
|
||||
/** «2 comentarios y 1 cambio». */
|
||||
what: string;
|
||||
/** «Ana y Bob». */
|
||||
who: string;
|
||||
/** The first new comment, to go to it. */
|
||||
firstComment: string | null;
|
||||
}
|
||||
|
||||
/** What is new since, or null when nothing is (or it is my first time). */
|
||||
export function newsSince(
|
||||
comments: Comment[],
|
||||
history: Activity[],
|
||||
since: string | null,
|
||||
me: string | null,
|
||||
): IssueNews | null {
|
||||
if (!since) return null;
|
||||
const fresh = comments.filter((c) => isNewComment(c, since, me));
|
||||
const changes = history.filter((e) => isNewChange(e, since, me));
|
||||
if (!fresh.length && !changes.length) return null;
|
||||
const what = [
|
||||
count(fresh.length, 'comentario', 'comentarios'),
|
||||
count(changes.length, 'cambio', 'cambios'),
|
||||
].filter(Boolean);
|
||||
const who = [
|
||||
...new Set([
|
||||
...fresh.map((c) => c.author.name),
|
||||
...changes.map((e) => e.actor.name),
|
||||
]),
|
||||
];
|
||||
return {
|
||||
since,
|
||||
what: what.join(' y '),
|
||||
who:
|
||||
who.length > 1
|
||||
? `${who.slice(0, -1).join(', ')} y ${who[who.length - 1]}`
|
||||
: who[0],
|
||||
firstComment: fresh[0]?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function count(n: number, one: string, many: string): string {
|
||||
return n === 1 ? `1 ${one}` : n ? `${n} ${many}` : '';
|
||||
}
|
||||
|
||||
/** A ticket title out of a comment: its first line, without Markdown. */
|
||||
export function titleFrom(body: string): string {
|
||||
const line =
|
||||
body
|
||||
.split('\n')
|
||||
.map((l) =>
|
||||
l
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^\s*(#+|>|[-*+]|\d+\.)\s*/, '')
|
||||
.replace(/[*_`~]/g, '')
|
||||
.trim()
|
||||
// A title ends without the sentence's stop.
|
||||
.replace(/[.:;,]+$/, ''),
|
||||
)
|
||||
.find(Boolean) ?? '';
|
||||
if (line.length <= 120) return line;
|
||||
const cut = line.slice(0, 120);
|
||||
const space = cut.lastIndexOf(' ');
|
||||
return `${space > 60 ? cut.slice(0, space) : cut}…`;
|
||||
}
|
||||
@@ -170,6 +170,8 @@ export abstract class IssuesGateway {
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/** Follows the ticket with everything under it (or stops). */
|
||||
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
||||
/** I opened it now: answers my visit before (null: never). */
|
||||
abstract visit(issueId: string): Observable<string | null>;
|
||||
/** Reminds me of the ticket then (replaces the waiting one). */
|
||||
abstract setReminder(
|
||||
issueId: string,
|
||||
@@ -368,6 +370,13 @@ export abstract class CommentsGateway {
|
||||
resolved: boolean,
|
||||
conclusion: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Makes a ticket of it: a subtask of its ticket, or linked. Answers the new one. */
|
||||
abstract turnIntoIssue(
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Observable<CommandResult>;
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
||||
abstract markDecision(
|
||||
commentId: string,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
ResolveThreadDocument,
|
||||
TurnCommentIntoIssueDocument,
|
||||
VotePollDocument,
|
||||
ClosePollDocument,
|
||||
PublishReleaseDocument,
|
||||
@@ -111,6 +112,22 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
.pipe(map((d) => toCommandResult(d.resolveThread)));
|
||||
}
|
||||
|
||||
turnIntoIssue(
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(TurnCommentIntoIssueDocument, {
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.turnCommentIntoIssue)));
|
||||
}
|
||||
|
||||
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(MarkDecisionDocument, { commentId, on })
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
CancelReviewDocument,
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
VisitIssueDocument,
|
||||
SetReminderDocument,
|
||||
MyDayDocument,
|
||||
AddToMyDayDocument,
|
||||
@@ -272,6 +273,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
visit(issueId: string): Observable<string | null> {
|
||||
return this.gql
|
||||
.mutate(VisitIssueDocument, { issueId })
|
||||
.pipe(map((d) => d.visitIssue ?? null));
|
||||
}
|
||||
|
||||
followTree(issueId: string, on: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = on
|
||||
? this.gql.mutate(FollowTreeDocument, { issueId })
|
||||
|
||||
@@ -218,6 +218,10 @@ fragment CommentFields on Comment {
|
||||
resolvedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
spawned {
|
||||
id
|
||||
key
|
||||
}
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
|
||||
@@ -754,6 +754,7 @@ export type IssueFieldsFragment = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
@@ -817,6 +818,7 @@ export type CommentFieldsFragment = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
|
||||
@@ -1027,6 +1029,7 @@ export type IssueQuery = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
@@ -1398,6 +1401,7 @@ export type IssueCommentsQuery = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
@@ -1830,6 +1834,23 @@ export type ResolveThreadMutation = {
|
||||
resolveThread: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type TurnCommentIntoIssueMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
asSubtask: boolean;
|
||||
}>;
|
||||
|
||||
export type TurnCommentIntoIssueMutation = {
|
||||
turnCommentIntoIssue: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type VisitIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type VisitIssueMutation = { visitIssue: string | null };
|
||||
|
||||
export type MarkDecisionMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
on: boolean;
|
||||
@@ -1870,6 +1891,7 @@ export type SearchDecisionsQuery = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
issue: { id: string; key: string; title: string };
|
||||
@@ -3254,6 +3276,7 @@ export type CommentChangedSubscription = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
};
|
||||
@@ -4091,6 +4114,17 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -4536,6 +4570,17 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -6853,6 +6898,17 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -9204,6 +9260,17 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -12014,6 +12081,181 @@ export const ResolveThreadDocument = {
|
||||
ResolveThreadMutation,
|
||||
ResolveThreadMutationVariables
|
||||
>;
|
||||
export const TurnCommentIntoIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'TurnCommentIntoIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'type' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'asSubtask' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'turnCommentIntoIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'type' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'type' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'asSubtask' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'asSubtask' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
TurnCommentIntoIssueMutation,
|
||||
TurnCommentIntoIssueMutationVariables
|
||||
>;
|
||||
export const VisitIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'VisitIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'visitIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<VisitIssueMutation, VisitIssueMutationVariables>;
|
||||
export const MarkDecisionDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -12304,6 +12546,17 @@ export const SearchDecisionsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -19548,6 +19801,17 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
|
||||
@@ -396,6 +396,26 @@ mutation ResolveThread(
|
||||
}
|
||||
}
|
||||
|
||||
mutation TurnCommentIntoIssue(
|
||||
$commentId: ID!
|
||||
$title: String!
|
||||
$type: String!
|
||||
$asSubtask: Boolean!
|
||||
) {
|
||||
turnCommentIntoIssue(
|
||||
commentId: $commentId
|
||||
title: $title
|
||||
type: $type
|
||||
asSubtask: $asSubtask
|
||||
) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation VisitIssue($issueId: ID!) {
|
||||
visitIssue(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
||||
markDecision(commentId: $commentId, on: $on) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -69,12 +69,103 @@
|
||||
// Follow and edit, together, level with the title's first line.
|
||||
.ts-issue__actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* «Más»: the ticket's less frequent actions, in a menu. */
|
||||
/* Laid out as pi-icon-button lays out its own (its styles are scoped). */
|
||||
.ts-more {
|
||||
display: flex !important;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
mat-icon {
|
||||
display: flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-menu {
|
||||
display: grid;
|
||||
width: min(22rem, calc(100vw - 2rem));
|
||||
// Pushed against the screen's edge on a phone: keep the page's gutter.
|
||||
margin-inline-start: 1rem;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--ts-surface);
|
||||
box-shadow: 0 12px 32px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
/* @pi/ui styles every <button> globally, hence the !important. */
|
||||
.ts-menu__item {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0.5rem 0.625rem !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0.5rem !important;
|
||||
background: transparent !important;
|
||||
color: var(--ts-ink) !important;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.cdk-menu-item-focused {
|
||||
background: var(--ts-sunken) !important;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
pi-icon {
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-menu__text {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
font-weight: 600;
|
||||
|
||||
small {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-menu__check {
|
||||
color: var(--ts-brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* A note above the ticket (my reminder, what is new). */
|
||||
.ts-banner {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ts-banner__actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Workflow: one button per allowed destination, in that status' colour.
|
||||
.ts-transitions {
|
||||
display: flex;
|
||||
@@ -339,6 +430,40 @@ a.ts-key {
|
||||
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Written by someone else since my last visit. */
|
||||
.ts-comment.is-new:not(.is-decision) {
|
||||
margin-inline: -0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
border-left: 3px solid var(--ts-brand);
|
||||
background: color-mix(in srgb, var(--ts-brand) 5%, transparent);
|
||||
}
|
||||
|
||||
.ts-comment__new {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
color: var(--ts-brand);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* «→ Convertido en WEB-45». */
|
||||
.ts-comment__spawned {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.5rem;
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
a {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment.is-pointed {
|
||||
animation: ts-pointed 2.4s ease-out;
|
||||
}
|
||||
@@ -469,6 +594,23 @@ a.ts-key {
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Changed by someone else since my last visit. */
|
||||
&.is-new .ts-log__what {
|
||||
color: var(--ts-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-new .ts-log__when::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
margin-right: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-brand);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,25 +898,6 @@ a.ts-key {
|
||||
background: var(--ts-c-green, #2e9e6a);
|
||||
}
|
||||
|
||||
/* My own reminder on the ticket. */
|
||||
.ts-reminder {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.625rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.625rem;
|
||||
background: color-mix(in srgb, var(--ts-c-amber) 14%, transparent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ts-reminder__actions {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ts-reminder__choices {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
@@ -931,6 +1054,12 @@ a.ts-key {
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__hint {
|
||||
margin: 0 0 0.875rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-poll-editor__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -16,6 +16,14 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { catchError, distinctUntilChanged, filter, of, switchMap } from 'rxjs';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import {
|
||||
CdkMenu,
|
||||
CdkMenuItem,
|
||||
CdkMenuItemCheckbox,
|
||||
CdkMenuTrigger,
|
||||
} from '@angular/cdk/menu';
|
||||
import { ConnectedPosition } from '@angular/cdk/overlay';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
@@ -25,6 +33,8 @@ import {
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiNoteActionContentDirective,
|
||||
PiSegmentedControlComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
@@ -44,7 +54,12 @@ import {
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Attachment,
|
||||
Activity,
|
||||
Comment,
|
||||
isNewChange,
|
||||
isNewComment,
|
||||
newsSince,
|
||||
titleFrom,
|
||||
CommentPoll,
|
||||
DevLink,
|
||||
Issue,
|
||||
@@ -102,6 +117,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
RichEditorComponent,
|
||||
NgTemplateOutlet,
|
||||
RouterLink,
|
||||
CdkMenu,
|
||||
CdkMenuItem,
|
||||
CdkMenuItemCheckbox,
|
||||
CdkMenuTrigger,
|
||||
MatIcon,
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
MediaRecorderComponent,
|
||||
@@ -113,6 +133,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiNoteActionContentDirective,
|
||||
PiSegmentedControlComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
@@ -216,79 +238,6 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(execute)="facade.setWatching(!facade.watching())"
|
||||
/>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="eye"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Pedir revisión"
|
||||
title="Pedir a alguien del equipo que lo revise"
|
||||
(execute)="openReviewRequest()"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole() && inMyDay() !== null) {
|
||||
<pi-icon-button
|
||||
icon="sun"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="inMyDay() ? 'En mi día' : 'Mi día'"
|
||||
[attr.aria-pressed]="inMyDay()"
|
||||
[attr.title]="
|
||||
inMyDay()
|
||||
? 'Quitar de lo que quiero sacar hoy'
|
||||
: 'Añadir a lo que quiero sacar hoy (en el inicio)'
|
||||
"
|
||||
(execute)="toggleMyDay(issue)"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<pi-icon-button
|
||||
icon="bell"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="issue.myReminder ? 'Recordatorio' : 'Recordármelo'"
|
||||
[attr.aria-pressed]="!!issue.myReminder"
|
||||
title="Avisarme de este ticket cuando yo diga"
|
||||
(execute)="openReminder()"
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
project.myRole() &&
|
||||
(facade.children().length || issue.followingTree)
|
||||
) {
|
||||
<pi-icon-button
|
||||
icon="kanban"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="
|
||||
issue.followingTree
|
||||
? 'Siguiendo todo · ' + facade.children().length
|
||||
: 'Seguir todo'
|
||||
"
|
||||
[attr.aria-pressed]="issue.followingTree"
|
||||
[attr.title]="
|
||||
issue.followingTree
|
||||
? 'Dejar de seguir lo que cuelga de este ticket'
|
||||
: 'Avisarme de este ticket y de todo lo que cuelga de él, también lo que se añada'
|
||||
"
|
||||
(execute)="facade.setFollowingTree(!issue.followingTree)"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<pi-icon-button
|
||||
icon="pin"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="issue.pinned ? 'Fijado' : 'Fijar'"
|
||||
[attr.aria-pressed]="issue.pinned"
|
||||
[attr.title]="
|
||||
issue.pinned
|
||||
? 'Quitar de mis fijados'
|
||||
: 'Tenerlo a mano en el inicio y al buscar'
|
||||
"
|
||||
(execute)="facade.setPinned(!issue.pinned)"
|
||||
/>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
@@ -298,43 +247,210 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
title="Editar (tecla E)"
|
||||
(execute)="openEdit()"
|
||||
/>
|
||||
<pi-icon-button
|
||||
icon="copy"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Duplicar"
|
||||
title="Crear otro ticket con lo mismo (sin estado, persona ni comentarios)"
|
||||
(execute)="duplicate()"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<!-- @pi/ui's look on a native button: the menu needs it. -->
|
||||
<button
|
||||
type="button"
|
||||
class="ts-more has-text pi-button pi-button__appearance--outline pi-button__icon pi-button__medium pi-color__grey pi-color__mode--dark"
|
||||
[cdkMenuTriggerFor]="moreMenu"
|
||||
[cdkMenuPosition]="morePositions"
|
||||
>
|
||||
<span>Más</span>
|
||||
<mat-icon svgIcon="chevron-down" aria-hidden="true" />
|
||||
</button>
|
||||
}
|
||||
<ng-template #moreMenu>
|
||||
<div class="ts-menu" cdkMenu>
|
||||
@if (project.canContribute()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-menu__item"
|
||||
cdkMenuItem
|
||||
(cdkMenuItemTriggered)="openReviewRequest()"
|
||||
>
|
||||
<pi-icon icon="eye" size="small" />
|
||||
<span class="ts-menu__text"
|
||||
>Pedir revisión<small
|
||||
>A alguien del equipo, que lo verá en «Mi
|
||||
trabajo»</small
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
}
|
||||
@if (inMyDay() !== null) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-menu__item"
|
||||
cdkMenuItemCheckbox
|
||||
[cdkMenuItemChecked]="!!inMyDay()"
|
||||
(cdkMenuItemTriggered)="toggleMyDay(issue)"
|
||||
>
|
||||
<pi-icon icon="sun" size="small" />
|
||||
<span class="ts-menu__text"
|
||||
>{{ inMyDay() ? 'En mi día' : 'Añadir a mi día'
|
||||
}}<small
|
||||
>Lo que quiero sacar hoy, en el inicio</small
|
||||
></span
|
||||
>
|
||||
@if (inMyDay()) {
|
||||
<span class="ts-menu__check" aria-hidden="true"
|
||||
>✓</span
|
||||
>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="ts-menu__item"
|
||||
cdkMenuItem
|
||||
(cdkMenuItemTriggered)="openReminder()"
|
||||
>
|
||||
<pi-icon icon="bell" size="small" />
|
||||
<span class="ts-menu__text"
|
||||
>{{
|
||||
issue.myReminder
|
||||
? 'Cambiar el recordatorio'
|
||||
: 'Recordármelo'
|
||||
}}<small
|
||||
>Un aviso solo para mí, cuando yo diga</small
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
@if (facade.children().length || issue.followingTree) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-menu__item"
|
||||
cdkMenuItemCheckbox
|
||||
[cdkMenuItemChecked]="issue.followingTree"
|
||||
(cdkMenuItemTriggered)="
|
||||
facade.setFollowingTree(!issue.followingTree)
|
||||
"
|
||||
>
|
||||
<pi-icon icon="kanban" size="small" />
|
||||
<span class="ts-menu__text"
|
||||
>{{
|
||||
issue.followingTree
|
||||
? 'Siguiendo todo · ' + facade.children().length
|
||||
: 'Seguir todo'
|
||||
}}<small
|
||||
>Este ticket y lo que cuelga de él, también lo
|
||||
nuevo</small
|
||||
></span
|
||||
>
|
||||
@if (issue.followingTree) {
|
||||
<span class="ts-menu__check" aria-hidden="true"
|
||||
>✓</span
|
||||
>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="ts-menu__item"
|
||||
cdkMenuItemCheckbox
|
||||
[cdkMenuItemChecked]="issue.pinned"
|
||||
(cdkMenuItemTriggered)="facade.setPinned(!issue.pinned)"
|
||||
>
|
||||
<pi-icon icon="pin" size="small" />
|
||||
<span class="ts-menu__text"
|
||||
>{{ issue.pinned ? 'Fijado' : 'Fijar'
|
||||
}}<small>A mano en el inicio y al buscar</small></span
|
||||
>
|
||||
@if (issue.pinned) {
|
||||
<span class="ts-menu__check" aria-hidden="true">✓</span>
|
||||
}
|
||||
</button>
|
||||
@if (project.canContribute()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-menu__item"
|
||||
cdkMenuItem
|
||||
(cdkMenuItemTriggered)="duplicate()"
|
||||
>
|
||||
<pi-icon icon="copy" size="small" />
|
||||
<span class="ts-menu__text"
|
||||
>Duplicar<small
|
||||
>Otro ticket con lo mismo, sin estado ni
|
||||
comentarios</small
|
||||
></span
|
||||
>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (issue.myReminder; as reminder) {
|
||||
<p class="ts-reminder" role="status">
|
||||
<span aria-hidden="true">⏰</span>
|
||||
Te lo recordaré {{ when(reminder.at) }}
|
||||
@if (reminder.note) {
|
||||
· «{{ reminder.note }}»
|
||||
}
|
||||
<span class="ts-reminder__actions">
|
||||
<pi-action-button
|
||||
size="x-small"
|
||||
<pi-note
|
||||
class="ts-banner"
|
||||
gap="xs"
|
||||
status="info"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
justify="space-between"
|
||||
alignment="center"
|
||||
[title]="'Te lo recordaré ' + when(reminder.at)"
|
||||
[text]="reminder.note ? '«' + reminder.note + '»' : ''"
|
||||
>
|
||||
<pi-note-action-content>
|
||||
<span class="ts-banner__actions">
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Cambiar"
|
||||
(execute)="openReminder()"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="x-small"
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Quitar"
|
||||
(execute)="facade.setReminder(null)"
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
</pi-note-action-content>
|
||||
</pi-note>
|
||||
}
|
||||
|
||||
@if (news(); as news) {
|
||||
<pi-note
|
||||
class="ts-banner"
|
||||
gap="xs"
|
||||
status="info"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
justify="space-between"
|
||||
alignment="center"
|
||||
[title]="
|
||||
'Desde tu última visita (' +
|
||||
ago(news.since) +
|
||||
'): ' +
|
||||
news.what +
|
||||
' de ' +
|
||||
news.who
|
||||
"
|
||||
text=""
|
||||
>
|
||||
@if (news.firstComment; as first) {
|
||||
<pi-note-action-content>
|
||||
<span class="ts-banner__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Ver lo nuevo"
|
||||
(execute)="goToNews(first)"
|
||||
/>
|
||||
</span>
|
||||
</pi-note-action-content>
|
||||
}
|
||||
</pi-note>
|
||||
}
|
||||
|
||||
@if (project.canContribute() && issue.allowedTransitions.length) {
|
||||
@@ -852,6 +968,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
class="ts-comment"
|
||||
[id]="'comment-' + comment.id"
|
||||
[class.is-decision]="comment.decision && !comment.deleted"
|
||||
[class.is-new]="isNew(comment)"
|
||||
>
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
@@ -868,6 +985,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
@if (comment.decision && !comment.deleted) {
|
||||
<span class="ts-comment__decision">Decisión</span>
|
||||
}
|
||||
@if (isNew(comment)) {
|
||||
<span class="ts-comment__new">Nuevo</span>
|
||||
}
|
||||
<span class="ts-spacer"></span>
|
||||
@if (!comment.deleted && project.canContribute()) {
|
||||
<!-- Native: pi-icon-button leaves its button unnamed. -->
|
||||
@@ -893,6 +1013,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-icon icon="flag" size="small" />
|
||||
</button>
|
||||
}
|
||||
@if (!comment.deleted && project.canContribute()) {
|
||||
<!-- Native, like the flag: a named button. -->
|
||||
<button
|
||||
type="button"
|
||||
class="ts-comment__flag"
|
||||
aria-label="Convertir en ticket"
|
||||
title="Convertir en ticket: su texto será la descripción"
|
||||
(click)="openTurnIntoIssue(comment)"
|
||||
>
|
||||
<pi-icon icon="task" size="small" />
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted && comment.author.id === facade.me()
|
||||
) {
|
||||
@@ -925,6 +1057,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
@if (comment.spawned.length) {
|
||||
<p class="ts-comment__spawned">
|
||||
<span aria-hidden="true">→</span> Convertido en
|
||||
@for (spawn of comment.spawned; track spawn.id) {
|
||||
<a [routerLink]="['..', spawn.key]">{{
|
||||
spawn.key
|
||||
}}</a>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@if (!comment.deleted && comment.poll; as poll) {
|
||||
<div
|
||||
@@ -1044,6 +1186,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
: ''
|
||||
}}</span
|
||||
>
|
||||
@if (newIn(thread); as count) {
|
||||
<span class="ts-comment__new"
|
||||
>{{ count }}
|
||||
{{ count === 1 ? 'nuevo' : 'nuevos' }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1216,6 +1364,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
@if (pollOn()) {
|
||||
<fieldset class="ts-poll-editor">
|
||||
<legend>Encuesta · de 2 a 6 opciones</legend>
|
||||
<p class="ts-poll-editor__hint">
|
||||
Se publica con el comentario, al pulsar «Publicar
|
||||
encuesta». El texto de arriba es opcional.
|
||||
</p>
|
||||
<pi-text-field
|
||||
placeholder="¿Stripe o Redsys para los pagos?"
|
||||
[value]="pollQuestion()"
|
||||
(valueChange)="pollQuestion.set($event.slice(0, 200))"
|
||||
>
|
||||
<pi-label [isRequired]="true">Pregunta</pi-label>
|
||||
</pi-text-field>
|
||||
@for (option of pollOptions(); track $index) {
|
||||
<div class="ts-poll-editor__row">
|
||||
<pi-text-field
|
||||
@@ -1251,11 +1410,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(execute)="pollOptions.set([...pollOptions(), ''])"
|
||||
/>
|
||||
}
|
||||
<pi-action-button
|
||||
size="small"
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[hasPadding]="false"
|
||||
appearance="outline"
|
||||
size="small"
|
||||
text="Quitar la encuesta"
|
||||
(execute)="pollOn.set(false)"
|
||||
/>
|
||||
@@ -1291,8 +1450,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Comentar"
|
||||
[disabled]="!commentBody().trim() && !commentFiles().length"
|
||||
[text]="pollOn() ? 'Publicar encuesta' : 'Comentar'"
|
||||
[disabled]="
|
||||
pollOn()
|
||||
? !pollQuestion().trim() || pollReady() < 2
|
||||
: !commentBody().trim() && !commentFiles().length
|
||||
"
|
||||
(execute)="addComment()"
|
||||
/>
|
||||
</div>
|
||||
@@ -1304,7 +1467,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<h2 class="ts-section">Historial</h2>
|
||||
<ol class="ts-log">
|
||||
@for (entry of facade.history(); track entry.id) {
|
||||
<li>
|
||||
<li [class.is-new]="isNewChange(entry)">
|
||||
<time
|
||||
class="ts-log__when"
|
||||
[attr.datetime]="entry.occurredAt"
|
||||
@@ -1451,6 +1614,41 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-skeleton shape="text" [lines]="8" />
|
||||
}
|
||||
|
||||
<ng-template #turnIntoIssueForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); sendTurnIntoIssue()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
El texto del comentario será la descripción, y el comentario enlazará
|
||||
al ticket nuevo.
|
||||
</p>
|
||||
<pi-text-field [(value)]="spawnTitle">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-select-field
|
||||
[options]="subtaskTypeOptions()"
|
||||
[value]="spawnType()"
|
||||
(valueChange)="spawnType.set($event ?? '')"
|
||||
>
|
||||
<pi-label>Tipo</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-segmented-control
|
||||
ariaLabel="Cómo queda"
|
||||
[options]="spawnModes()"
|
||||
[(value)]="spawnMode"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Crear ticket"
|
||||
[disabled]="!spawnTitle().trim() || !spawnType()"
|
||||
(execute)="sendTurnIntoIssue()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #reviewRequestForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -1673,6 +1871,20 @@ export class IssuePage {
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly reminderForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reminderForm');
|
||||
private readonly turnIntoIssueForm =
|
||||
viewChild.required<TemplateRef<unknown>>('turnIntoIssueForm');
|
||||
/** «Convertir en ticket»: the comment, and the ticket being made of it. */
|
||||
private readonly spawnFrom = signal<Comment | null>(null);
|
||||
protected readonly spawnTitle = signal('');
|
||||
protected readonly spawnType = signal('');
|
||||
protected readonly spawnMode = signal<'link' | 'subtask'>('link');
|
||||
protected readonly spawnModes = computed(() => {
|
||||
const key = this.facade.issue()?.key ?? 'este';
|
||||
return [
|
||||
{ value: 'link' as const, label: `Enlazado a ${key}` },
|
||||
{ value: 'subtask' as const, label: `Subtarea de ${key}` },
|
||||
];
|
||||
});
|
||||
private readonly reviewRequestForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reviewRequestForm');
|
||||
private readonly reviewAnswerForm =
|
||||
@@ -2024,6 +2236,17 @@ export class IssuePage {
|
||||
if (id && this.facade.comments().some((c) => c.id === id))
|
||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||
});
|
||||
// My visit before this one, once per ticket (only with a session).
|
||||
effect(() => {
|
||||
const issueId = this.openIssueId();
|
||||
if (!issueId || !this.facade.me()) return;
|
||||
untracked(() =>
|
||||
this.issuesGateway.visit(issueId).subscribe({
|
||||
next: (since) => this.visit.set({ issueId, since }),
|
||||
error: () => this.visit.set(null),
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Whether it is in today's «Mi día», once per ticket.
|
||||
effect(() => {
|
||||
const issueId = this.openIssueId();
|
||||
@@ -2106,6 +2329,82 @@ export class IssuePage {
|
||||
protected readonly inMyDay = signal<boolean | null>(null);
|
||||
/** Changes with the ticket, not with each reload of it. */
|
||||
private readonly openIssueId = computed(() => this.facade.issue()?.id);
|
||||
/** «Más» opens under its button, aligned to its right edge. */
|
||||
protected readonly morePositions: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
offsetY: 6,
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
offsetY: -6,
|
||||
},
|
||||
];
|
||||
|
||||
/* «Lo nuevo desde tu última visita»: what others did since I last came. */
|
||||
private readonly visit = signal<{
|
||||
issueId: string;
|
||||
since: string | null;
|
||||
} | null>(null);
|
||||
private readonly since = computed(() => {
|
||||
const visit = this.visit();
|
||||
return visit && visit.issueId === this.openIssueId() ? visit.since : null;
|
||||
});
|
||||
protected isNew(comment: Comment): boolean {
|
||||
return isNewComment(comment, this.since(), this.facade.me());
|
||||
}
|
||||
protected isNewChange(entry: Activity): boolean {
|
||||
return isNewChange(entry, this.since(), this.facade.me());
|
||||
}
|
||||
protected newIn(thread: { comment: Comment; replies: Comment[] }): number {
|
||||
return [thread.comment, ...thread.replies].filter((c) => this.isNew(c))
|
||||
.length;
|
||||
}
|
||||
protected readonly news = computed(() =>
|
||||
newsSince(
|
||||
this.facade.comments(),
|
||||
this.facade.history(),
|
||||
this.since(),
|
||||
this.facade.me(),
|
||||
),
|
||||
);
|
||||
|
||||
/** To the first new comment, unfolding its conversation if settled. */
|
||||
protected goToNews(commentId: string): void {
|
||||
const thread = this.threads().find(
|
||||
(t) =>
|
||||
t.comment.id === commentId || t.replies.some((r) => r.id === commentId),
|
||||
);
|
||||
if (thread?.comment.resolved && !this.expanded().has(thread.comment.id))
|
||||
this.toggleThread(thread.comment.id);
|
||||
setTimeout(() => this.goToComment(commentId));
|
||||
}
|
||||
|
||||
protected openTurnIntoIssue(comment: Comment): void {
|
||||
this.spawnFrom.set(comment);
|
||||
this.spawnTitle.set(titleFrom(comment.body));
|
||||
this.spawnType.set(this.defaultSubtaskType());
|
||||
this.spawnMode.set('link');
|
||||
this.drawer.open('Convertir en ticket', this.turnIntoIssueForm());
|
||||
}
|
||||
|
||||
protected sendTurnIntoIssue(): void {
|
||||
const comment = this.spawnFrom();
|
||||
if (!comment || !this.spawnTitle().trim() || !this.spawnType()) return;
|
||||
this.facade.turnCommentIntoIssue(
|
||||
comment.id,
|
||||
this.spawnTitle(),
|
||||
this.spawnType(),
|
||||
this.spawnMode() === 'subtask',
|
||||
);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
private readonly issuesGateway = inject(IssuesGateway);
|
||||
|
||||
protected toggleMyDay(issue: Issue): void {
|
||||
@@ -2291,9 +2590,12 @@ export class IssuePage {
|
||||
|
||||
protected addComment(): void {
|
||||
const files = this.commentFiles();
|
||||
// Only files (a recording, say) need no words: their names say it.
|
||||
const text = this.commentBody().trim();
|
||||
const question = this.pollOn() ? this.pollQuestion().trim() : '';
|
||||
// A poll's question heads the comment; the text, if any, follows.
|
||||
const body =
|
||||
this.commentBody().trim() ||
|
||||
(question ? `**${question}**${text ? `\n\n${text}` : ''}` : text) ||
|
||||
// Only files (a recording, say) need no words: their names say it.
|
||||
(files.length === 1
|
||||
? `📎 ${files[0].name}`
|
||||
: files.length
|
||||
@@ -2303,6 +2605,10 @@ export class IssuePage {
|
||||
const options = this.pollOptions()
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (this.pollOn() && !question) {
|
||||
this.toast.error('Escribe la pregunta de la encuesta');
|
||||
return;
|
||||
}
|
||||
if (this.pollOn() && options.length < 2) {
|
||||
this.toast.error('Una encuesta necesita al menos dos opciones');
|
||||
return;
|
||||
@@ -2315,9 +2621,15 @@ export class IssuePage {
|
||||
|
||||
/** A quick poll in the new comment: its options while it is written. */
|
||||
protected readonly pollOn = signal(false);
|
||||
protected readonly pollQuestion = signal('');
|
||||
protected readonly pollOptions = signal<string[]>(['', '']);
|
||||
/** Options written so far: two make a poll ready to publish. */
|
||||
protected readonly pollReady = computed(
|
||||
() => this.pollOptions().filter((o) => o.trim()).length,
|
||||
);
|
||||
|
||||
protected startPoll(): void {
|
||||
this.pollQuestion.set('');
|
||||
this.pollOptions.set(['', '']);
|
||||
this.pollOn.set(true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user