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,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>(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))),
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user