import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiIconButtonComponent,
PiIconComponent,
PiLabelDirective,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextareaComponent,
PiTextFieldComponent,
type PiSelectFieldOption,
} from '@pi/ui';
import { DrawerService } from '@task-stream/web/shared/ui';
import {
IssueFacade,
ProjectFacade,
provideIssueFacade,
} from '@task-stream/web/tracker/application';
import {
Comment,
IssueLink,
IssueLinkType,
IssuePriority,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
clock,
initials,
LINK,
options,
PRIORITY,
statusLook,
timeAgo,
typeLook,
} from '../ui/copy';
const NONE = '';
@Component({
selector: 'ts-issue-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterLink,
FormField,
PiActionButtonComponent,
PiIconComponent,
PiIconButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiSelectFieldComponent,
PiSkeletonComponent,
PiTextFieldComponent,
PiTextareaComponent,
],
providers: [provideIssueFacade()],
styleUrl: './issue.page.scss',
template: `
← Tablero
@if (facade.issue(); as issue) {
@if (project.canContribute() && issue.allowedTransitions.length) {
Mover a
@for (to of issue.allowedTransitions; track to) {
}
}
Descripción
@if (issue.description) {
{{ issue.description }}
} @else {
Sin descripción.
}
@if (issue.figmaUrl) {
Ver el diseño en Figma
}
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()) {
}
Comentarios
{{ facade.comments().length }}
@for (comment of facade.comments(); track comment.id) {
}
@if (project.canContribute()) {
}
Historial
@for (entry of facade.history(); track entry.id) {
-
{{ entry.summary }}
{{ ago(entry.occurredAt) }}
}
} @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 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 })),
],
);
protected readonly priorityOptions = options(PRIORITY);
/** 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('');
protected readonly commentBody = signal('');
private pendingStatus: string | null = null;
private readonly reasonModel = signal({ text: '' });
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
private readonly editModel = signal({
title: '',
description: '',
labels: '',
figmaUrl: '',
});
protected readonly edit = form(this.editModel, (p) => required(p.title));
private editingComment: string | null = null;
private 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 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);
}
protected update(changes: {
type?: string;
priority?: IssuePriority;
area?: string | null;
}): void {
this.facade.update(changes);
}
protected link(): void {
const targetKey = this.linkTarget().trim();
if (!targetKey) return;
this.facade.link(
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
targetKey,
);
this.linkTarget.set('');
}
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.commentBody.set('');
}
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();
}
}
{{ comment.body }}