Files
task-stream/libs/web/tracker/presenters/src/lib/pages/issue.page.ts
Josep Piguillem 50d3ffd1dd feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:59:01 +02:00

1085 lines
36 KiB
TypeScript

import {
ChangeDetectionStrategy,
Component,
computed,
effect,
HostListener,
inject,
input,
signal,
TemplateRef,
viewChild,
} from '@angular/core';
import { form, FormField, required } from '@angular/forms/signals';
import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiCheckboxComponent,
PiFileUploadComponent,
PiIconButtonComponent,
PiIconComponent,
PiLabelDirective,
PiNoteComponent,
PiProgressBarComponent,
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,
insertMention,
IssueLink,
IssueLinkType,
IssuePriority,
matches,
mentionQuery,
mentionSegments,
ReleaseStatus,
} from '@task-stream/web/tracker/domain';
import {
clock,
initials,
LINK,
options,
PRIORITY,
statusLook,
timeAgo,
typeLook,
} from '../ui/copy';
import { AttachmentItem } from '../ui/attachments';
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: [
RouterLink,
FormField,
AttachmentItem,
PiActionButtonComponent,
PiCheckboxComponent,
PiFileUploadComponent,
PiIconComponent,
PiIconButtonComponent,
PiLabelDirective,
PiNoteComponent,
PiProgressBarComponent,
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>
@if (issue.parent; as parent) {
<a
class="ts-parent"
[routerLink]="['..', parent.key]"
[title]="'Cuelga de ' + parent.key"
>
<span aria-hidden="true">↑</span>
<span class="ts-key">{{ parent.key }}</span>
<span class="ts-parent__title">{{ parent.title }}</span>
</a>
}
</div>
<div class="ts-issue__title-row">
<h1>{{ issue.title }}</h1>
@if (project.myRole()) {
<pi-action-button
size="small"
color="grey"
appearance="outline"
[text]="
facade.watching()
? 'Siguiendo · ' + issue.watchers.length
: 'Seguir'
"
[attr.aria-pressed]="facade.watching()"
(execute)="facade.setWatching(!facade.watching())"
/>
}
@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">
Checklist
@if (issue.checklist.length) {
<span class="ts-mono"
>{{ facade.checklistProgress().done }}/{{
facade.checklistProgress().total
}}</span
>
}
</h2>
@if (issue.checklist.length) {
<ul class="ts-checklist">
@for (item of issue.checklist; track item.id) {
<li class="ts-checklist__item" [class.is-done]="item.done">
<pi-checkbox
[label]="item.text"
[checked]="item.done"
[disabled]="!project.canContribute()"
(checkedChange)="
facade.updateChecklistItem(item.id, { done: $event })
"
/>
@if (project.canContribute()) {
<pi-icon-button
icon="close"
color="grey"
appearance="ghost"
[attr.aria-label]="'Quitar «' + item.text + '»'"
(execute)="facade.removeChecklistItem(item.id)"
/>
}
</li>
}
</ul>
} @else if (!project.canContribute()) {
<p class="ts-muted">Sin pasos.</p>
}
@if (project.canContribute()) {
<form
class="ts-inline-form ts-inline-form--two"
(submit)="$event.preventDefault(); addChecklistItem()"
novalidate
>
<pi-text-field
placeholder="Nuevo paso, p. ej. Actualizar el changelog"
[(value)]="checklistText"
/>
<pi-action-button
size="small"
color="primary"
text="Añadir"
[disabled]="!checklistText().trim()"
(execute)="addChecklistItem()"
/>
</form>
}
</article>
<article class="ts-block">
<h2 class="ts-section">
Subtareas
@if (facade.children().length) {
<span class="ts-mono"
>{{ facade.childProgress().finished }}/{{
facade.childProgress().total
}}</span
>
}
</h2>
@if (facade.children().length) {
<pi-progress-bar
[label]="facade.childProgress().percent + '% terminado'"
[value]="facade.childProgress().percent"
/>
<ul class="ts-children">
@for (child of facade.children(); track child.id) {
<li class="ts-child">
<a class="ts-key" [routerLink]="['..', child.key]">{{
child.key
}}</a>
<span class="ts-child__title">{{ child.title }}</span>
<span
class="ts-pill"
[style.--tone]="statusTone(child.status)"
>{{ statusLabel(child.status) }}</span
>
@if (child.assignee) {
<span
class="ts-comment__avatar"
[title]="child.assignee.name"
>{{ initialsOf(child.assignee.name) }}</span
>
}
</li>
}
</ul>
} @else if (!project.canContribute()) {
<p class="ts-muted">Sin subtareas.</p>
}
@if (project.canContribute()) {
<form
class="ts-inline-form"
(submit)="$event.preventDefault(); addSubtask()"
novalidate
>
<pi-select-field
[options]="subtaskTypeOptions()"
[value]="subtaskType() ?? defaultSubtaskType()"
(valueChange)="subtaskType.set($event)"
/>
<pi-text-field
placeholder="Título de la subtarea"
[(value)]="subtaskTitle"
/>
<pi-action-button
size="small"
color="primary"
text="Crear"
[disabled]="!subtaskTitle().trim()"
(execute)="addSubtask()"
/>
</form>
<div class="ts-parent-form">
@if (issue.parent; as parent) {
<span class="ts-muted"
>Cuelga de
<a class="ts-key" [routerLink]="['..', parent.key]">{{
parent.key
}}</a></span
>
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Sacar"
(execute)="facade.setParent(null)"
/>
} @else {
<form
class="ts-inline-form ts-inline-form--two"
(submit)="$event.preventDefault(); setParent()"
novalidate
>
<pi-text-field
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
[(value)]="parentKey"
/>
<pi-action-button
size="small"
color="grey"
appearance="outline"
text="Colgar"
[disabled]="!parentKey().trim()"
(execute)="setParent()"
/>
</form>
}
</div>
}
</article>
<article class="ts-block">
<h2 class="ts-section">
Adjuntos
<span class="ts-mono">{{ ticketFiles().length }}</span>
</h2>
@if (ticketFiles().length) {
<div class="ts-attachments">
@for (file of ticketFiles(); track file.id) {
<ts-attachment
[attachment]="file"
[removable]="canRemove(file)"
/>
}
</div>
} @else if (!project.canContribute()) {
<p class="ts-muted">Sin adjuntos.</p>
}
@if (project.canContribute()) {
<div class="ts-form">
<pi-file-upload
label="Capturas, logs, vídeos…"
hint="Arrastra aquí, elige, o pega una captura con Ctrl+V. Hasta 25 MB por archivo."
[multiple]="true"
[maxFiles]="10"
[maxSizeBytes]="maxFileBytes"
[(value)]="pendingFiles"
(rejected)="rejected($event)"
/>
@if (pendingFiles().length) {
<div>
<pi-action-button
color="primary"
[text]="'Adjuntar ' + pendingFiles().length"
(execute)="attach()"
/>
</div>
}
</div>
}
</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">
@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
>
} @else {
{{ part.text }}
}
}
</p>
@if (filesOf(comment).length) {
<div class="ts-attachments">
@for (file of filesOf(comment); track file.id) {
<ts-attachment
[attachment]="file"
[removable]="canRemove(file)"
/>
}
</div>
}
</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… Escribe @ para mencionar a alguien."
[(value)]="commentBody"
/>
@if (mentionOptions().length) {
<div
class="ts-mentions"
role="group"
aria-label="Mencionar a"
>
@for (member of mentionOptions(); track member.id) {
<button
type="button"
class="ts-mentions__option"
(click)="mention(member)"
>
<span class="ts-comment__avatar" aria-hidden="true">{{
initialsOf(member.name)
}}</span>
{{ member.name }}
</button>
}
</div>
}
<pi-file-upload
label="Adjuntar al comentario"
[multiple]="true"
[maxFiles]="10"
[maxSizeBytes]="maxFileBytes"
[(value)]="commentFiles"
(rejected)="rejected($event)"
/>
<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]="sprintOptions()"
[disabled]="!project.canContribute() || sprintLocked()"
[value]="issue.sprint?.id ?? ''"
(valueChange)="facade.setSprint($event || null)"
>
<pi-label>Sprint</pi-label>
</pi-select-field>
<pi-select-field
[options]="typeOptions()"
[disabled]="!project.canContribute()"
[value]="issue.type"
(valueChange)="update({ type: $any($event) })"
>
<pi-label>Tipo</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 toast = inject(ToastService);
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(() =>
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<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 })),
],
);
/** Open sprints, plus the ticket's own even if it closed since. */
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
() => {
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);
/** Active values, plus the ticket's own even if archived since. */
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
() => {
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<PiSelectFieldOption<string>[]>(
() => {
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<string | null>(IssueLinkType.RELATES_TO);
protected readonly linkTarget = signal('');
protected readonly commentBody = signal('');
protected readonly commentFiles = signal<File[]>([]);
protected readonly segments = mentionSegments;
/** Members matching what is typed after an `@` at the end of the comment. */
protected readonly mentionOptions = computed(() => {
const query = mentionQuery(this.commentBody());
if (query === null) return [];
return this.project
.members()
.map((m) => m.user)
.filter(
(user) => user.id !== this.facade.me() && matches(user.name, query),
)
.slice(0, 6);
});
protected readonly checklistText = signal('');
protected readonly subtaskTitle = signal('');
protected readonly subtaskType = signal<string | null>(null);
protected readonly parentKey = signal('');
/** Active types; subtasks start as tasks when the project has them. */
protected readonly subtaskTypeOptions = computed<
PiSelectFieldOption<string>[]
>(() =>
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<File[]>([]);
protected readonly maxFileBytes = MAX_FILE_BYTES;
/** Files on the ticket itself; those under a comment are shown there. */
protected readonly ticketFiles = computed(
() => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [],
);
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));
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 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().trim();
if (!key) return;
this.facade.setParent(key);
this.parentKey.set('');
}
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.commentFiles());
this.commentBody.set('');
this.commentFiles.set([]);
}
protected mention(user: { id: string; name: string }): void {
this.commentBody.update((text) => insertMention(text, user));
}
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. */
@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();
}
}