- Postgres: every table has a uuid `id` (gen_random_uuid()); the event store keeps its relay order in `seq`, and the lookup tables their uniqueness in constraints of their own. - MongoDB: `_id` is Mongo's ObjectId; `id` is the domain id (the same uuid as the aggregate's stream in Postgres), unique, and every query goes by it. Production starts from empty databases. - Web: linking tickets and hanging one from a parent are picked from a searchable select (links across projects, parents within the project) instead of typing a key; every checkbox is @pi/ui's pi-checkbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1478 lines
50 KiB
TypeScript
1478 lines
50 KiB
TypeScript
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: `
|
|
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
|
|
|
@if (facade.issue(); as issue) {
|
|
@if (issue.trashedAt) {
|
|
<div class="ts-trashed" role="status">
|
|
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)) {
|
|
<pi-action-button
|
|
size="small"
|
|
color="primary"
|
|
text="Recuperar"
|
|
(execute)="facade.setTrashed(false)"
|
|
/>
|
|
}
|
|
</div>
|
|
}
|
|
<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>
|
|
}
|
|
@if (issue.blockedBy.length) {
|
|
<ts-blocked [blockers]="issue.blockedBy" />
|
|
}
|
|
</div>
|
|
<div class="ts-issue__title-row">
|
|
<h1>{{ issue.title }}</h1>
|
|
<div class="ts-issue__actions">
|
|
@if (facade.me()) {
|
|
<pi-icon-button
|
|
icon="arrow-up"
|
|
color="grey"
|
|
appearance="outline"
|
|
[text]="
|
|
(issue.voted ? 'Votado · ' : 'Votar · ') + issue.votes
|
|
"
|
|
[attr.aria-pressed]="issue.voted"
|
|
[attr.title]="
|
|
issue.voted
|
|
? 'Quitar mi voto'
|
|
: 'Votar: dice al equipo que lo quieres'
|
|
"
|
|
(execute)="facade.vote(!issue.voted)"
|
|
/>
|
|
} @else if (issue.votes) {
|
|
<span class="ts-votes ts-mono" title="Votos">
|
|
▲ {{ issue.votes }}
|
|
</span>
|
|
}
|
|
@if (project.myRole()) {
|
|
<pi-icon-button
|
|
[icon]="facade.watching() ? 'eye-off' : 'eye'"
|
|
color="grey"
|
|
appearance="outline"
|
|
[text]="
|
|
facade.watching()
|
|
? 'Siguiendo · ' + issue.watchers.length
|
|
: 'Seguir'
|
|
"
|
|
[attr.aria-pressed]="facade.watching()"
|
|
[attr.title]="
|
|
facade.watching()
|
|
? 'Dejar de seguir'
|
|
: 'Avisarme de sus cambios'
|
|
"
|
|
(execute)="facade.setWatching(!facade.watching())"
|
|
/>
|
|
}
|
|
@if (project.canContribute()) {
|
|
<pi-icon-button
|
|
icon="edit"
|
|
color="grey"
|
|
appearance="outline"
|
|
text="Editar"
|
|
title="Editar (tecla E)"
|
|
(execute)="openEdit()"
|
|
/>
|
|
<pi-icon-button
|
|
icon="copy"
|
|
color="grey"
|
|
appearance="outline"
|
|
text="Duplicar"
|
|
title="Crear otro ticket con lo mismo (sin estado, persona ni comentarios)"
|
|
(execute)="duplicate()"
|
|
/>
|
|
}
|
|
</div>
|
|
</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) {
|
|
<ts-markdown
|
|
[text]="issue.description"
|
|
[me]="facade.me()"
|
|
[loader]="loadImage"
|
|
/>
|
|
} @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' +
|
|
(facade.childProgress().storyPoints
|
|
? ' · ' +
|
|
facade.childProgress().finishedStoryPoints +
|
|
' de ' +
|
|
facade.childProgress().storyPoints +
|
|
' puntos'
|
|
: '')
|
|
"
|
|
[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-searchable-select-field
|
|
placeholder="Colgar de… (la épica o el ticket padre)"
|
|
searchPlaceholder="Busca por clave o título"
|
|
emptyLabel="Ningún ticket de este proyecto coincide"
|
|
[options]="parentOptions()"
|
|
[(value)]="parentKey"
|
|
/>
|
|
<pi-action-button
|
|
size="small"
|
|
color="grey"
|
|
appearance="outline"
|
|
text="Colgar"
|
|
[disabled]="!parentKey()"
|
|
(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-searchable-select-field
|
|
placeholder="Elige el ticket"
|
|
searchPlaceholder="Busca por clave o título"
|
|
emptyLabel="Ningún ticket coincide"
|
|
[options]="linkOptions()"
|
|
[(value)]="linkTarget"
|
|
/>
|
|
<pi-action-button
|
|
size="small"
|
|
color="primary"
|
|
text="Relacionar"
|
|
[disabled]="!linkTarget()"
|
|
(execute)="link()"
|
|
/>
|
|
</form>
|
|
}
|
|
</article>
|
|
|
|
@if (issue.devLinks.length) {
|
|
<article class="ts-block">
|
|
<h2 class="ts-section">
|
|
Desarrollo
|
|
<span class="ts-mono">{{ issue.devLinks.length }}</span>
|
|
</h2>
|
|
<ul class="ts-dev">
|
|
@for (link of devLinks(); track link.url) {
|
|
<li class="ts-dev__item">
|
|
<span class="ts-dev__kind">{{ devKind(link) }}</span>
|
|
<a
|
|
class="ts-dev__title"
|
|
[href]="link.url"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>{{ link.title }}</a
|
|
>
|
|
<code class="ts-dev__ref"
|
|
>{{ link.repository ? link.repository + ' · ' : ''
|
|
}}{{ link.ref }}</code
|
|
>
|
|
@if (link.state) {
|
|
<span class="ts-pill" [attr.data-state]="link.state">{{
|
|
devState(link)
|
|
}}</span>
|
|
}
|
|
<span class="ts-muted ts-dev__when"
|
|
>{{ link.author ? link.author + ' · ' : ''
|
|
}}{{ ago(link.at) }}</span
|
|
>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</article>
|
|
}
|
|
|
|
<article class="ts-block">
|
|
<h2 class="ts-section">
|
|
Comentarios
|
|
<span class="ts-mono">{{ commentCount() }}</span>
|
|
</h2>
|
|
<ng-template #commentView let-comment>
|
|
<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.deleted ? '—' : comment.author.name
|
|
}}</strong>
|
|
<span class="ts-comment__when"
|
|
>{{ ago(comment.createdAt)
|
|
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
|
>
|
|
<span class="ts-spacer"></span>
|
|
@if (
|
|
!comment.deleted && comment.author.id === facade.me()
|
|
) {
|
|
<pi-icon-button
|
|
icon="edit"
|
|
color="grey"
|
|
appearance="ghost"
|
|
aria-label="Editar"
|
|
(execute)="openEditComment(comment)"
|
|
/>
|
|
}
|
|
@if (
|
|
!comment.deleted &&
|
|
(comment.author.id === facade.me() || project.canManage())
|
|
) {
|
|
<pi-icon-button
|
|
icon="trash"
|
|
color="grey"
|
|
appearance="ghost"
|
|
aria-label="Eliminar"
|
|
(execute)="deleteComment(comment)"
|
|
/>
|
|
}
|
|
</div>
|
|
@if (comment.deleted) {
|
|
<p class="ts-muted ts-comment__gone">Comentario borrado</p>
|
|
} @else {
|
|
<ts-markdown
|
|
[text]="comment.body"
|
|
[me]="facade.me()"
|
|
[loader]="loadImage"
|
|
/>
|
|
}
|
|
@if (
|
|
!comment.deleted &&
|
|
(comment.reactions.length || project.myRole())
|
|
) {
|
|
<ts-reactions
|
|
[reactions]="comment.reactions"
|
|
[me]="facade.me()"
|
|
[canReact]="!!project.myRole()"
|
|
(react)="
|
|
facade.react(comment.id, $event.emoji, $event.on)
|
|
"
|
|
/>
|
|
}
|
|
@if (!comment.deleted && 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>
|
|
</ng-template>
|
|
@for (thread of threads(); track thread.comment.id) {
|
|
<ng-container
|
|
*ngTemplateOutlet="
|
|
commentView;
|
|
context: { $implicit: thread.comment }
|
|
"
|
|
/>
|
|
<div class="ts-replies">
|
|
@for (reply of thread.replies; track reply.id) {
|
|
<ng-container
|
|
*ngTemplateOutlet="
|
|
commentView;
|
|
context: { $implicit: reply }
|
|
"
|
|
/>
|
|
}
|
|
@if (replyingTo() === thread.comment.id) {
|
|
<form
|
|
class="ts-form ts-reply"
|
|
(submit)="$event.preventDefault(); reply(thread.comment)"
|
|
novalidate
|
|
>
|
|
<ts-rich-editor
|
|
[placeholder]="
|
|
'Responde a ' + thread.comment.author.name + '…'
|
|
"
|
|
[(value)]="replyBody"
|
|
[members]="mentionable()"
|
|
[me]="facade.me()"
|
|
[upload]="pasteImage"
|
|
[loader]="loadImage"
|
|
(submitted)="reply(thread.comment)"
|
|
/>
|
|
<div class="ts-reply__actions">
|
|
<pi-action-button
|
|
color="primary"
|
|
text="Responder"
|
|
[disabled]="!replyBody().trim()"
|
|
(execute)="reply(thread.comment)"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="ts-plain-link"
|
|
(click)="replyingTo.set(null)"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
} @else if (
|
|
project.canContribute() && !thread.comment.deleted
|
|
) {
|
|
<button
|
|
type="button"
|
|
class="ts-plain-link ts-reply__open"
|
|
(click)="startReply(thread.comment)"
|
|
>
|
|
Responder{{
|
|
thread.replies.length
|
|
? ' (' + thread.replies.length + ')'
|
|
: ''
|
|
}}
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
@if (project.canContribute()) {
|
|
<form
|
|
class="ts-form"
|
|
(submit)="$event.preventDefault(); addComment()"
|
|
novalidate
|
|
>
|
|
<ts-rich-editor
|
|
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
|
|
[(value)]="commentBody"
|
|
[members]="mentionable()"
|
|
[me]="facade.me()"
|
|
[upload]="pasteImage"
|
|
[loader]="loadImage"
|
|
(submitted)="addComment()"
|
|
/>
|
|
<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]="pointsOptions()"
|
|
[disabled]="!project.canContribute()"
|
|
[value]="issue.storyPoints === null ? '' : '' + issue.storyPoints"
|
|
(valueChange)="update({ storyPoints: $event ? +$event : null })"
|
|
>
|
|
<pi-label>Estimación</pi-label>
|
|
</pi-select-field>
|
|
<pi-date-field
|
|
[disabled]="!project.canContribute()"
|
|
[value]="dateOf(issue.startDate)"
|
|
(valueChange)="setDate('startDate', $event)"
|
|
>
|
|
<pi-label>Empieza</pi-label>
|
|
</pi-date-field>
|
|
<pi-date-field
|
|
[disabled]="!project.canContribute()"
|
|
[value]="dateOf(issue.dueDate)"
|
|
(valueChange)="setDate('dueDate', $event)"
|
|
>
|
|
<pi-label>Fecha límite</pi-label>
|
|
</pi-date-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>
|
|
|
|
@if (!issue.trashedAt && canTrash(issue)) {
|
|
<pi-action-button
|
|
size="small"
|
|
color="grey"
|
|
appearance="ghost"
|
|
text="Mover a la papelera"
|
|
(execute)="trash()"
|
|
/>
|
|
}
|
|
<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>
|
|
<ts-rich-editor
|
|
label="Descripción"
|
|
[formField]="edit.description"
|
|
[members]="mentionable()"
|
|
[me]="facade.me()"
|
|
[upload]="pasteImage"
|
|
[loader]="loadImage"
|
|
(submitted)="saveEdit()"
|
|
/>
|
|
<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
|
|
>
|
|
<ts-rich-editor
|
|
[formField]="commentEdit.body"
|
|
[members]="mentionable()"
|
|
[me]="facade.me()"
|
|
[upload]="pasteImage"
|
|
[loader]="loadImage"
|
|
(submitted)="saveComment()"
|
|
/>
|
|
<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 router = inject(Router);
|
|
private readonly route = inject(ActivatedRoute);
|
|
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);
|
|
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<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<string | null>(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<string | null>(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<string, Comment[]>();
|
|
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<File[]>([]);
|
|
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<string | null>(null);
|
|
protected readonly parentKey = signal<string | null>(null);
|
|
/** 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. */
|
|
/** 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();
|
|
}
|
|
}
|