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))),
),
};

View File

@@ -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';

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>;

View File

@@ -52,6 +52,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
releaseId: filter.releaseId ?? null,
assigneeId: filter.assigneeId ?? null,
area: filter.area ?? null,
types: filter.types?.length ? filter.types : null,
priorities: filter.priorities?.length
? (filter.priorities as never)
: null,
labels: filter.labels?.length ? filter.labels : null,
search: filter.search?.trim() || null,
unassigned: filter.unassigned ?? null,
})
.pipe(
map((d) =>

View File

@@ -0,0 +1,41 @@
import { inject, Injectable } from '@angular/core';
import {
SavedFilter,
SavedFilterCriteria,
SavedFiltersGateway,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
DeleteSavedFilterDocument,
SavedFiltersDocument,
SaveFilterDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@Injectable()
export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
private readonly gql = inject(GraphqlOperations);
list(projectId: string): Observable<SavedFilter[]> {
return this.gql
.query(SavedFiltersDocument, { projectId })
.pipe(map((d) => d.savedFilters as SavedFilter[]));
}
save(input: {
projectId: string;
name: string;
shared: boolean;
criteria: SavedFilterCriteria;
}): Observable<string> {
return this.gql
.mutate(SaveFilterDocument, { input: input as never })
.pipe(map((d) => d.saveFilter));
}
remove(id: string): Observable<void> {
return this.gql
.mutate(DeleteSavedFilterDocument, { id })
.pipe(map(() => undefined));
}
}

View File

@@ -147,6 +147,25 @@ export type RankIssueInput = {
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
export type SaveFilterInput = {
criteria: SavedFilterCriteriaInput;
name: string;
projectId: string;
/** Visible to the whole project (members, not viewers, share) */
shared?: boolean;
};
export type SavedFilterCriteriaInput = {
areas?: Array<string> | null | undefined;
assigneeId?: string | null | undefined;
labels?: Array<string> | null | undefined;
priorities?: Array<IssuePriority> | null | undefined;
releaseId?: string | null | undefined;
search?: string | null | undefined;
types?: Array<string> | null | undefined;
unassigned?: boolean | null | undefined;
};
/** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */
export type StatusCategory =
| 'CANCELLED'
@@ -335,6 +354,11 @@ export type BoardQueryVariables = Exact<{
releaseId?: string | null | undefined;
assigneeId?: string | null | undefined;
area?: string | null | undefined;
types?: Array<string> | string | null | undefined;
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
labels?: Array<string> | string | null | undefined;
search?: string | null | undefined;
unassigned?: boolean | null | undefined;
}>;
export type BoardQuery = {
@@ -1138,6 +1162,62 @@ export type ReleaseBurnupQuery = {
};
};
export type SavedFilterFieldsFragment = {
id: string;
projectId: string;
name: string;
shared: boolean;
createdAt: string;
owner: { id: string; name: string };
criteria: {
types: Array<string> | null;
areas: Array<string> | null;
priorities: Array<IssuePriority> | null;
assigneeId: string | null;
unassigned: boolean | null;
releaseId: string | null;
labels: Array<string> | null;
search: string | null;
};
};
export type SavedFiltersQueryVariables = Exact<{
projectId: string;
}>;
export type SavedFiltersQuery = {
savedFilters: Array<{
id: string;
projectId: string;
name: string;
shared: boolean;
createdAt: string;
owner: { id: string; name: string };
criteria: {
types: Array<string> | null;
areas: Array<string> | null;
priorities: Array<IssuePriority> | null;
assigneeId: string | null;
unassigned: boolean | null;
releaseId: string | null;
labels: Array<string> | null;
search: string | null;
};
}>;
};
export type SaveFilterMutationVariables = Exact<{
input: SaveFilterInput;
}>;
export type SaveFilterMutation = { saveFilter: string };
export type DeleteSavedFilterMutationVariables = Exact<{
id: string;
}>;
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
export type IssueChangedSubscriptionVariables = Exact<{
projectId: string;
}>;
@@ -1949,6 +2029,74 @@ export const NotificationFieldsFragmentDoc = {
},
],
} as unknown as DocumentNode<NotificationFieldsFragment, unknown>;
export const SavedFilterFieldsFragmentDoc = {
kind: 'Document',
definitions: [
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'SavedFilterFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SavedFilter' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'shared' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'owner' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'criteria' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
{ kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'unassigned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'search' } },
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
],
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
export const BoardDocument = {
kind: 'Document',
definitions: [
@@ -1992,6 +2140,73 @@ export const BoardDocument = {
variable: { kind: 'Variable', name: { kind: 'Name', value: 'area' } },
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'types' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'IssuePriority' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'labels' },
},
type: {
kind: 'ListType',
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'unassigned' },
},
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
},
],
selectionSet: {
kind: 'SelectionSet',
@@ -2041,6 +2256,46 @@ export const BoardDocument = {
name: { kind: 'Name', value: 'area' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'types' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'types' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'priorities' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'priorities' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'labels' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'labels' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'search' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'search' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'unassigned' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'unassigned' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'perColumn' },
@@ -7168,6 +7423,207 @@ export const ReleaseBurnupDocument = {
},
],
} as unknown as DocumentNode<ReleaseBurnupQuery, ReleaseBurnupQueryVariables>;
export const SavedFiltersDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'SavedFilters' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'savedFilters' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'projectId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'projectId' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'SavedFilterFields' },
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'SavedFilterFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SavedFilter' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'shared' } },
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'owner' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'criteria' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'types' } },
{ kind: 'Field', name: { kind: 'Name', value: 'areas' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priorities' } },
{ kind: 'Field', name: { kind: 'Name', value: 'assigneeId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'unassigned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'search' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<SavedFiltersQuery, SavedFiltersQueryVariables>;
export const SaveFilterDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'SaveFilter' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'SaveFilterInput' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'saveFilter' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'input' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'input' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<SaveFilterMutation, SaveFilterMutationVariables>;
export const DeleteSavedFilterDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'DeleteSavedFilter' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'deleteSavedFilter' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'id' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'id' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
DeleteSavedFilterMutation,
DeleteSavedFilterMutationVariables
>;
export const IssueChangedDocument = {
kind: 'Document',
definitions: [

View File

@@ -1,10 +1,25 @@
query Board($idOrKey: String!, $releaseId: ID, $assigneeId: ID, $area: String) {
query Board(
$idOrKey: String!
$releaseId: ID
$assigneeId: ID
$area: String
$types: [String!]
$priorities: [IssuePriority!]
$labels: [String!]
$search: String
$unassigned: Boolean
) {
project(idOrKey: $idOrKey) {
id
board(
releaseId: $releaseId
assigneeId: $assigneeId
area: $area
types: $types
priorities: $priorities
labels: $labels
search: $search
unassigned: $unassigned
perColumn: 100
) {
status

View File

@@ -0,0 +1,34 @@
fragment SavedFilterFields on SavedFilter {
id
projectId
name
shared
createdAt
owner {
...UserRefFields
}
criteria {
types
areas
priorities
assigneeId
unassigned
releaseId
labels
search
}
}
query SavedFilters($projectId: ID!) {
savedFilters(projectId: $projectId) {
...SavedFilterFields
}
}
mutation SaveFilter($input: SaveFilterInput!) {
saveFilter(input: $input)
}
mutation DeleteSavedFilter($id: ID!) {
deleteSavedFilter(id: $id)
}

View File

@@ -10,6 +10,7 @@ import {
NotificationsGateway,
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -24,6 +25,7 @@ import { GraphqlNotificationsGateway } from './adapters/graphql-notifications.ga
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -37,5 +39,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
]);
}

View File

@@ -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;

View File

@@ -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()))