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:
2026-10-08 14:14:38 +02:00
parent 0606f53026
commit 298383f19f
46 changed files with 2922 additions and 26 deletions

View File

@@ -6,6 +6,7 @@ import {
CommandResult,
CreateIssueInput,
IssueChanged,
SavedFilter,
} from '@task-stream/web/tracker/domain';
export const boardPageEvents = eventGroup({
@@ -20,6 +21,9 @@ export const boardPageEvents = eventGroup({
reason?: string | null;
}>(),
issueCreateRequested: type<CreateIssueInput>(),
/** Save the current filter under a name. */
viewSaveRequested: type<{ name: string; shared: boolean }>(),
viewDeleteRequested: type<{ id: string }>(),
},
});
@@ -31,6 +35,10 @@ export const boardApiEvents = eventGroup({
issueMoveFailed: type<string>(),
issueCreated: type<CommandResult>(),
issueCreateFailed: type<string>(),
viewsLoaded: type<SavedFilter[]>(),
viewSaved: type<string>(),
viewDeleted: type<string>(),
viewFailed: type<string>(),
},
});

View File

@@ -1,6 +1,11 @@
import { inject, Injectable, Provider } from '@angular/core';
import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { BoardFilter, CreateIssueInput } from '@task-stream/web/tracker/domain';
import {
BoardFilter,
criteriaOf,
CreateIssueInput,
sameCriteria,
} from '@task-stream/web/tracker/domain';
import { boardPageEvents } from '../events/board.events';
import { BoardStore } from '../stores/board.store';
@@ -17,6 +22,17 @@ export class BoardFacade {
readonly loading = this.store.loading;
readonly creating = this.store.creating;
readonly error = this.store.error;
/** Saved views of this board: mine, then the shared ones. */
readonly views = this.store.views;
/** The saved view that shows exactly what the board shows now, if any. */
readonly activeView = computed(() => {
const filter = this.store.filter();
if (!filter) return null;
const criteria = criteriaOf(filter);
return (
this.store.views().find((v) => sameCriteria(v.criteria, criteria)) ?? null
);
});
/** Shows the board with this filter: opens it for a new project, refilters otherwise. */
show(filter: BoardFilter): void {
@@ -33,6 +49,16 @@ export class BoardFacade {
createIssue(input: CreateIssueInput): void {
this.dispatch.issueCreateRequested(input);
}
/** Saves the current filter as a view: private, or shared with the project. */
saveView(name: string, shared: boolean): void {
if (name.trim())
this.dispatch.viewSaveRequested({ name: name.trim(), shared });
}
deleteView(id: string): void {
this.dispatch.viewDeleteRequested({ id });
}
}
/** Store + facade of the board, for the board route's component. */

View File

@@ -12,6 +12,7 @@ import {
ProjectRole,
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -69,6 +70,11 @@ function gateways() {
download: vi.fn(() => of(new Blob(['x']))),
remove: vi.fn(() => of(ok)),
},
views: {
list: vi.fn(() => of([])),
save: vi.fn(() => of('v1')),
remove: vi.fn(() => of(undefined)),
},
realtime: {
issueChanges: () => NEVER,
commentChanges: () => NEVER,
@@ -106,6 +112,7 @@ function setup() {
{ provide: AttachmentsGateway, useValue: fakes.attachments },
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{ provide: SavedFiltersGateway, useValue: fakes.views },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
@@ -141,6 +148,35 @@ describe('BoardFacade', () => {
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
expect(board.filter()).toEqual({ projectId: 'p2' });
});
it('saves the current filter as a view and knows when it is showing one', () => {
const { board, fakes } = setup();
const view = {
id: 'v1',
projectId: 'p1',
name: 'Bugs',
shared: true,
owner: { id: 'me', name: 'Me' },
createdAt: '',
criteria: { types: ['BUG'] },
};
fakes.views.list.mockReturnValue(of([view]) as never);
board.show({ projectId: 'p1', types: ['BUG'], search: ' ' });
board.saveView(' Bugs ', true);
expect(fakes.views.save).toHaveBeenCalledWith({
projectId: 'p1',
name: 'Bugs',
shared: true,
criteria: { types: ['BUG'] },
});
expect(board.activeView()?.id).toBe('v1');
board.show({ projectId: 'p1' });
expect(board.activeView()).toBeNull();
board.deleteView('v1');
expect(fakes.views.remove).toHaveBeenCalledWith('v1');
});
});
describe('ProjectFacade', () => {

View File

@@ -22,10 +22,29 @@ export function matchesFilter(
filter: BoardFilter | null,
): boolean {
if (!filter) return true;
if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
if (filter.unassigned) {
if (issue.assignee) return false;
} else if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
return false;
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
if (filter.area && issue.area !== filter.area) return false;
if (filter.types?.length && !filter.types.includes(issue.type)) return false;
if (filter.priorities?.length && !filter.priorities.includes(issue.priority))
return false;
if (
filter.labels?.length &&
!filter.labels.every((l) => issue.labels.includes(l.toLowerCase()))
)
return false;
// Cards carry no description: a change that matched only by it drops off
// until the next load, which is the safe side.
const text = filter.search?.trim().toLowerCase();
if (
text &&
!issue.key.toLowerCase().includes(text) &&
!issue.title.toLowerCase().includes(text)
)
return false;
return true;
}

View File

@@ -10,11 +10,15 @@ import {
import {
BoardColumn,
BoardFilter,
criteriaOf,
IssuesGateway,
SavedFilter,
SavedFiltersGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
distinctUntilChanged,
EMPTY,
exhaustMap,
map,
merge,
@@ -36,6 +40,8 @@ interface BoardState {
loading: boolean;
creating: boolean;
error: string | null;
/** Saved views of this board: mine, then the shared ones. */
views: SavedFilter[];
}
/** Kanban board of a project, live. Scoped to the board route. */
@@ -46,6 +52,7 @@ export const BoardStore = signalStore(
loading: false,
creating: false,
error: null,
views: [],
}),
withComputed(({ columns }) => ({
total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)),
@@ -70,6 +77,7 @@ export const BoardStore = signalStore(
on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({
columns: applyIssueChange(state.columns, payload.issue, state.filter),
})),
on(boardApiEvents.viewsLoaded, ({ payload }) => ({ views: payload })),
on(boardPageEvents.issueCreateRequested, () => ({ creating: true })),
on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({
creating: false,
@@ -82,7 +90,16 @@ export const BoardStore = signalStore(
issues = inject(IssuesGateway),
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
savedFilters = inject(SavedFiltersGateway),
) => {
const loadViews = (projectId: string) =>
savedFilters.list(projectId).pipe(
mapResponse({
next: (views) => boardApiEvents.viewsLoaded(views),
// Views are a convenience: without them the board still works.
error: () => boardApiEvents.viewsLoaded([]),
}),
);
const load = (filter: BoardFilter) =>
issues.board(filter).pipe(
mapResponse({
@@ -101,6 +118,50 @@ export const BoardStore = signalStore(
switchMap((projectId) => realtime.issueChanges(projectId)),
map((change) => boardRealtimeEvents.issueChanged(change)),
),
views$: merge(
events.on(boardPageEvents.opened).pipe(
map(({ payload }) => payload.projectId),
distinctUntilChanged(),
),
events
.on(boardApiEvents.viewSaved, boardApiEvents.viewDeleted)
.pipe(map(() => store.filter()?.projectId ?? null)),
).pipe(
switchMap((projectId) => (projectId ? loadViews(projectId) : EMPTY)),
),
saveView$: events.on(boardPageEvents.viewSaveRequested).pipe(
exhaustMap(({ payload }) => {
const filter = store.filter();
if (!filter) return EMPTY;
return savedFilters
.save({
projectId: filter.projectId,
name: payload.name,
shared: payload.shared,
criteria: criteriaOf(filter),
})
.pipe(
mapResponse({
next: () => boardApiEvents.viewSaved(payload.name),
error: (error) =>
boardApiEvents.viewFailed(describeError(error)),
}),
);
}),
),
deleteView$: events.on(boardPageEvents.viewDeleteRequested).pipe(
mergeMap(({ payload }) => {
const name =
store.views().find((v) => v.id === payload.id)?.name ?? '';
return savedFilters.remove(payload.id).pipe(
mapResponse({
next: () => boardApiEvents.viewDeleted(name),
error: (error) =>
boardApiEvents.viewFailed(describeError(error)),
}),
);
}),
),
move$: events.on(boardPageEvents.issueMoved).pipe(
mergeMap(({ payload }) =>
issues
@@ -146,7 +207,25 @@ export const BoardStore = signalStore(
),
),
events
.on(boardApiEvents.issueCreateFailed, boardApiEvents.loadFailed)
.on(boardApiEvents.viewSaved)
.pipe(
tap(({ payload }) =>
notifications.success(`Vista «${payload}» guardada`),
),
),
events
.on(boardApiEvents.viewDeleted)
.pipe(
tap(({ payload }) =>
notifications.success(`Vista «${payload}» eliminada`),
),
),
events
.on(
boardApiEvents.issueCreateFailed,
boardApiEvents.loadFailed,
boardApiEvents.viewFailed,
)
.pipe(tap(({ payload }) => notifications.error(payload))),
),
};