first commit
This commit is contained in:
615
libs/web/tracker/presenters/src/lib/pages/issue.page.ts
Normal file
615
libs/web/tracker/presenters/src/lib/pages/issue.page.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
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,
|
||||
IssueArea,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
REASON_REQUIRED,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
AREA,
|
||||
clock,
|
||||
initials,
|
||||
LINK,
|
||||
options,
|
||||
PRIORITY,
|
||||
STATUS,
|
||||
TYPE,
|
||||
timeAgo,
|
||||
} 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: `
|
||||
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
||||
|
||||
@if (facade.issue(); as issue) {
|
||||
<div class="ts-issue">
|
||||
<section class="ts-issue__main">
|
||||
<header class="ts-issue__header">
|
||||
<div class="ts-issue__meta">
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-type" [style.--tone]="type().tone">
|
||||
<pi-icon [icon]="type().icon" size="x-small" />
|
||||
{{ type().label }}
|
||||
</span>
|
||||
<span class="ts-pill" [style.--tone]="status().tone">{{
|
||||
status().label
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Editar"
|
||||
(execute)="openEdit()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (project.canContribute() && issue.allowedTransitions.length) {
|
||||
<div class="ts-transitions" role="group" aria-label="Mover a">
|
||||
<span class="ts-eyebrow">Mover a</span>
|
||||
@for (to of issue.allowedTransitions; track to) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-move"
|
||||
[style.--tone]="statusTone(to)"
|
||||
(click)="move(to)"
|
||||
>
|
||||
{{ statusLabel(to) }}
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Descripción</h2>
|
||||
@if (issue.description) {
|
||||
<p class="ts-prewrap">{{ issue.description }}</p>
|
||||
} @else {
|
||||
<p class="ts-muted">Sin descripción.</p>
|
||||
}
|
||||
@if (issue.figmaUrl) {
|
||||
<a
|
||||
class="ts-figma"
|
||||
[href]="issue.figmaUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<pi-icon icon="figma" size="small" /> Ver el diseño en Figma
|
||||
</a>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Relaciones</h2>
|
||||
@for (
|
||||
link of facade.outwardLinks();
|
||||
track link.issueId + link.type
|
||||
) {
|
||||
<div class="ts-link">
|
||||
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
|
||||
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
|
||||
link.issueKey
|
||||
}}</a>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="close"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Quitar"
|
||||
(execute)="unlink(link)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@for (
|
||||
link of facade.inwardLinks();
|
||||
track link.issueId + link.type
|
||||
) {
|
||||
<div class="ts-link">
|
||||
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
|
||||
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
|
||||
link.issueKey
|
||||
}}</a>
|
||||
</div>
|
||||
}
|
||||
@if (!issue.links.length) {
|
||||
<p class="ts-muted">
|
||||
Sin relaciones. Vincula FE ↔ BE ↔ QA para seguir el trabajo de
|
||||
punta a punta.
|
||||
</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form"
|
||||
(submit)="$event.preventDefault(); link()"
|
||||
novalidate
|
||||
>
|
||||
<pi-select-field
|
||||
[options]="linkTypeOptions"
|
||||
[(value)]="linkType"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Clave, p. ej. BE-3"
|
||||
[(value)]="linkTarget"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Relacionar"
|
||||
[disabled]="!linkTarget().trim()"
|
||||
(execute)="link()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Comentarios
|
||||
<span class="ts-mono">{{ facade.comments().length }}</span>
|
||||
</h2>
|
||||
@for (comment of facade.comments(); track comment.id) {
|
||||
<div class="ts-comment">
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
}}</span>
|
||||
<div class="ts-comment__body">
|
||||
<div class="ts-comment__head">
|
||||
<strong>{{ comment.author.name }}</strong>
|
||||
<span class="ts-comment__when"
|
||||
>{{ ago(comment.createdAt)
|
||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (comment.author.id === facade.me()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Editar"
|
||||
(execute)="openEditComment(comment)"
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
comment.author.id === facade.me() || project.canManage()
|
||||
) {
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Eliminar"
|
||||
(execute)="deleteComment(comment)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<p class="ts-prewrap">{{ comment.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); addComment()"
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea
|
||||
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
|
||||
[(value)]="commentBody"
|
||||
/>
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Comentar"
|
||||
[disabled]="!commentBody().trim()"
|
||||
(execute)="addComment()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Historial</h2>
|
||||
<ol class="ts-log">
|
||||
@for (entry of facade.history(); track entry.id) {
|
||||
<li>
|
||||
<time
|
||||
class="ts-log__when"
|
||||
[attr.datetime]="entry.occurredAt"
|
||||
>{{ clock(entry.occurredAt) }}</time
|
||||
>
|
||||
<span class="ts-log__what">{{ entry.summary }}</span>
|
||||
<span class="ts-log__ago">{{ ago(entry.occurredAt) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<aside class="ts-issue__side ts-panel">
|
||||
<p class="ts-eyebrow">Ficha</p>
|
||||
<pi-select-field
|
||||
[options]="assigneeOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.assignee?.id ?? ''"
|
||||
(valueChange)="assign($event)"
|
||||
>
|
||||
<pi-label>Asignado</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="releaseOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.release?.id ?? ''"
|
||||
(valueChange)="setRelease($event)"
|
||||
>
|
||||
<pi-label>Release</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="priorityOptions"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.priority"
|
||||
(valueChange)="update({ priority: $any($event) })"
|
||||
>
|
||||
<pi-label>Prioridad</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="areaOptions"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.area ?? ''"
|
||||
(valueChange)="update({ area: $any($event || null) })"
|
||||
>
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
|
||||
<dl class="ts-facts">
|
||||
<dt>Reportado por</dt>
|
||||
<dd>{{ issue.reporter.name }}</dd>
|
||||
<dt>Creado</dt>
|
||||
<dd class="ts-mono">{{ ago(issue.createdAt) }}</dd>
|
||||
<dt>Actualizado</dt>
|
||||
<dd class="ts-mono">{{ ago(issue.updatedAt) }}</dd>
|
||||
@if (issue.labels.length) {
|
||||
<dt>Etiquetas</dt>
|
||||
<dd class="ts-labels">
|
||||
@for (label of issue.labels; track label) {
|
||||
<span class="ts-label">#{{ label }}</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
} @else if (facade.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudo abrir el ticket"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else {
|
||||
<pi-skeleton shape="text" [lines]="8" />
|
||||
}
|
||||
|
||||
<ng-template #reasonForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); confirmMove()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
Explica el motivo: quedará en el historial del ticket.
|
||||
</p>
|
||||
<pi-textarea
|
||||
[formField]="reason.text"
|
||||
placeholder="Qué ha fallado y cómo reproducirlo"
|
||||
>
|
||||
<pi-label [isRequired]="true">Motivo</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Mover"
|
||||
[disabled]="reason().invalid()"
|
||||
(execute)="confirmMove()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #editForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); saveEdit()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field [formField]="edit.title">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea [formField]="edit.description">
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
|
||||
<pi-label>Etiquetas (separadas por comas)</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-text-field
|
||||
[formField]="edit.figmaUrl"
|
||||
placeholder="https://www.figma.com/design/…"
|
||||
>
|
||||
<pi-label>Frame de Figma</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Guardar"
|
||||
[disabled]="edit().invalid()"
|
||||
(execute)="saveEdit()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #commentEditForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); saveComment()"
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea [formField]="commentEdit.body" />
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Guardar"
|
||||
[disabled]="commentEdit().invalid()"
|
||||
(execute)="saveComment()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
export class IssuePage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly issueKey = input.required<string>();
|
||||
|
||||
protected readonly facade = inject(IssueFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly editForm =
|
||||
viewChild.required<TemplateRef<unknown>>('editForm');
|
||||
private readonly commentEditForm =
|
||||
viewChild.required<TemplateRef<unknown>>('commentEditForm');
|
||||
|
||||
protected readonly type = computed(
|
||||
() => TYPE[this.facade.issue()?.type ?? 'TASK'],
|
||||
);
|
||||
protected readonly status = computed(
|
||||
() => STATUS[this.facade.issue()?.status ?? IssueStatus.BACKLOG],
|
||||
);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Sin asignar' },
|
||||
...this.project
|
||||
.members()
|
||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||
],
|
||||
);
|
||||
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ 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);
|
||||
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: NONE, label: 'Sin área' },
|
||||
...options(AREA),
|
||||
];
|
||||
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<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly commentBody = signal('');
|
||||
|
||||
private pendingStatus: IssueStatus | 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: IssueStatus) => STATUS[status].label;
|
||||
protected statusTone = (status: IssueStatus) => STATUS[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: IssueStatus): void {
|
||||
if (REASON_REQUIRED.includes(to)) {
|
||||
this.pendingStatus = to;
|
||||
this.reasonModel.set({ text: '' });
|
||||
this.drawer.open(`Mover a «${STATUS[to].label}»`, 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: {
|
||||
priority?: IssuePriority;
|
||||
area?: IssueArea | 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user