first commit
This commit is contained in:
374
libs/web/tracker/presenters/src/lib/pages/board.page.ts
Normal file
374
libs/web/tracker/presenters/src/lib/pages/board.page.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import {
|
||||
CdkDrag,
|
||||
CdkDragDrop,
|
||||
CdkDropList,
|
||||
CdkDropListGroup,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiSegmentedControlComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
type SegmentedControlOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
BoardFacade,
|
||||
ProjectFacade,
|
||||
provideBoardFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
BoardColumn,
|
||||
IssueArea,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
IssueType,
|
||||
REASON_REQUIRED,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { IssueCardComponent } from '../ui/issue-card';
|
||||
import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy';
|
||||
|
||||
const ALL = '';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-board-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CdkDropListGroup,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
FormField,
|
||||
IssueCardComponent,
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
PiLabelDirective,
|
||||
],
|
||||
providers: [provideBoardFacade()],
|
||||
styleUrl: './board.page.scss',
|
||||
template: `
|
||||
<div class="ts-board-toolbar">
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Persona"
|
||||
[options]="assigneeOptions()"
|
||||
[(value)]="assignee"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Release"
|
||||
[options]="releaseOptions()"
|
||||
[(value)]="releaseFilter"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Área"
|
||||
[options]="areaOptions"
|
||||
[(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 {
|
||||
<div class="ts-board" cdkDropListGroup>
|
||||
@for (column of board.columns(); track column.status) {
|
||||
<section
|
||||
class="ts-column"
|
||||
[attr.data-status]="column.status"
|
||||
[style.--tone]="statusTone(column.status)"
|
||||
>
|
||||
<header class="ts-column__header">
|
||||
<span class="ts-column__name">{{
|
||||
statusLabel(column.status)
|
||||
}}</span>
|
||||
<span class="ts-column__count">{{ column.total }}</span>
|
||||
</header>
|
||||
<div
|
||||
class="ts-column__list"
|
||||
cdkDropList
|
||||
[id]="column.status"
|
||||
[cdkDropListData]="column"
|
||||
[cdkDropListDisabled]="!project.canContribute()"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
>
|
||||
@for (issue of column.issues; track issue.id) {
|
||||
<ts-issue-card cdkDrag [cdkDragData]="issue" [issue]="issue" />
|
||||
} @empty {
|
||||
<p class="ts-column__empty">Nada por aquí</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-segmented-control
|
||||
ariaLabel="Tipo"
|
||||
[options]="typeOptions"
|
||||
[(value)]="newType"
|
||||
/>
|
||||
<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="Pasos para reproducir, criterios de aceptación…"
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
@if (newType() === 'UI_CHANGE') {
|
||||
<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>
|
||||
</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 #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>();
|
||||
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
|
||||
/* Filters */
|
||||
protected readonly assignee = signal<string | null>(ALL);
|
||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||
protected readonly area = signal<string | null>(ALL);
|
||||
|
||||
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 })),
|
||||
],
|
||||
);
|
||||
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: ALL, label: 'Todas las áreas' },
|
||||
...options(AREA),
|
||||
];
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly typeOptions: SegmentedControlOption<IssueType>[] =
|
||||
options(TYPE);
|
||||
|
||||
/* New ticket */
|
||||
protected readonly newType = signal<IssueType>(IssueType.BUG);
|
||||
private readonly model = signal({
|
||||
title: '',
|
||||
description: '',
|
||||
figmaUrl: '',
|
||||
priority: IssuePriority.MEDIUM as string,
|
||||
area: ALL 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.newType() === IssueType.BUG
|
||||
? 'El botón de pagar no responde en Safari'
|
||||
: 'Qué hay que hacer',
|
||||
);
|
||||
|
||||
/* Move needing a reason */
|
||||
private pendingMove: { issueId: string; to: IssueStatus } | 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));
|
||||
});
|
||||
// (Re)load whenever the project or a filter changes.
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
const filter = {
|
||||
assigneeId: this.assignee() || undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
area: (this.area() || undefined) as IssueArea | undefined,
|
||||
};
|
||||
if (!projectId) return;
|
||||
untracked(() => this.board.show({ projectId, ...filter }));
|
||||
});
|
||||
}
|
||||
|
||||
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
|
||||
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
|
||||
|
||||
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;
|
||||
if (REASON_REQUIRED.includes(to)) {
|
||||
this.pendingMove = { issueId, to };
|
||||
this.reasonModel.set({ text: '' });
|
||||
this.drawer.open(`Mover a «${STATUS[to].label}»`, 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();
|
||||
}
|
||||
|
||||
protected openCreate(): void {
|
||||
this.model.set({
|
||||
title: '',
|
||||
description: '',
|
||||
figmaUrl: '',
|
||||
priority: IssuePriority.MEDIUM,
|
||||
area: ALL,
|
||||
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.newType() === IssueType.UI_CHANGE && v.figmaUrl
|
||||
? v.figmaUrl
|
||||
: null,
|
||||
priority: v.priority as IssuePriority,
|
||||
area: (v.area || null) as IssueArea | null,
|
||||
assigneeId: v.assigneeId || null,
|
||||
releaseId: v.releaseId || null,
|
||||
});
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user