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:
@@ -5,3 +5,4 @@ export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
export * from './lib/models/mentions';
|
||||
export * from './lib/models/hierarchy';
|
||||
export * from './lib/models/views';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
libs/web/tracker/domain/src/lib/models/views.spec.ts
Normal file
43
libs/web/tracker/domain/src/lib/models/views.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
libs/web/tracker/domain/src/lib/models/views.ts
Normal file
57
libs/web/tracker/domain/src/lib/models/views.ts
Normal 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);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user