The board now filters by text, type, priority and unassigned tickets as well. Any combination can be saved as a named view, private or shared with the project, and opened again with one click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
652 lines
20 KiB
TypeScript
652 lines
20 KiB
TypeScript
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,
|
|
PiSlideToggleComponent,
|
|
PiTextareaComponent,
|
|
PiTextFieldComponent,
|
|
type PiSelectFieldOption,
|
|
type SegmentedControlOption,
|
|
} from '@pi/ui';
|
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
|
import { DrawerService } from '@task-stream/web/shared/ui';
|
|
import {
|
|
BoardFacade,
|
|
ProjectFacade,
|
|
provideBoardFacade,
|
|
} from '@task-stream/web/tracker/application';
|
|
import {
|
|
BoardColumn,
|
|
BoardFilter,
|
|
boardFilterOf,
|
|
IssuePriority,
|
|
IssueSummary,
|
|
ReleaseStatus,
|
|
SavedFilter,
|
|
} from '@task-stream/web/tracker/domain';
|
|
import { IssueCardComponent } from '../ui/issue-card';
|
|
import { options, PRIORITY, statusLook } from '../ui/copy';
|
|
|
|
const ALL = '';
|
|
/** "Sin asignar" in the person filter. */
|
|
const NOBODY = '__nobody__';
|
|
|
|
@Component({
|
|
selector: 'ts-board-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [
|
|
CdkDropListGroup,
|
|
CdkDropList,
|
|
CdkDrag,
|
|
FormField,
|
|
IssueCardComponent,
|
|
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="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 {
|
|
<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()"
|
|
[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>
|
|
</section>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<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="Pasos para reproducir, criterios de aceptación…"
|
|
>
|
|
<pi-label>Descripción</pi-label>
|
|
</pi-textarea>
|
|
@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>
|
|
</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>();
|
|
|
|
protected readonly board = inject(BoardFacade);
|
|
protected readonly project = inject(ProjectFacade);
|
|
private readonly drawer = inject(DrawerService);
|
|
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 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.area() ||
|
|
this.typeFilter() ||
|
|
this.priorityFilter() ||
|
|
this.search() ||
|
|
this.labels().length
|
|
),
|
|
);
|
|
|
|
/* 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 typeOptions = computed<SegmentedControlOption<string>[]>(
|
|
() =>
|
|
this.project
|
|
.workflow()
|
|
.activeTypes()
|
|
.map((t) => ({ value: t.id, label: t.name })),
|
|
);
|
|
|
|
/* New ticket */
|
|
protected readonly newType = signal<string>('');
|
|
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.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((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,
|
|
};
|
|
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;
|
|
|
|
/** 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;
|
|
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.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.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())
|
|
);
|
|
}
|
|
|
|
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,
|
|
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,
|
|
assigneeId: v.assigneeId || null,
|
|
releaseId: v.releaseId || null,
|
|
});
|
|
this.drawer.closeAll();
|
|
}
|
|
}
|