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

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