feat: saved board views and richer board filters
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>
This commit is contained in:
@@ -6,6 +6,14 @@
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.ts-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ts-filter {
|
||||
width: 13rem;
|
||||
|
||||
|
||||
@@ -23,11 +23,13 @@ import {
|
||||
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,
|
||||
@@ -36,14 +38,19 @@ import {
|
||||
} 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',
|
||||
@@ -58,6 +65,7 @@ const ALL = '';
|
||||
PiSelectFieldComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiSkeletonComponent,
|
||||
PiSlideToggleComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
PiLabelDirective,
|
||||
@@ -65,13 +73,70 @@ const ALL = '';
|
||||
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]="assigneeOptions()"
|
||||
[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"
|
||||
@@ -208,6 +273,37 @@ const ALL = '';
|
||||
</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"
|
||||
@@ -243,15 +339,72 @@ export class BoardPage {
|
||||
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>[]>(
|
||||
() => [
|
||||
@@ -332,16 +485,36 @@ export class BoardPage {
|
||||
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 filter = {
|
||||
assigneeId: this.assigneeFilter() || undefined,
|
||||
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, ...filter }));
|
||||
untracked(() =>
|
||||
this.board.show({
|
||||
projectId,
|
||||
...(Object.fromEntries(
|
||||
Object.entries(filter).filter(([, v]) => v !== undefined),
|
||||
) as Omit<BoardFilter, 'projectId'>),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,6 +556,62 @@ export class BoardPage {
|
||||
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()))
|
||||
|
||||
Reference in New Issue
Block a user