feat: formatted text, a list view, ticket details, public proposals and votes
Formatted text
- Descriptions, comments, replies and release notes render Markdown,
sanitised with DOMPurify (tested against scripts, handlers and
javascript: links). A pasted image is uploaded and written as
; the web fetches it with the reader's session,
so it works on private projects. Every text box has a preview. The
attachment upload now answers its attachmentId.
List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
text, open/closed, type, priority and assignee; status, priority and
assignee change in place; bulk edit (the backlog's bar, now shared).
Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
when reading with one query per board or page; shown on cards, list
rows and the ticket.
Public proposals
- A public project can take bug reports and ideas from anyone
(acceptsProposals): POST /api/explore/projects/{project}/proposals and
the form at /p/:key/proponer. They become tickets labelled «propuesta»,
opened by TaskStream; a honeypot field and 5 per address every 15
minutes keep spam out. No e-mail is asked: it would be public.
Votes
- Anyone signed in votes on a public project's tickets, members on a
private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
voted on each ticket, buttons on the ticket and the roadmap, a column
in the list. The showcase carries votes and the open proposals.
MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
@@ -47,9 +47,9 @@ import {
|
||||
isTyping,
|
||||
matches,
|
||||
mentionQuery,
|
||||
mentionSegments,
|
||||
POINT_SCALE,
|
||||
ReleaseStatus,
|
||||
REACTIONS,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
clock,
|
||||
@@ -63,6 +63,13 @@ import {
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
import { AttachmentItem } from '../ui/attachments';
|
||||
import {
|
||||
AttachmentLoader,
|
||||
ImageUploader,
|
||||
MarkdownComponent,
|
||||
MarkdownHintComponent,
|
||||
PasteImagesDirective,
|
||||
} from '../ui/markdown';
|
||||
|
||||
const NONE = '';
|
||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||
@@ -72,6 +79,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
selector: 'ts-issue-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MarkdownComponent,
|
||||
MarkdownHintComponent,
|
||||
PasteImagesDirective,
|
||||
NgTemplateOutlet,
|
||||
RouterLink,
|
||||
FormField,
|
||||
@@ -133,10 +143,46 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<span class="ts-parent__title">{{ parent.title }}</span>
|
||||
</a>
|
||||
}
|
||||
@if (issue.blockedBy.length) {
|
||||
<span
|
||||
class="ts-blocked"
|
||||
role="status"
|
||||
[title]="blockedTitle(issue.blockedBy)"
|
||||
>
|
||||
<pi-icon icon="lock" size="x-small" aria-hidden="true" />
|
||||
Bloqueado por
|
||||
@for (blocker of issue.blockedBy; track blocker.id) {
|
||||
<a class="ts-key" [routerLink]="['..', blocker.key]">{{
|
||||
blocker.key
|
||||
}}</a>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
<div class="ts-issue__actions">
|
||||
@if (facade.me()) {
|
||||
<pi-icon-button
|
||||
icon="arrow-up"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="
|
||||
(issue.voted ? 'Votado · ' : 'Votar · ') + issue.votes
|
||||
"
|
||||
[attr.aria-pressed]="issue.voted"
|
||||
[attr.title]="
|
||||
issue.voted
|
||||
? 'Quitar mi voto'
|
||||
: 'Votar: dice al equipo que lo quieres'
|
||||
"
|
||||
(execute)="facade.vote(!issue.voted)"
|
||||
/>
|
||||
} @else if (issue.votes) {
|
||||
<span class="ts-votes ts-mono" title="Votos">
|
||||
▲ {{ issue.votes }}
|
||||
</span>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<pi-icon-button
|
||||
[icon]="facade.watching() ? 'eye-off' : 'eye'"
|
||||
@@ -165,6 +211,14 @@ 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()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,7 +244,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Descripción</h2>
|
||||
@if (issue.description) {
|
||||
<p class="ts-prewrap">{{ issue.description }}</p>
|
||||
<ts-markdown
|
||||
[text]="issue.description"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
} @else {
|
||||
<p class="ts-muted">Sin descripción.</p>
|
||||
}
|
||||
@@ -539,7 +597,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (!comment.deleted && comment.author.id === facade.me()) {
|
||||
@if (
|
||||
!comment.deleted && comment.author.id === facade.me()
|
||||
) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
@@ -564,19 +624,60 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
@if (comment.deleted) {
|
||||
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
|
||||
} @else {
|
||||
<p class="ts-prewrap">
|
||||
@for (part of segments(comment.body); track $index) {
|
||||
@if ('mention' in part) {
|
||||
<span
|
||||
class="ts-mention"
|
||||
[class.is-me]="part.userId === facade.me()"
|
||||
>@{{ part.mention }}</span
|
||||
<ts-markdown
|
||||
[text]="comment.body"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted &&
|
||||
(comment.reactions.length || project.myRole())
|
||||
) {
|
||||
<div class="ts-reactions">
|
||||
@for (
|
||||
reaction of comment.reactions;
|
||||
track reaction.emoji
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reaction"
|
||||
[class.is-mine]="reacted(reaction)"
|
||||
[attr.aria-pressed]="reacted(reaction)"
|
||||
[title]="reactionTitle(reaction)"
|
||||
[disabled]="!project.myRole()"
|
||||
(click)="
|
||||
facade.react(
|
||||
comment.id,
|
||||
reaction.emoji,
|
||||
!reacted(reaction)
|
||||
)
|
||||
"
|
||||
>
|
||||
} @else {
|
||||
{{ part.text }}
|
||||
{{ reaction.emoji }}
|
||||
<span class="ts-mono">{{
|
||||
reaction.users.length
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@if (project.myRole()) {
|
||||
<details class="ts-reaction-picker">
|
||||
<summary title="Reaccionar" aria-label="Reaccionar">
|
||||
<pi-icon icon="add" size="x-small" />
|
||||
</summary>
|
||||
<div class="ts-reaction-picker__menu">
|
||||
@for (emoji of reactions; track emoji) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="pickReaction(comment, emoji, $event)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!comment.deleted && filesOf(comment).length) {
|
||||
<div class="ts-attachments">
|
||||
@@ -593,12 +694,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</ng-template>
|
||||
@for (thread of threads(); track thread.comment.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="commentView; context: { $implicit: thread.comment }"
|
||||
*ngTemplateOutlet="
|
||||
commentView;
|
||||
context: { $implicit: thread.comment }
|
||||
"
|
||||
/>
|
||||
<div class="ts-replies">
|
||||
@for (reply of thread.replies; track reply.id) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="commentView; context: { $implicit: reply }"
|
||||
*ngTemplateOutlet="
|
||||
commentView;
|
||||
context: { $implicit: reply }
|
||||
"
|
||||
/>
|
||||
}
|
||||
@if (replyingTo() === thread.comment.id) {
|
||||
@@ -614,6 +721,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
'… Escribe @ para mencionar a alguien.'
|
||||
"
|
||||
[(value)]="replyBody"
|
||||
[tsPasteImages]="pasteImage"
|
||||
/>
|
||||
<ts-markdown-hint
|
||||
[text]="replyBody()"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
[images]="true"
|
||||
/>
|
||||
@if (replyMentionOptions().length) {
|
||||
<div
|
||||
@@ -621,15 +735,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
role="group"
|
||||
aria-label="Mencionar a"
|
||||
>
|
||||
@for (member of replyMentionOptions(); track member.id) {
|
||||
@for (
|
||||
member of replyMentionOptions();
|
||||
track member.id
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-mentions__option"
|
||||
(click)="mentionInReply(member)"
|
||||
>
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(member.name)
|
||||
}}</span>
|
||||
<span
|
||||
class="ts-comment__avatar"
|
||||
aria-hidden="true"
|
||||
>{{ initialsOf(member.name) }}</span
|
||||
>
|
||||
{{ member.name }}
|
||||
</button>
|
||||
}
|
||||
@@ -651,7 +770,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
} @else if (project.canContribute() && !thread.comment.deleted) {
|
||||
} @else if (
|
||||
project.canContribute() && !thread.comment.deleted
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link ts-reply__open"
|
||||
@@ -675,6 +796,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-textarea
|
||||
placeholder="Deja una decisión, una explicación, un paso para reproducir… Escribe @ para mencionar a alguien."
|
||||
[(value)]="commentBody"
|
||||
[tsPasteImages]="pasteImage"
|
||||
/>
|
||||
<ts-markdown-hint
|
||||
[text]="commentBody()"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
[images]="true"
|
||||
/>
|
||||
@if (mentionOptions().length) {
|
||||
<div
|
||||
@@ -881,9 +1009,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-text-field [formField]="edit.title">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea [formField]="edit.description">
|
||||
<pi-textarea
|
||||
[formField]="edit.description"
|
||||
[tsPasteImages]="pasteImage"
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<ts-markdown-hint
|
||||
[text]="editModel().description"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
[images]="true"
|
||||
/>
|
||||
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
|
||||
<pi-label>Etiquetas (separadas por comas)</pi-label>
|
||||
</pi-text-field>
|
||||
@@ -909,7 +1046,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(submit)="$event.preventDefault(); saveComment()"
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea [formField]="commentEdit.body" />
|
||||
<pi-textarea
|
||||
[formField]="commentEdit.body"
|
||||
[tsPasteImages]="pasteImage"
|
||||
/>
|
||||
<ts-markdown-hint
|
||||
[text]="commentEditModel().body"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
[images]="true"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
@@ -929,6 +1075,8 @@ export class IssuePage {
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly editForm =
|
||||
@@ -1046,7 +1194,48 @@ export class IssuePage {
|
||||
() => this.facade.comments().filter((c) => !c.deleted).length,
|
||||
);
|
||||
protected readonly commentFiles = signal<File[]>([]);
|
||||
protected readonly segments = mentionSegments;
|
||||
protected readonly reactions = REACTIONS;
|
||||
|
||||
protected reacted(reaction: { users: { id: string }[] }): boolean {
|
||||
return reaction.users.some((u) => u.id === this.facade.me());
|
||||
}
|
||||
|
||||
protected reactionTitle(reaction: {
|
||||
emoji: string;
|
||||
users: { name: string }[];
|
||||
}): string {
|
||||
return `${reaction.users.map((u) => u.name).join(', ')} · ${reaction.emoji}`;
|
||||
}
|
||||
|
||||
/** From the picker: adds it, or takes it back if it was already there. */
|
||||
protected pickReaction(comment: Comment, emoji: string, event: Event): void {
|
||||
const mine = comment.reactions.find((r) => r.emoji === emoji);
|
||||
this.facade.react(comment.id, emoji, !(mine && this.reacted(mine)));
|
||||
(event.target as HTMLElement).closest('details')?.removeAttribute('open');
|
||||
}
|
||||
|
||||
protected blockedTitle(blockers: { key: string; title: string }[]): string {
|
||||
return blockers.map((b) => `${b.key}: ${b.title}`).join('\n');
|
||||
}
|
||||
|
||||
protected duplicate(): void {
|
||||
this.facade.duplicate().subscribe({
|
||||
next: (key) => {
|
||||
this.toast.success(
|
||||
key ? `Creado ${key}, copia de este` : 'Copia creada',
|
||||
);
|
||||
if (key)
|
||||
void this.router.navigate(['..', key], { relativeTo: this.route });
|
||||
},
|
||||
error: () => this.toast.error('No se pudo duplicar'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Images inside descriptions and comments: fetched, and uploaded on paste. */
|
||||
protected readonly loadImage: AttachmentLoader = (id) =>
|
||||
this.facade.fileById(id);
|
||||
protected readonly pasteImage: ImageUploader = (file) =>
|
||||
this.facade.uploadImage(file);
|
||||
/** Members matching what is typed after an `@` at the end of the comment. */
|
||||
protected readonly mentionOptions = computed(() =>
|
||||
this.membersMatching(this.commentBody()),
|
||||
@@ -1111,7 +1300,7 @@ export class IssuePage {
|
||||
private readonly reasonModel = signal({ text: '' });
|
||||
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
|
||||
|
||||
private readonly editModel = signal({
|
||||
protected readonly editModel = signal({
|
||||
title: '',
|
||||
description: '',
|
||||
labels: '',
|
||||
@@ -1120,7 +1309,7 @@ export class IssuePage {
|
||||
protected readonly edit = form(this.editModel, (p) => required(p.title));
|
||||
|
||||
private editingComment: string | null = null;
|
||||
private readonly commentEditModel = signal({ body: '' });
|
||||
protected readonly commentEditModel = signal({ body: '' });
|
||||
protected readonly commentEdit = form(this.commentEditModel, (p) =>
|
||||
required(p.body),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user