Since the swimlanes, each column's list is stamped from a template declared outside the cdkDropListGroup, and a template only sees the drop groups around where it is declared: every column was on its own and no card could leave its column. The lists of each row are now connected by id, with and without swimlanes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
914 lines
29 KiB
TypeScript
914 lines
29 KiB
TypeScript
import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
|
|
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
effect,
|
|
HostListener,
|
|
inject,
|
|
input,
|
|
signal,
|
|
TemplateRef,
|
|
untracked,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { NgTemplateOutlet } from '@angular/common';
|
|
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
|
import {
|
|
PiActionButtonComponent,
|
|
PiLabelDirective,
|
|
PiSegmentedControlComponent,
|
|
PiSelectFieldComponent,
|
|
PiSkeletonComponent,
|
|
PiSlideToggleComponent,
|
|
PiTextareaComponent,
|
|
PiTextFieldComponent,
|
|
type PiSelectFieldOption,
|
|
type SegmentedControlOption,
|
|
} from '@pi/ui';
|
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
|
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
|
import {
|
|
BoardFacade,
|
|
ProjectFacade,
|
|
provideBoardFacade,
|
|
} from '@task-stream/web/tracker/application';
|
|
import {
|
|
BoardColumn,
|
|
BoardFilter,
|
|
boardFilterOf,
|
|
IssuePriority,
|
|
IssueSummary,
|
|
isTyping,
|
|
overWip,
|
|
POINT_SCALE,
|
|
ReleaseStatus,
|
|
SavedFilter,
|
|
sumPoints,
|
|
SwimlaneMode,
|
|
swimlanes,
|
|
Workflow,
|
|
} from '@task-stream/web/tracker/domain';
|
|
import { IssueCardComponent } from '../ui/issue-card';
|
|
import {
|
|
options,
|
|
pointOptions,
|
|
PRIORITY,
|
|
PRIORITY_LEVEL,
|
|
statusLook,
|
|
typeLook,
|
|
} from '../ui/copy';
|
|
|
|
const ALL = '';
|
|
const LANE_KEY = 'ts.board.lanes';
|
|
|
|
/** The grouping last chosen on this browser (a convenience, not a setting). */
|
|
function storedLaneMode(): SwimlaneMode {
|
|
try {
|
|
const stored = localStorage.getItem(LANE_KEY);
|
|
return (['assignee', 'epic', 'priority', 'type'] as const).includes(
|
|
stored as never,
|
|
)
|
|
? (stored as SwimlaneMode)
|
|
: 'none';
|
|
} catch {
|
|
return 'none';
|
|
}
|
|
}
|
|
|
|
const statusLookType = (workflow: Workflow, id: string) =>
|
|
typeLook(workflow, id).label;
|
|
/** "Sin asignar" in the person filter. */
|
|
const NOBODY = '__nobody__';
|
|
|
|
@Component({
|
|
selector: 'ts-board-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
CdkDropList,
|
|
CdkDrag,
|
|
FormField,
|
|
IssueCardComponent,
|
|
NgTemplateOutlet,
|
|
PiActionButtonComponent,
|
|
PiSelectFieldComponent,
|
|
PiSegmentedControlComponent,
|
|
PiSkeletonComponent,
|
|
PiSlideToggleComponent,
|
|
PiTextFieldComponent,
|
|
PiTextareaComponent,
|
|
PiLabelDirective,
|
|
],
|
|
providers: [provideBoardFacade()],
|
|
styleUrl: './board.page.scss',
|
|
template: `
|
|
@if (project.myRole()) {
|
|
<div class="ts-views" role="group" aria-label="Vistas guardadas">
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Vistas guardadas"
|
|
[options]="viewOptions()"
|
|
[value]="board.activeView()?.id ?? ''"
|
|
(valueChange)="applyView($event)"
|
|
/>
|
|
@if (!board.activeView() && isFiltered()) {
|
|
<pi-action-button
|
|
size="small"
|
|
color="grey"
|
|
appearance="outline"
|
|
text="Guardar vista"
|
|
(execute)="openSaveView()"
|
|
/>
|
|
}
|
|
@if (board.activeView(); as view) {
|
|
@if (canDeleteView(view)) {
|
|
<pi-action-button
|
|
size="small"
|
|
color="grey"
|
|
appearance="ghost"
|
|
[text]="'Borrar «' + view.name + '»'"
|
|
(execute)="board.deleteView(view.id)"
|
|
/>
|
|
}
|
|
}
|
|
@if (isFiltered()) {
|
|
<pi-action-button
|
|
size="small"
|
|
color="grey"
|
|
appearance="ghost"
|
|
text="Quitar filtros"
|
|
(execute)="clearFilters()"
|
|
/>
|
|
}
|
|
</div>
|
|
}
|
|
<div class="ts-board-toolbar">
|
|
<pi-text-field
|
|
class="ts-filter"
|
|
placeholder="Buscar en el tablero…"
|
|
[(value)]="searchInput"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Persona"
|
|
[options]="assigneeFilterOptions()"
|
|
[(value)]="assigneeFilter"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Tipo"
|
|
[options]="typeFilterOptions()"
|
|
[(value)]="typeFilter"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Prioridad"
|
|
[options]="priorityFilterOptions"
|
|
[(value)]="priorityFilter"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Agrupar por"
|
|
[options]="laneOptions"
|
|
[value]="laneMode()"
|
|
(valueChange)="setLaneMode($event)"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Sprint"
|
|
[options]="sprintFilterOptions()"
|
|
[(value)]="sprintFilter"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Release"
|
|
[options]="releaseOptions()"
|
|
[(value)]="releaseFilter"
|
|
/>
|
|
<pi-select-field
|
|
class="ts-filter"
|
|
placeholder="Área"
|
|
[options]="areaFilterOptions()"
|
|
[(value)]="area"
|
|
/>
|
|
<span class="ts-board-total"
|
|
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
|
|
>
|
|
<span class="ts-spacer"></span>
|
|
@if (project.canContribute()) {
|
|
<pi-action-button
|
|
color="primary"
|
|
text="Nuevo ticket"
|
|
(execute)="openCreate()"
|
|
/>
|
|
}
|
|
</div>
|
|
|
|
@if (board.loading() && !board.columns().length) {
|
|
<div class="ts-board">
|
|
@for (i of [1, 2, 3, 4]; track i) {
|
|
<pi-skeleton shape="block" height="20rem" />
|
|
}
|
|
</div>
|
|
} @else {
|
|
@if (laneMode() === 'none') {
|
|
<div class="ts-board">
|
|
@for (column of board.columns(); track column.status) {
|
|
<section
|
|
class="ts-column"
|
|
[class.is-over]="isOver(column)"
|
|
[attr.data-status]="column.status"
|
|
[style.--tone]="statusTone(column.status)"
|
|
>
|
|
<ng-container
|
|
[ngTemplateOutlet]="columnHead"
|
|
[ngTemplateOutletContext]="{ column }"
|
|
/>
|
|
<ng-container
|
|
[ngTemplateOutlet]="columnList"
|
|
[ngTemplateOutletContext]="{ column, lane: '' }"
|
|
/>
|
|
</section>
|
|
}
|
|
</div>
|
|
} @else {
|
|
<!-- One scroll for heads and rows, so the columns stay aligned. -->
|
|
<div class="ts-lanes">
|
|
<div class="ts-board ts-board--heads">
|
|
@for (column of board.columns(); track column.status) {
|
|
<section
|
|
class="ts-column ts-column--head"
|
|
[class.is-over]="isOver(column)"
|
|
[style.--tone]="statusTone(column.status)"
|
|
>
|
|
<ng-container
|
|
[ngTemplateOutlet]="columnHead"
|
|
[ngTemplateOutletContext]="{ column }"
|
|
/>
|
|
</section>
|
|
}
|
|
</div>
|
|
@for (lane of lanes(); track lane.key) {
|
|
<section class="ts-lane" [attr.aria-label]="laneLabel(lane.key)">
|
|
<h3 class="ts-lane__title">
|
|
{{ laneLabel(lane.key) }}
|
|
<span class="ts-mono ts-muted">{{ lane.count }}</span>
|
|
</h3>
|
|
<!-- Cards move between columns of their own row only. -->
|
|
<div class="ts-board">
|
|
@for (column of lane.columns; track column.status) {
|
|
<section
|
|
class="ts-column ts-column--lane"
|
|
[attr.data-status]="column.status"
|
|
[style.--tone]="statusTone(column.status)"
|
|
>
|
|
<ng-container
|
|
[ngTemplateOutlet]="columnList"
|
|
[ngTemplateOutletContext]="{ column, lane: lane.key }"
|
|
/>
|
|
</section>
|
|
}
|
|
</div>
|
|
</section>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
<ng-template #columnHead let-column="column">
|
|
<header class="ts-column__header">
|
|
<span class="ts-column__name">{{ statusLabel(column.status) }}</span>
|
|
<span
|
|
class="ts-column__count"
|
|
[attr.title]="
|
|
wipOf(column) ? 'Límite WIP: ' + wipOf(column) + ' tickets' : null
|
|
"
|
|
>{{ column.total }}{{ wipOf(column) ? ' / ' + wipOf(column) : '' }}
|
|
@if (pointsOf(column); as points) {
|
|
<span class="ts-column__points" title="Puntos estimados"
|
|
>· {{ points }} pts</span
|
|
>
|
|
}
|
|
</span>
|
|
</header>
|
|
</ng-template>
|
|
|
|
<ng-template #columnList let-column="column" let-lane="lane">
|
|
<div
|
|
class="ts-column__list"
|
|
cdkDropList
|
|
[id]="lane + '|' + column.status"
|
|
[cdkDropListConnectedTo]="rowLists()[lane] ?? []"
|
|
[cdkDropListData]="column"
|
|
[cdkDropListDisabled]="!project.canContribute()"
|
|
[cdkDropListEnterPredicate]="canEnter"
|
|
(cdkDropListDropped)="drop($event)"
|
|
>
|
|
@for (issue of column.issues; track issue.id) {
|
|
<ts-issue-card
|
|
cdkDrag
|
|
[cdkDragData]="issue"
|
|
[issue]="issue"
|
|
[workflow]="project.workflow()"
|
|
/>
|
|
} @empty {
|
|
<p class="ts-column__empty">Nada por aquí</p>
|
|
}
|
|
</div>
|
|
</ng-template>
|
|
|
|
<ng-template #createForm>
|
|
<form
|
|
class="ts-form"
|
|
(submit)="$event.preventDefault(); create()"
|
|
novalidate
|
|
>
|
|
@if (typeOptions().length <= 5) {
|
|
<pi-segmented-control
|
|
ariaLabel="Tipo"
|
|
[options]="typeOptions()"
|
|
[(value)]="newType"
|
|
/>
|
|
} @else {
|
|
<pi-select-field [options]="typeOptions()" [(value)]="newType">
|
|
<pi-label>Tipo</pi-label>
|
|
</pi-select-field>
|
|
}
|
|
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
|
|
<pi-label [isRequired]="true">Título</pi-label>
|
|
</pi-text-field>
|
|
<pi-textarea
|
|
[formField]="f.description"
|
|
[placeholder]="
|
|
template()?.description ??
|
|
'Pasos para reproducir, criterios de aceptación…'
|
|
"
|
|
>
|
|
<pi-label>Descripción</pi-label>
|
|
</pi-textarea>
|
|
@if (template(); as t) {
|
|
<p class="ts-muted ts-template-hint">
|
|
Plantilla de {{ typeLabel() }}:
|
|
@if (t.description) {
|
|
si dejas la descripción vacía se usará la de la plantilla
|
|
}
|
|
@if (t.description && t.checklist.length) {
|
|
·
|
|
}
|
|
@if (t.checklist.length) {
|
|
se añadirá su checklist ({{ t.checklist.length }} pasos)
|
|
}
|
|
</p>
|
|
}
|
|
@if (project.workflow().isDesignType(newType())) {
|
|
<pi-text-field
|
|
[formField]="f.figmaUrl"
|
|
placeholder="https://www.figma.com/design/…"
|
|
>
|
|
<pi-label>Frame de Figma</pi-label>
|
|
</pi-text-field>
|
|
}
|
|
<div class="ts-form__row">
|
|
<pi-select-field [formField]="f.priority" [options]="priorityOptions">
|
|
<pi-label>Prioridad</pi-label>
|
|
</pi-select-field>
|
|
<pi-select-field [formField]="f.area" [options]="areaOptions()">
|
|
<pi-label>Área</pi-label>
|
|
</pi-select-field>
|
|
<pi-select-field
|
|
[formField]="f.storyPoints"
|
|
[options]="pointsOptions"
|
|
>
|
|
<pi-label>Estimación</pi-label>
|
|
</pi-select-field>
|
|
</div>
|
|
<div class="ts-form__row">
|
|
<pi-select-field
|
|
[formField]="f.assigneeId"
|
|
[options]="assigneeOptions()"
|
|
>
|
|
<pi-label>Asignar a</pi-label>
|
|
</pi-select-field>
|
|
<pi-select-field
|
|
[formField]="f.releaseId"
|
|
[options]="releaseOptions()"
|
|
>
|
|
<pi-label>Release</pi-label>
|
|
</pi-select-field>
|
|
</div>
|
|
<pi-action-button
|
|
size="w100"
|
|
color="primary"
|
|
[text]="board.creating() ? 'Creando…' : 'Crear ticket'"
|
|
[disabled]="f().invalid() || board.creating()"
|
|
(execute)="create()"
|
|
/>
|
|
</form>
|
|
</ng-template>
|
|
|
|
<ng-template #viewForm>
|
|
<form
|
|
class="ts-form"
|
|
(submit)="$event.preventDefault(); saveView()"
|
|
novalidate
|
|
>
|
|
<p class="ts-muted">
|
|
Guarda los filtros de ahora para volver a ellos con un clic.
|
|
</p>
|
|
<pi-text-field
|
|
placeholder="Bugs críticos de frontend"
|
|
[(value)]="viewName"
|
|
>
|
|
<pi-label [isRequired]="true">Nombre</pi-label>
|
|
</pi-text-field>
|
|
@if (project.canContribute()) {
|
|
<pi-slide-toggle
|
|
label="Compartir con todo el proyecto"
|
|
[(checked)]="viewShared"
|
|
/>
|
|
}
|
|
<pi-action-button
|
|
size="w100"
|
|
color="primary"
|
|
text="Guardar vista"
|
|
[disabled]="!viewName().trim()"
|
|
(execute)="saveView()"
|
|
/>
|
|
</form>
|
|
</ng-template>
|
|
|
|
<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="Sigue fallando en Safari 17…"
|
|
>
|
|
<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>
|
|
`,
|
|
})
|
|
export class BoardPage {
|
|
/** `?release=` (from the releases page), bound by the router. */
|
|
readonly release = input<string | undefined>();
|
|
/** `?assignee=` (from the tracking page's team list), bound by the router. */
|
|
readonly assignee = input<string | undefined>();
|
|
/** `?sprint=` (from the sprints page), bound by the router. */
|
|
readonly sprint = input<string | undefined>();
|
|
|
|
protected readonly board = inject(BoardFacade);
|
|
protected readonly project = inject(ProjectFacade);
|
|
private readonly drawer = inject(DrawerService);
|
|
private readonly toast = inject(ToastService);
|
|
private readonly auth = inject(AuthFacade);
|
|
private readonly me = computed(() => this.auth.user()?.id ?? null);
|
|
private readonly createForm =
|
|
viewChild.required<TemplateRef<unknown>>('createForm');
|
|
private readonly reasonForm =
|
|
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
|
private readonly viewForm =
|
|
viewChild.required<TemplateRef<unknown>>('viewForm');
|
|
|
|
/* Filters */
|
|
protected readonly assigneeFilter = signal<string | null>(ALL);
|
|
protected readonly releaseFilter = signal<string | null>(ALL);
|
|
protected readonly area = signal<string | null>(ALL);
|
|
protected readonly typeFilter = signal<string | null>(ALL);
|
|
protected readonly sprintFilter = signal<string | null>(ALL);
|
|
protected readonly sprintFilterOptions = computed<
|
|
PiSelectFieldOption<string>[]
|
|
>(() => [
|
|
{ value: ALL, label: 'Todos los sprints' },
|
|
...this.project.openSprints().map((s) => ({
|
|
value: s.id,
|
|
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
|
|
})),
|
|
]);
|
|
protected readonly priorityFilter = signal<string | null>(ALL);
|
|
/** What is typed; `search` follows it once typing pauses. */
|
|
protected readonly searchInput = signal('');
|
|
private readonly search = signal('');
|
|
/** Labels only come from a saved view (no picker on the toolbar). */
|
|
private readonly labels = signal<string[]>([]);
|
|
protected readonly isFiltered = computed(
|
|
() =>
|
|
!!(
|
|
this.assigneeFilter() ||
|
|
this.releaseFilter() ||
|
|
this.sprintFilter() ||
|
|
this.area() ||
|
|
this.typeFilter() ||
|
|
this.priorityFilter() ||
|
|
this.search() ||
|
|
this.labels().length
|
|
),
|
|
);
|
|
|
|
/* Swimlanes and WIP */
|
|
protected readonly laneOptions: PiSelectFieldOption<string>[] = [
|
|
{ value: 'none', label: 'Sin agrupar' },
|
|
{ value: 'assignee', label: 'Agrupar por persona' },
|
|
{ value: 'epic', label: 'Agrupar por épica' },
|
|
{ value: 'priority', label: 'Agrupar por prioridad' },
|
|
{ value: 'type', label: 'Agrupar por tipo' },
|
|
];
|
|
protected readonly laneMode = signal<SwimlaneMode>(storedLaneMode());
|
|
protected readonly lanes = computed(() =>
|
|
swimlanes(this.board.columns(), this.laneMode(), (key) =>
|
|
this.laneMode() === 'priority'
|
|
? -PRIORITY_LEVEL[key as IssuePriority]
|
|
: 0,
|
|
),
|
|
);
|
|
protected laneLabel(key: string): string {
|
|
switch (this.laneMode()) {
|
|
case 'assignee':
|
|
return key
|
|
? (this.project.members().find((m) => m.user.id === key)?.user.name ??
|
|
'Antiguo miembro')
|
|
: 'Sin asignar';
|
|
case 'epic': {
|
|
if (!key) return 'Sin épica';
|
|
const card = this.board
|
|
.columns()
|
|
.flatMap((c) => c.issues)
|
|
.find((i) => i.parent?.id === key);
|
|
return card?.parent ? `${card.parent.key} · ${card.parent.title}` : key;
|
|
}
|
|
case 'priority':
|
|
return PRIORITY[key as IssuePriority]?.label ?? key;
|
|
case 'type':
|
|
return statusLookType(this.project.workflow(), key);
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
protected setLaneMode(mode: string | null): void {
|
|
const next = (mode || 'none') as SwimlaneMode;
|
|
this.laneMode.set(next);
|
|
try {
|
|
localStorage.setItem(LANE_KEY, next);
|
|
} catch {
|
|
// Private mode: the choice lasts until the page closes.
|
|
}
|
|
}
|
|
protected wipOf = (column: BoardColumn) =>
|
|
this.project.workflow().wipLimit(column.status);
|
|
protected isOver = (column: BoardColumn) =>
|
|
overWip(column.total, this.wipOf(column));
|
|
|
|
/* Saved views */
|
|
protected readonly viewName = signal('');
|
|
protected readonly viewShared = signal(false);
|
|
protected readonly viewOptions = computed<PiSelectFieldOption<string>[]>(
|
|
() => [
|
|
{ value: ALL, label: 'Sin vista guardada' },
|
|
...this.board.views().map((v) => ({
|
|
value: v.id,
|
|
label: v.shared ? `${v.name} · compartida` : v.name,
|
|
})),
|
|
],
|
|
);
|
|
protected readonly assigneeFilterOptions = computed<
|
|
PiSelectFieldOption<string>[]
|
|
>(() => [
|
|
{ value: ALL, label: 'Todas las personas' },
|
|
{ value: NOBODY, label: 'Sin asignar' },
|
|
...this.project
|
|
.members()
|
|
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
|
]);
|
|
protected readonly typeFilterOptions = computed<
|
|
PiSelectFieldOption<string>[]
|
|
>(() => [
|
|
{ value: ALL, label: 'Todos los tipos' },
|
|
...this.project
|
|
.workflow()
|
|
.settings.issueTypes.map((t) => ({ value: t.id, label: t.name })),
|
|
]);
|
|
protected readonly priorityFilterOptions: PiSelectFieldOption<string>[] = [
|
|
{ value: ALL, label: 'Todas las prioridades' },
|
|
...options(PRIORITY),
|
|
];
|
|
|
|
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
|
() => [
|
|
{ value: ALL, label: 'Todas las personas' },
|
|
...this.project
|
|
.members()
|
|
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
|
],
|
|
);
|
|
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
|
|
() => [
|
|
{ value: ALL, label: 'Todas las releases' },
|
|
...this.project
|
|
.releases()
|
|
.filter((r) => r.status === ReleaseStatus.PLANNED)
|
|
.map((r) => ({ value: r.id, label: r.name })),
|
|
],
|
|
);
|
|
/** Archived areas still filter (old tickets keep them). */
|
|
protected readonly areaFilterOptions = computed<
|
|
PiSelectFieldOption<string>[]
|
|
>(() => [
|
|
{ value: ALL, label: 'Todas las áreas' },
|
|
...this.project
|
|
.workflow()
|
|
.settings.areas.map((a) => ({ value: a.id, label: a.name })),
|
|
]);
|
|
protected readonly areaOptions = computed<PiSelectFieldOption<string>[]>(
|
|
() => [
|
|
{ value: ALL, label: 'Sin área' },
|
|
...this.project
|
|
.workflow()
|
|
.activeAreas()
|
|
.map((a) => ({ value: a.id, label: a.name })),
|
|
],
|
|
);
|
|
protected readonly priorityOptions = options(PRIORITY);
|
|
protected readonly pointsOptions = pointOptions(POINT_SCALE);
|
|
/** Estimated points of the cards loaded in a column. */
|
|
protected pointsOf = (column: BoardColumn) => sumPoints(column.issues);
|
|
protected readonly typeOptions = computed<SegmentedControlOption<string>[]>(
|
|
() =>
|
|
this.project
|
|
.workflow()
|
|
.activeTypes()
|
|
.map((t) => ({ value: t.id, label: t.name })),
|
|
);
|
|
|
|
/* New ticket */
|
|
protected readonly newType = signal<string>('');
|
|
/** The template the new ticket will get (the API applies it). */
|
|
protected readonly template = computed(
|
|
() =>
|
|
this.project
|
|
.workflow()
|
|
.settings.issueTypes.find((t) => t.id === this.newType())?.template ??
|
|
null,
|
|
);
|
|
protected readonly typeLabel = computed(
|
|
() => typeLook(this.project.workflow(), this.newType()).label,
|
|
);
|
|
private readonly model = signal({
|
|
title: '',
|
|
description: '',
|
|
figmaUrl: '',
|
|
priority: IssuePriority.MEDIUM as string,
|
|
area: ALL as string,
|
|
storyPoints: '' as string,
|
|
assigneeId: ALL as string,
|
|
releaseId: ALL as string,
|
|
});
|
|
protected readonly f = form(this.model, (p) => {
|
|
required(p.title);
|
|
maxLength(p.title, 255);
|
|
});
|
|
protected readonly titlePlaceholder = computed(() =>
|
|
this.project.workflow().type(this.newType()).icon === 'bug'
|
|
? 'El botón de pagar no responde en Safari'
|
|
: 'Qué hay que hacer',
|
|
);
|
|
|
|
/* Move needing a reason */
|
|
private pendingMove: { issueId: string; to: string } | null = null;
|
|
private readonly reasonModel = signal({ text: '' });
|
|
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
const release = this.release();
|
|
if (release) untracked(() => this.releaseFilter.set(release));
|
|
});
|
|
effect(() => {
|
|
const assignee = this.assignee();
|
|
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
|
|
});
|
|
effect(() => {
|
|
const sprint = this.sprint();
|
|
if (sprint) untracked(() => this.sprintFilter.set(sprint));
|
|
});
|
|
effect((onCleanup) => {
|
|
const text = this.searchInput().trim();
|
|
const timer = setTimeout(() => this.search.set(text), 300);
|
|
onCleanup(() => clearTimeout(timer));
|
|
});
|
|
// (Re)load whenever the project or a filter changes.
|
|
effect(() => {
|
|
const projectId = this.project.project()?.id;
|
|
const assignee = this.assigneeFilter();
|
|
const type = this.typeFilter();
|
|
const priority = this.priorityFilter() as IssuePriority | null;
|
|
const filter: Omit<BoardFilter, 'projectId'> = {
|
|
unassigned: assignee === NOBODY || undefined,
|
|
assigneeId: (assignee !== NOBODY && assignee) || undefined,
|
|
releaseId: this.releaseFilter() || undefined,
|
|
area: this.area() || undefined,
|
|
types: type ? [type] : undefined,
|
|
priorities: priority ? [priority] : undefined,
|
|
labels: this.labels().length ? this.labels() : undefined,
|
|
search: this.search() || undefined,
|
|
sprintId: this.sprintFilter() || undefined,
|
|
};
|
|
if (!projectId) return;
|
|
untracked(() =>
|
|
this.board.show({
|
|
projectId,
|
|
...(Object.fromEntries(
|
|
Object.entries(filter).filter(([, v]) => v !== undefined),
|
|
) as Omit<BoardFilter, 'projectId'>),
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
|
|
protected statusLabel = (status: string) =>
|
|
statusLook(this.project.workflow(), status).label;
|
|
protected statusTone = (status: string) =>
|
|
statusLook(this.project.workflow(), status).tone;
|
|
|
|
/**
|
|
* The ids of each row's column lists, by row ('' without swimlanes): a card
|
|
* moves between the columns of its own row. Connected by id because the
|
|
* lists are stamped from a template, and a template only sees the drop
|
|
* groups around where it is declared, not where it is placed.
|
|
*/
|
|
protected readonly rowLists = computed(() => {
|
|
const statuses = this.board.columns().map((c) => c.status);
|
|
const rows =
|
|
this.laneMode() === 'none' ? [''] : this.lanes().map((l) => l.key);
|
|
return Object.fromEntries(
|
|
rows.map((row) => [row, statuses.map((status) => `${row}|${status}`)]),
|
|
);
|
|
});
|
|
|
|
/** Columns a dragged ticket can enter: its allowed transitions. */
|
|
protected readonly canEnter = (
|
|
drag: CdkDrag<IssueSummary>,
|
|
drop: CdkDropList<BoardColumn>,
|
|
) =>
|
|
this.project
|
|
.workflow()
|
|
.allowedFrom(drag.data.status)
|
|
.includes(drop.data.status);
|
|
|
|
protected drop(
|
|
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
|
|
): void {
|
|
if (event.previousContainer === event.container) return;
|
|
const to = event.container.data.status;
|
|
const issueId = event.item.data.id;
|
|
const target = this.board.columns().find((c) => c.status === to);
|
|
const limit = this.project.workflow().wipLimit(to);
|
|
if (target && overWip(target.total + 1, limit))
|
|
this.toast.info(
|
|
`«${this.statusLabel(to)}» pasa de su límite WIP (${target.total + 1}/${limit}): mejor terminar algo antes de empezar más`,
|
|
);
|
|
if (this.project.workflow().requiresReason(to)) {
|
|
this.pendingMove = { issueId, to };
|
|
this.reasonModel.set({ text: '' });
|
|
this.drawer.open(`Mover a «${this.statusLabel(to)}»`, this.reasonForm());
|
|
return;
|
|
}
|
|
this.board.moveIssue(issueId, to);
|
|
}
|
|
|
|
protected confirmMove(): void {
|
|
if (!this.pendingMove || this.reason().invalid()) return;
|
|
const { issueId, to } = this.pendingMove;
|
|
this.board.moveIssue(issueId, to, this.reasonModel().text);
|
|
this.pendingMove = null;
|
|
this.drawer.closeAll();
|
|
}
|
|
|
|
/** Shows what a saved view shows (the toolbar picks the first of each list). */
|
|
protected applyView(id: string | null): void {
|
|
const view = this.board.views().find((v) => v.id === id);
|
|
if (!view) {
|
|
if (!id) this.clearFilters();
|
|
return;
|
|
}
|
|
const projectId = this.project.project()?.id ?? '';
|
|
const filter = boardFilterOf(projectId, view.criteria);
|
|
this.assigneeFilter.set(
|
|
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
|
|
);
|
|
this.releaseFilter.set(filter.releaseId ?? ALL);
|
|
this.sprintFilter.set(ALL);
|
|
this.area.set(filter.area ?? ALL);
|
|
this.typeFilter.set(filter.types?.[0] ?? ALL);
|
|
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
|
|
this.labels.set(filter.labels ?? []);
|
|
this.searchInput.set(filter.search ?? '');
|
|
this.search.set(filter.search ?? '');
|
|
}
|
|
|
|
protected clearFilters(): void {
|
|
for (const filter of [
|
|
this.assigneeFilter,
|
|
this.releaseFilter,
|
|
this.sprintFilter,
|
|
this.area,
|
|
this.typeFilter,
|
|
this.priorityFilter,
|
|
])
|
|
filter.set(ALL);
|
|
this.labels.set([]);
|
|
this.searchInput.set('');
|
|
this.search.set('');
|
|
}
|
|
|
|
protected openSaveView(): void {
|
|
this.viewName.set('');
|
|
this.viewShared.set(false);
|
|
this.drawer.open('Guardar vista', this.viewForm());
|
|
}
|
|
|
|
protected saveView(): void {
|
|
if (!this.viewName().trim()) return;
|
|
this.board.saveView(
|
|
this.viewName(),
|
|
this.viewShared() && this.project.canContribute(),
|
|
);
|
|
this.drawer.closeAll();
|
|
}
|
|
|
|
protected canDeleteView(view: SavedFilter): boolean {
|
|
return (
|
|
view.owner.id === this.me() || (view.shared && this.project.canManage())
|
|
);
|
|
}
|
|
|
|
/** `c`: new ticket (not while typing, nor with a drawer open). */
|
|
@HostListener('document:keydown', ['$event'])
|
|
protected onKey(event: KeyboardEvent): void {
|
|
if (
|
|
event.key !== 'c' ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.altKey ||
|
|
isTyping(event.target) ||
|
|
!this.project.canContribute() ||
|
|
document.querySelector('[role="dialog"]')
|
|
)
|
|
return;
|
|
event.preventDefault();
|
|
this.openCreate();
|
|
}
|
|
|
|
protected openCreate(): void {
|
|
const types = this.typeOptions();
|
|
if (!types.some((t) => t.value === this.newType()))
|
|
this.newType.set(types[0]?.value ?? '');
|
|
this.model.set({
|
|
title: '',
|
|
description: '',
|
|
figmaUrl: '',
|
|
priority: IssuePriority.MEDIUM,
|
|
area: ALL,
|
|
storyPoints: '',
|
|
assigneeId: ALL,
|
|
releaseId: this.releaseFilter() || ALL,
|
|
});
|
|
this.drawer.open('Nuevo ticket', this.createForm(), '36rem');
|
|
}
|
|
|
|
protected create(): void {
|
|
const projectId = this.project.project()?.id;
|
|
if (!projectId || this.f().invalid()) return;
|
|
const v = this.model();
|
|
this.board.createIssue({
|
|
projectId,
|
|
type: this.newType(),
|
|
title: v.title,
|
|
description: v.description || null,
|
|
figmaUrl:
|
|
this.project.workflow().isDesignType(this.newType()) && v.figmaUrl
|
|
? v.figmaUrl
|
|
: null,
|
|
priority: v.priority as IssuePriority,
|
|
area: v.area || null,
|
|
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
|
|
assigneeId: v.assigneeId || null,
|
|
releaseId: v.releaseId || null,
|
|
});
|
|
this.drawer.closeAll();
|
|
}
|
|
}
|