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

@@ -76,6 +76,14 @@ export interface MyIssuesFilter {
export interface BoardFilter {
projectId: string;
assigneeId?: string;
/** Only tickets nobody has (wins over `assigneeId`). */
unassigned?: boolean;
releaseId?: string;
area?: string;
/** Ticket type ids. */
types?: string[];
priorities?: IssuePriority[];
labels?: string[];
/** Matches key, title or description. */
search?: string;
}

View File

@@ -70,6 +70,29 @@ export interface Epic {
progress: ChildProgress;
}
/** What a saved view narrows the board to; empty: everything. */
export interface SavedFilterCriteria {
types?: string[] | null;
areas?: string[] | null;
priorities?: IssuePriority[] | null;
assigneeId?: string | null;
unassigned?: boolean | null;
releaseId?: string | null;
labels?: string[] | null;
search?: string | null;
}
/** A named view of the board: private, or shared with the project. */
export interface SavedFilter {
id: string;
projectId: string;
owner: UserRef;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
createdAt: string;
}
/** What boards and lists show. */
export interface IssueSummary {
id: string;

View File

@@ -0,0 +1,43 @@
import { IssuePriority } from './enums';
import { boardFilterOf, criteriaOf, sameCriteria } from './views';
describe('saved views', () => {
it('turns a board filter into criteria and back', () => {
const filter = {
projectId: 'p1',
assigneeId: 'u1',
area: 'FRONTEND',
types: ['BUG'],
priorities: [IssuePriority.HIGH],
search: ' login ',
};
const criteria = criteriaOf(filter);
expect(criteria).toEqual({
assigneeId: 'u1',
areas: ['FRONTEND'],
types: ['BUG'],
priorities: [IssuePriority.HIGH],
search: 'login',
});
expect(boardFilterOf('p1', criteria)).toEqual({
...filter,
search: 'login',
});
});
it('prefers "nobody" over a person', () => {
expect(
criteriaOf({ projectId: 'p', unassigned: true, assigneeId: 'u' }),
).toEqual({ unassigned: true });
});
it('compares criteria regardless of list order and empty values', () => {
expect(
sameCriteria(
{ types: ['BUG', 'QA'], labels: [], search: null },
{ types: ['QA', 'BUG'] },
),
).toBe(true);
expect(sameCriteria({ types: ['BUG'] }, {})).toBe(false);
});
});

View File

@@ -0,0 +1,57 @@
import { BoardFilter } from './inputs';
import { SavedFilterCriteria } from './models';
const has = <T>(values?: T[] | null): values is T[] => !!values?.length;
/** The board filter as saved criteria (no project, nothing empty). */
export function criteriaOf(filter: BoardFilter): SavedFilterCriteria {
const criteria: SavedFilterCriteria = {};
if (filter.unassigned) criteria.unassigned = true;
else if (filter.assigneeId) criteria.assigneeId = filter.assigneeId;
if (filter.releaseId) criteria.releaseId = filter.releaseId;
if (filter.area) criteria.areas = [filter.area];
if (has(filter.types)) criteria.types = [...filter.types];
if (has(filter.priorities)) criteria.priorities = [...filter.priorities];
if (has(filter.labels)) criteria.labels = [...filter.labels];
if (filter.search?.trim()) criteria.search = filter.search.trim();
return criteria;
}
/** Saved criteria as the board filter of a project. */
export function boardFilterOf(
projectId: string,
criteria: SavedFilterCriteria,
): BoardFilter {
const filter: BoardFilter = { projectId };
if (criteria.unassigned) filter.unassigned = true;
else if (criteria.assigneeId) filter.assigneeId = criteria.assigneeId;
if (criteria.releaseId) filter.releaseId = criteria.releaseId;
if (has(criteria.areas)) filter.area = criteria.areas[0];
if (has(criteria.types)) filter.types = [...criteria.types];
if (has(criteria.priorities)) filter.priorities = [...criteria.priorities];
if (has(criteria.labels)) filter.labels = [...criteria.labels];
if (criteria.search?.trim()) filter.search = criteria.search.trim();
return filter;
}
/** Whether two filters show the same tickets (order of lists aside). */
export function sameCriteria(
a: SavedFilterCriteria,
b: SavedFilterCriteria,
): boolean {
const norm = (c: SavedFilterCriteria) =>
JSON.stringify(
Object.entries(c)
.filter(
([, v]) =>
v !== null &&
v !== undefined &&
v !== '' &&
v !== false &&
!(Array.isArray(v) && !v.length),
)
.map(([k, v]) => [k, Array.isArray(v) ? [...v].sort() : v])
.sort(([x], [y]) => String(x).localeCompare(String(y))),
);
return norm(a) === norm(b);
}

View File

@@ -5,6 +5,8 @@ import {
BoardColumn,
Comment,
Epic,
SavedFilter,
SavedFilterCriteria,
CommandResult,
Issue,
IssuePage,
@@ -124,6 +126,18 @@ export abstract class IssuesGateway {
): Observable<CommandResult>;
}
/** Saved views of a project's board (mine and the shared ones). */
export abstract class SavedFiltersGateway {
abstract list(projectId: string): Observable<SavedFilter[]>;
abstract save(input: {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
}): Observable<string>;
abstract remove(id: string): Observable<void>;
}
export abstract class CommentsGateway {
abstract list(issueId: string): Observable<Comment[]>;
abstract add(issueId: string, body: string): Observable<CommandResult>;