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)) { }
}
{{ issue.key }} {{ type().label }} {{ status().label }} @if (issue.parent; as parent) { {{ parent.key }} {{ parent.title }} } @if (issue.blockedBy.length) { }

{{ issue.title }}

@if (facade.me()) { } @else if (issue.votes) { ▲ {{ issue.votes }} } @if (project.myRole()) { } @if (project.canContribute()) { }
@if (project.canContribute() && issue.allowedTransitions.length) {
Mover a @for (to of issue.allowedTransitions; track to) { }
}

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) { {{ initialsOf(child.assignee.name) }} }
  • }
} @else if (!project.canContribute()) {

Sin subtareas.

} @if (project.canContribute()) {
@if (issue.parent; as parent) { Cuelga de {{ parent.key }} } @else {
}
}

Adjuntos {{ ticketFiles().length }}

@if (ticketFiles().length) {
@for (file of ticketFiles(); track file.id) { }
} @else if (!project.canContribute()) {

Sin adjuntos.

} @if (project.canContribute()) {
@if (pendingFiles().length) {
}
}

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() }}

{{ comment.deleted ? '—' : comment.author.name }} {{ ago(comment.createdAt) }}{{ comment.editedAt ? ' · editado' : '' }} @if ( !comment.deleted && comment.author.id === facade.me() ) { } @if ( !comment.deleted && (comment.author.id === facade.me() || project.canManage()) ) { }
@if (comment.deleted) {

Comentario borrado

} @else { } @if ( !comment.deleted && (comment.reactions.length || project.myRole()) ) { } @if (!comment.deleted && filesOf(comment).length) {
@for (file of filesOf(comment); track file.id) { }
}
@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 ) { }
} @if (project.canContribute()) {
}

Historial

    @for (entry of facade.history(); track entry.id) {
  1. {{ entry.summary }} {{ ago(entry.occurredAt) }}
  2. }
} @else if (facade.error(); as error) { } @else { }

Explica el motivo: quedará en el historial del ticket.

Motivo
Título Etiquetas (separadas por comas) Frame de Figma
`, }) 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(); } }