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

@@ -181,6 +181,57 @@ export class ChangeIssueStatusInput {
reason?: string | null;
}
@InputType()
export class SavedFilterCriteriaInput {
@Field(() => [String], { nullable: true })
@IsOptional()
@Matches(SETTING_ID, { each: true })
types?: string[];
@Field(() => [String], { nullable: true })
@IsOptional()
@Matches(SETTING_ID, { each: true })
areas?: string[];
@Field(() => [IssuePriority], { nullable: true })
@IsOptional()
@IsEnum(IssuePriority, { each: true })
priorities?: IssuePriority[];
@Field(() => ID, { nullable: true })
@IsOptional()
@IsString()
assigneeId?: string | null;
@Field({ nullable: true }) @IsOptional() @IsBoolean() unassigned?: boolean;
@Field(() => ID, { nullable: true })
@IsOptional()
@IsUUID()
releaseId?: string | null;
@Field(() => [String], { nullable: true })
@IsOptional()
@ArrayMaxSize(20)
@IsString({ each: true })
labels?: string[];
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
@MaxLength(200)
search?: string | null;
}
@InputType()
export class SaveFilterInput {
@Field(() => ID) @IsUUID() projectId!: string;
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
@Field({
defaultValue: false,
description: 'Visible to the whole project (members, not viewers, share)',
})
@IsBoolean()
shared!: boolean;
@Field(() => SavedFilterCriteriaInput)
@ValidateNested()
@Type(() => SavedFilterCriteriaInput)
criteria!: SavedFilterCriteriaInput;
}
@InputType()
export class RankIssueInput {
@Field(() => ID) @IsUUID() issueId!: string;

View File

@@ -15,7 +15,10 @@ import {
OptionalAuth,
} from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
import {
IssuePriority,
ProjectReadModel,
} from '@task-stream/api/tracker/domain';
import { viewerOf } from '../common/viewer';
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
import {
@@ -108,6 +111,20 @@ export class ProjectResolver {
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
@Args('area', { type: () => String, nullable: true }) area?: string,
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
@Args('types', { type: () => [String], nullable: true })
types?: string[] | null,
@Args('priorities', { type: () => [IssuePriority], nullable: true })
priorities?: IssuePriority[] | null,
@Args('labels', { type: () => [String], nullable: true })
labels?: string[] | null,
@Args('search', {
type: () => String,
nullable: true,
description: 'Matches key, title or description',
})
search?: string | null,
@Args('unassigned', { type: () => Boolean, nullable: true })
unassigned?: boolean | null,
) {
return this.projects.board(
viewerOf(me),
@@ -116,6 +133,11 @@ export class ProjectResolver {
releaseId,
assigneeId,
areas: area ? [area] : undefined,
types: types ?? undefined,
priorities: priorities ?? undefined,
labels: labels ?? undefined,
search: search?.slice(0, 200) ?? undefined,
unassigned: unassigned ?? undefined,
},
perColumn,
);

View File

@@ -0,0 +1,44 @@
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { CurrentUser } from '@task-stream/api/identity/presenters';
import { SavedFiltersService } from '@task-stream/api/tracker/application';
import { SaveFilterInput } from './inputs';
import { SavedFilterGql } from './types';
/** Saved views of a project's board. */
@Resolver(() => SavedFilterGql)
export class SavedFilterResolver {
constructor(private readonly filters: SavedFiltersService) {}
@Query(() => [SavedFilterGql], {
description: "My views of the project's board, then the shared ones",
})
savedFilters(
@CurrentUser() me: User,
@Args('projectId', { type: () => ID }) projectId: string,
) {
return this.filters.list(me.id, projectId);
}
@Mutation(() => ID, {
description:
'Save the current board filters under a name (members of the project)',
})
async saveFilter(
@CurrentUser() me: User,
@Args('input') input: SaveFilterInput,
) {
return (await this.filters.save(me.id, input)).id;
}
@Mutation(() => Boolean, {
description: 'Delete a view: whoever saved it, or an admin if it is shared',
})
async deleteSavedFilter(
@CurrentUser() me: User,
@Args('id', { type: () => ID }) id: string,
) {
await this.filters.delete(me.id, id);
return true;
}
}

View File

@@ -198,6 +198,35 @@ export class EpicGql {
@Field(() => ChildProgressGql) progress!: ChildProgressGql;
}
@ObjectType('SavedFilterCriteria', {
description: 'What a saved view narrows the board to; empty: everything',
})
export class SavedFilterCriteriaGql {
@Field(() => [String], { nullable: true }) types?: string[];
@Field(() => [String], { nullable: true }) areas?: string[];
@Field(() => [IssuePriority], { nullable: true })
priorities?: IssuePriority[];
@Field(() => ID, { nullable: true }) assigneeId?: string | null;
@Field({ nullable: true }) unassigned?: boolean;
@Field(() => ID, { nullable: true }) releaseId?: string | null;
@Field(() => [String], { nullable: true }) labels?: string[];
@Field(() => String, { nullable: true }) search?: string | null;
}
@ObjectType('SavedFilter', {
description:
'A named view of the board: private to whoever saved it, or shared with the project',
})
export class SavedFilterGql {
@Field(() => ID) id!: string;
@Field(() => ID) projectId!: string;
@Field(() => UserRefGql) owner!: UserRefGql;
@Field() name!: string;
@Field() shared!: boolean;
@Field(() => SavedFilterCriteriaGql) criteria!: SavedFilterCriteriaGql;
@Field() createdAt!: string;
}
@ObjectType('IssuePage')
export class IssuePageGql {
@Field(() => [IssueGql]) items!: IssueGql[];