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()) {
@if (!board.activeView() && isFiltered()) {
}
@if (board.activeView(); as view) {
@if (canDeleteView(view)) {
}
}
@if (isFiltered()) {
}
}
@if (board.loading() && !board.columns().length) {
@for (i of [1, 2, 3, 4]; track i) {
}
} @else {
@for (column of board.columns(); track column.status) {
{{
statusLabel(column.status)
}}
{{ column.total }}
@for (issue of column.issues; track issue.id) {
} @empty {
Nada por aquí
}
}
}
`,
})
export class BoardPage {
/** `?release=` (from the releases page), bound by the router. */
readonly release = input();
/** `?assignee=` (from the tracking page's team list), bound by the router. */
readonly assignee = input();
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>('createForm');
private readonly reasonForm =
viewChild.required>('reasonForm');
private readonly viewForm =
viewChild.required>('viewForm');
/* Filters */
protected readonly assigneeFilter = signal(ALL);
protected readonly releaseFilter = signal(ALL);
protected readonly area = signal(ALL);
protected readonly typeFilter = signal(ALL);
protected readonly priorityFilter = signal(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([]);
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[]>(
() => [
{ 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[]
>(() => [
{ 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[]
>(() => [
{ value: ALL, label: 'Todos los tipos' },
...this.project
.workflow()
.settings.issueTypes.map((t) => ({ value: t.id, label: t.name })),
]);
protected readonly priorityFilterOptions: PiSelectFieldOption[] = [
{ value: ALL, label: 'Todas las prioridades' },
...options(PRIORITY),
];
protected readonly assigneeOptions = computed[]>(
() => [
{ value: ALL, label: 'Todas las personas' },
...this.project
.members()
.map((m) => ({ value: m.user.id, label: m.user.name })),
],
);
protected readonly releaseOptions = computed[]>(
() => [
{ 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[]
>(() => [
{ value: ALL, label: 'Todas las áreas' },
...this.project
.workflow()
.settings.areas.map((a) => ({ value: a.id, label: a.name })),
]);
protected readonly areaOptions = computed[]>(
() => [
{ 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[]>(
() =>
this.project
.workflow()
.activeTypes()
.map((t) => ({ value: t.id, label: t.name })),
);
/* New ticket */
protected readonly newType = signal('');
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 = {
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),
}),
);
});
}
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,
drop: CdkDropList,
) =>
this.project
.workflow()
.allowedFrom(drag.data.status)
.includes(drop.data.status);
protected drop(
event: CdkDragDrop,
): 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();
}
}