import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
HostListener,
inject,
input,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
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 {
PiActionButtonComponent,
PiCheckboxComponent,
PiDateFieldComponent,
PiFileUploadComponent,
PiIconButtonComponent,
PiIconComponent,
PiLabelDirective,
PiNoteComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiFileRejection,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
import {
IssueFacade,
ProjectFacade,
provideIssueFacade,
} from '@task-stream/web/tracker/application';
import {
Attachment,
Comment,
DevLink,
IssueLink,
IssueLinkType,
IssuePriority,
isTyping,
POINT_SCALE,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
clock,
initials,
LINK,
options,
pointOptions,
PRIORITY,
statusLook,
timeAgo,
typeLook,
} from '../ui/copy';
import { AttachmentItem } from '../ui/attachments';
import { BlockedComponent } from '../ui/blocked';
import { ReactionsComponent } from '../ui/reactions';
import {
AttachmentLoader,
ImageUploader,
MarkdownComponent,
} from '../ui/markdown';
import { RichEditorComponent } from '../ui/rich-editor';
const NONE = '';
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
const MAX_FILE_BYTES = 25 * 1024 * 1024;
@Component({
selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlockedComponent,
ReactionsComponent,
MarkdownComponent,
RichEditorComponent,
NgTemplateOutlet,
RouterLink,
FormField,
AttachmentItem,
PiActionButtonComponent,
PiCheckboxComponent,
PiDateFieldComponent,
PiFileUploadComponent,
PiIconComponent,
PiIconButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiProgressBarComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
PiTextareaComponent,
],
providers: [provideIssueFacade()],
styleUrl: './issue.page.scss',
template: `
← Tablero
@if (facade.issue(); as issue) {
@if (issue.trashedAt) {
En la papelera desde {{ ago(issue.trashedAt) }}: no aparece en el
tablero ni en las búsquedas, y se borrará del todo a los 30 días.
@if (canTrash(issue)) {
}
}
@if (project.canContribute() && issue.allowedTransitions.length) {
Mover a
@for (to of issue.allowedTransitions; track to) {
{{ statusLabel(to) }}
→
}
}
Descripción
@if (issue.description) {
} @else {
Sin descripción.
}
@if (issue.figmaUrl) {
Ver el diseño en Figma
}
Checklist
@if (issue.checklist.length) {
{{ facade.checklistProgress().done }}/{{
facade.checklistProgress().total
}}
}
@if (issue.checklist.length) {
@for (item of issue.checklist; track item.id) {
@if (project.canContribute()) {
}
}
} @else if (!project.canContribute()) {
Sin pasos.
}
@if (project.canContribute()) {
}
Subtareas
@if (facade.children().length) {
{{ facade.childProgress().finished }}/{{
facade.childProgress().total
}}
}
@if (facade.children().length) {
@for (child of facade.children(); track child.id) {
{{
child.key
}}
{{ child.title }}
{{ statusLabel(child.status) }}
@if (child.assignee) {
}
}
} @else if (!project.canContribute()) {
Sin subtareas.
}
@if (project.canContribute()) {
}
Adjuntos
{{ ticketFiles().length }}
@if (ticketFiles().length) {
@for (file of ticketFiles(); track file.id) {
}
} @else if (!project.canContribute()) {
Sin adjuntos.
}
@if (project.canContribute()) {
}
Relaciones
@for (
link of facade.outwardLinks();
track link.issueId + link.type
) {
}
@for (
link of facade.inwardLinks();
track link.issueId + link.type
) {
}
@if (!issue.links.length) {
Sin relaciones. Vincula FE ↔ BE ↔ QA para seguir el trabajo de
punta a punta.
}
@if (project.canContribute()) {
}
@if (issue.devLinks.length) {
Desarrollo
{{ issue.devLinks.length }}
@for (link of devLinks(); track link.url) {
{{ devKind(link) }}
{{ link.title }}
{{ link.repository ? link.repository + ' · ' : ''
}}{{ link.ref }}
@if (link.state) {
{{
devState(link)
}}
}
{{ link.author ? link.author + ' · ' : ''
}}{{ ago(link.at) }}
}
}
Comentarios
{{ commentCount() }}
@for (thread of threads(); track thread.comment.id) {
@for (reply of thread.replies; track reply.id) {
}
@if (replyingTo() === thread.comment.id) {
} @else if (
project.canContribute() && !thread.comment.deleted
) {
Responder{{
thread.replies.length
? ' (' + thread.replies.length + ')'
: ''
}}
}
}
@if (project.canContribute()) {
}
Historial
@for (entry of facade.history(); track entry.id) {
{{ clock(entry.occurredAt) }}
{{ entry.summary }}
{{ ago(entry.occurredAt) }}
}
Ficha
Asignado
Release
Sprint
Tipo
Prioridad
Estimación
Empieza
Fecha límite
Área
@if (!issue.trashedAt && canTrash(issue)) {
}
Reportado por
{{ issue.reporter.name }}
Creado
{{ ago(issue.createdAt) }}
Actualizado
{{ ago(issue.updatedAt) }}
@if (issue.labels.length) {
Etiquetas
@for (label of issue.labels; track label) {
#{{ label }}
}
}
} @else if (facade.error(); as error) {
} @else {
}
`,
})
export class IssuePage {
/** Route param (`withComponentInputBinding`). */
readonly issueKey = input.required();
protected readonly facade = inject(IssueFacade);
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>('reasonForm');
private readonly editForm =
viewChild.required>('editForm');
private readonly commentEditForm =
viewChild.required>('commentEditForm');
protected readonly type = computed(() =>
typeLook(this.project.workflow(), this.facade.issue()?.type ?? ''),
);
protected readonly status = computed(() =>
statusLook(this.project.workflow(), this.facade.issue()?.status ?? ''),
);
protected readonly ago = timeAgo;
protected readonly assigneeOptions = computed[]>(
() => [
{ value: NONE, label: 'Sin asignar' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly releaseOptions = computed[]>(
() => [
{ value: NONE, label: 'Sin release' },
...this.project
.releases()
.filter(
(r) =>
r.status === ReleaseStatus.PLANNED ||
r.id === this.facade.issue()?.release?.id,
)
.map((r) => ({ value: r.id, label: r.name })),
],
);
/** Open sprints, plus the ticket's own even if it closed since. */
protected readonly sprintOptions = computed[]>(
() => {
const current = this.facade.issue()?.sprint;
const open = this.project.openSprints();
return [
{ value: NONE, label: 'Backlog (sin sprint)' },
...open.map((s) => ({
value: s.id,
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
})),
...(current && !open.some((s) => s.id === current.id)
? [{ value: current.id, label: `${current.name} · cerrado` }]
: []),
];
},
);
/** A closed sprint keeps its tickets. */
protected readonly sprintLocked = computed(() => {
const current = this.facade.issue()?.sprint;
return (
!!current && !this.project.openSprints().some((s) => s.id === current.id)
);
});
protected readonly priorityOptions = options(PRIORITY);
protected readonly pointsOptions = computed(() =>
pointOptions(POINT_SCALE, this.facade.issue()?.storyPoints ?? null),
);
/** Active values, plus the ticket's own even if archived since. */
protected readonly typeOptions = computed[]>(
() => {
const workflow = this.project.workflow();
const current = this.facade.issue()?.type;
return workflow.settings.issueTypes
.filter((t) => !t.archived || t.id === current)
.map((t) => ({ value: t.id, label: t.name }));
},
);
protected readonly areaOptions = computed[]>(
() => {
const workflow = this.project.workflow();
const current = this.facade.issue()?.area;
return [
{ value: NONE, label: 'Sin área' },
...workflow.settings.areas
.filter((a) => !a.archived || a.id === current)
.map((a) => ({ value: a.id, label: a.name })),
];
},
);
protected readonly linkTypeOptions = (
Object.keys(LINK) as IssueLinkType[]
).map((value) => ({
value,
label: LINK[value].outward,
}));
// Inline forms: plain signals (a required validator would flag them red right after submitting).
protected readonly linkType = signal(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal(null);
/** Tickets of my projects, to pick from instead of typing a key. */
private readonly candidates = toSignal(
toObservable(computed(() => this.facade.issue()?.id ?? null)).pipe(
filter((id): id is string => !!id),
distinctUntilChanged(),
switchMap(() => this.facade.candidates().pipe(catchError(() => of([])))),
),
{ initialValue: [] },
);
/** Any of them: links may cross projects (FE ↔ BE ↔ QA). */
protected readonly linkOptions = computed(() =>
this.candidates().map((issue) => ({
value: issue.key,
label: `${issue.key} · ${issue.title}`,
})),
);
/** Only this project's: a ticket hangs from one of its own. */
protected readonly parentOptions = computed(() => {
const projectId = this.facade.issue()?.projectId;
return this.candidates()
.filter((issue) => issue.projectId === projectId)
.map((issue) => ({
value: issue.key,
label: `${issue.key} · ${issue.title}`,
}));
});
protected readonly commentBody = signal('');
/** The comment being answered, and the answer being written. */
protected readonly replyingTo = signal(null);
protected readonly replyBody = signal('');
/** Top-level comments, each with its replies (oldest first). */
protected readonly threads = computed(() => {
const comments = this.facade.comments();
const ids = new Set(comments.map((c) => c.id));
const replies = new Map();
for (const c of comments)
if (c.replyTo && ids.has(c.replyTo))
replies.set(c.replyTo, [...(replies.get(c.replyTo) ?? []), c]);
// A reply whose comment did not load stands on its own.
return comments
.filter((c) => !c.replyTo || !ids.has(c.replyTo))
.map((comment) => ({ comment, replies: replies.get(comment.id) ?? [] }));
});
protected readonly commentCount = computed(
() => this.facade.comments().filter((c) => !c.deleted).length,
);
protected readonly commentFiles = signal([]);
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);
/** Who `@` offers in what is written here. */
protected readonly mentionable = computed(() =>
this.project.members().map((m) => m.user),
);
/** Pull requests first, then the newest. */
protected readonly devLinks = computed(() =>
[...(this.facade.issue()?.devLinks ?? [])].sort(
(a, b) =>
Number(b.kind === 'PULL_REQUEST') - Number(a.kind === 'PULL_REQUEST') ||
b.at.localeCompare(a.at),
),
);
protected devKind = (link: DevLink) =>
link.kind === 'PULL_REQUEST'
? 'PR'
: link.kind === 'BRANCH'
? 'Rama'
: 'Commit';
protected devState = (link: DevLink) =>
link.state === 'MERGED'
? 'Fusionada'
: link.state === 'CLOSED'
? 'Cerrada'
: 'Abierta';
protected readonly checklistText = signal('');
protected readonly subtaskTitle = signal('');
protected readonly subtaskType = signal(null);
protected readonly parentKey = signal(null);
/** Active types; subtasks start as tasks when the project has them. */
protected readonly subtaskTypeOptions = computed<
PiSelectFieldOption[]
>(() =>
this.project
.workflow()
.settings.issueTypes.filter((t) => !t.archived)
.map((t) => ({ value: t.id, label: t.name })),
);
protected readonly defaultSubtaskType = computed(() => {
const ids = this.subtaskTypeOptions().map((o) => o.value);
return ids.includes('TASK') ? 'TASK' : (ids[0] ?? '');
});
protected readonly pendingFiles = signal([]);
protected readonly maxFileBytes = MAX_FILE_BYTES;
/** Files on the ticket itself; those under a comment are shown there. */
/** Files shown inside the description or a comment (pasted images). */
private readonly inlineFiles = computed(() => {
const texts = [
this.facade.issue()?.description ?? '',
...this.facade.comments().map((c) => c.body),
].join('\n');
return new Set(
[...texts.matchAll(/\(attachment:([\w-]{1,64})\)/g)].map((m) => m[1]),
);
});
/** The ticket's own files, not the ones already shown inside a text. */
protected readonly ticketFiles = computed(
() =>
this.facade
.issue()
?.attachments.filter(
(a) => !a.commentId && !this.inlineFiles().has(a.id),
) ?? [],
);
protected filesOf = (comment: Comment): Attachment[] =>
this.facade
.issue()
?.attachments.filter((a) => a.commentId === comment.id) ?? [];
protected canRemove = (file: Attachment): boolean =>
this.project.canContribute() &&
(file.uploadedBy.id === this.facade.me() || this.project.canManage());
private pendingStatus: string | null = null;
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
protected readonly editModel = signal({
title: '',
description: '',
labels: '',
figmaUrl: '',
});
protected readonly edit = form(this.editModel, (p) => required(p.title));
private editingComment: string | null = null;
protected readonly commentEditModel = signal({ body: '' });
protected readonly commentEdit = form(this.commentEditModel, (p) =>
required(p.body),
);
constructor() {
effect(() => this.facade.open(this.issueKey()));
}
protected statusLabel = (status: string) =>
statusLook(this.project.workflow(), status).label;
protected statusTone = (status: string) =>
statusLook(this.project.workflow(), status).tone;
protected readonly initialsOf = initials;
protected readonly clock = clock;
protected linkLabel = (link: IssueLink) =>
link.direction === 'OUTWARD'
? LINK[link.type].outward
: LINK[link.type].inward;
protected addChecklistItem(): void {
const text = this.checklistText().trim();
if (!text) return;
this.facade.addChecklistItem(text);
this.checklistText.set('');
}
protected addSubtask(): void {
const title = this.subtaskTitle().trim();
const type = this.subtaskType() ?? this.defaultSubtaskType();
if (!title || !type) return;
this.facade.addSubtask(title, type);
this.subtaskTitle.set('');
}
protected setParent(): void {
const key = this.parentKey();
if (!key) return;
this.facade.setParent(key);
this.parentKey.set(null);
}
protected move(to: string): void {
if (this.project.workflow().requiresReason(to)) {
this.pendingStatus = to;
this.reasonModel.set({ text: '' });
this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm());
return;
}
this.facade.changeStatus(to);
}
protected confirmMove(): void {
if (!this.pendingStatus || this.reason().invalid()) return;
this.facade.changeStatus(this.pendingStatus, this.reasonModel().text);
this.pendingStatus = null;
this.drawer.closeAll();
}
protected assign(assigneeId: string | null): void {
this.facade.assign(assigneeId || null);
}
protected setRelease(releaseId: string | null): void {
this.facade.setRelease(releaseId || null);
}
/** `YYYY-MM-DD` → the local date the picker shows. */
protected dateOf(day: string | null): Date | null {
return day ? new Date(`${day.slice(0, 10)}T00:00:00`) : null;
}
protected setDate(field: 'startDate' | 'dueDate', date: Date | null): void {
const day = date
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
: null;
if (day !== (this.facade.issue()?.[field]?.slice(0, 10) ?? null))
this.update({ [field]: day });
}
protected update(changes: {
type?: string;
priority?: IssuePriority;
area?: string | null;
storyPoints?: number | null;
startDate?: string | null;
dueDate?: string | null;
}): void {
this.facade.update(changes);
}
protected link(): void {
const targetKey = this.linkTarget();
if (!targetKey) return;
this.facade.link(
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
targetKey,
);
this.linkTarget.set(null);
}
protected unlink(link: IssueLink): void {
this.facade.unlink(link);
}
protected addComment(): void {
const body = this.commentBody().trim();
if (!body) return;
this.facade.addComment(body, this.commentFiles());
this.commentBody.set('');
this.commentFiles.set([]);
}
protected startReply(comment: Comment): void {
this.replyBody.set('');
this.replyingTo.set(comment.id);
}
protected reply(comment: Comment): void {
const body = this.replyBody().trim();
if (!body) return;
this.facade.addComment(body, [], comment.id);
this.replyBody.set('');
this.replyingTo.set(null);
}
protected attach(): void {
this.facade.attach(this.pendingFiles());
this.pendingFiles.set([]);
}
protected rejected(rejections: PiFileRejection[]): void {
for (const { file, reason } of rejections) {
this.toast.error(
reason === 'size'
? `${file.name} pasa de 25 MB`
: reason === 'max-files'
? 'Como mucho 10 archivos a la vez'
: `${file.name} no es un tipo admitido`,
);
}
}
/** A screenshot pasted anywhere on the ticket is attached to it. */
/** Whoever opened it, or whoever manages the project. */
protected canTrash(issue: { reporter: { id: string } }): boolean {
return (
this.project.canContribute() &&
(issue.reporter.id === this.facade.me() || this.project.canManage())
);
}
protected trash(): void {
if (
confirm(
'¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.',
)
)
this.facade.setTrashed(true);
}
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
@HostListener('document:keydown', ['$event'])
protected onKey(event: KeyboardEvent): void {
if (
event.key !== 'e' ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
isTyping(event.target) ||
!this.project.canContribute() ||
document.querySelector('[role="dialog"]')
)
return;
event.preventDefault();
this.openEdit();
}
@HostListener('document:paste', ['$event'])
protected pasted(event: ClipboardEvent): void {
const target = event.target as HTMLElement | null;
// Pasting text into a field is just typing.
if (target?.closest('input, textarea, [contenteditable]')) return;
if (!this.project.canContribute() || !this.facade.issue()) return;
const files = [...(event.clipboardData?.files ?? [])].filter(
(file) => file.size <= MAX_FILE_BYTES,
);
if (!files.length) return;
event.preventDefault();
this.facade.attach(
files.map((file) =>
file.name && file.name !== 'image.png'
? file
: new File(
[file],
`captura-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`,
{
type: file.type,
},
),
),
);
}
protected openEditComment(comment: Comment): void {
this.editingComment = comment.id;
this.commentEditModel.set({ body: comment.body });
this.drawer.open('Editar comentario', this.commentEditForm());
}
protected saveComment(): void {
if (!this.editingComment || this.commentEdit().invalid()) return;
this.facade.editComment(this.editingComment, this.commentEditModel().body);
this.drawer.closeAll();
}
protected deleteComment(comment: Comment): void {
if (confirm('¿Eliminar este comentario?'))
this.facade.deleteComment(comment.id);
}
protected openEdit(): void {
const issue = this.facade.issue();
if (!issue) return;
this.editModel.set({
title: issue.title,
description: issue.description ?? '',
labels: issue.labels.join(', '),
figmaUrl: issue.figmaUrl ?? '',
});
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem');
}
protected saveEdit(): void {
if (this.edit().invalid()) return;
const v = this.editModel();
this.facade.update({
title: v.title,
description: v.description || null,
labels: v.labels
.split(',')
.map((l) => l.trim())
.filter(Boolean),
figmaUrl: v.figmaUrl || null,
});
this.drawer.closeAll();
}
}
Comentario borrado
} @else {