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()) { }
}
{{ board.total() }} tickets @if (project.canContribute()) { }
@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í

}
}
}
@if (typeOptions().length <= 5) { } @else { Tipo } Título Descripción @if (project.workflow().isDesignType(newType())) { Frame de Figma }
Prioridad Área
Asignar a Release

Guarda los filtros de ahora para volver a ellos con un clic.

Nombre @if (project.canContribute()) { }

Explica el motivo: quedará en el historial del ticket.

Motivo
`, }) 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(); } }