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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -251,6 +251,40 @@ export class BoardQueryDto {
|
||||
@Matches(SETTING_ID)
|
||||
area?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
type?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priority?: IssuePriority[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsString({ each: true })
|
||||
label?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only tickets without assignee' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -589,6 +623,76 @@ export class SetIssueReleaseDto {
|
||||
releaseId!: string | null;
|
||||
}
|
||||
|
||||
export class SavedFilterCriteriaDto {
|
||||
@ApiPropertyOptional({ type: [String], description: 'Ticket type ids' })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
types?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Area ids' })
|
||||
@IsOptional()
|
||||
@Matches(SETTING_ID, { each: true })
|
||||
areas?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priorities?: IssuePriority[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
nullable: true,
|
||||
description: 'Pireon user id',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assigneeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() unassigned?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
releaseId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
labels?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: String, nullable: true, maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
export class SaveFilterDto {
|
||||
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'Visible to the whole project (members, not viewers, share)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
shared = false;
|
||||
|
||||
@ApiProperty({ type: SavedFilterCriteriaDto })
|
||||
@ValidateNested()
|
||||
@Type(() => SavedFilterCriteriaDto)
|
||||
criteria!: SavedFilterCriteriaDto;
|
||||
}
|
||||
|
||||
export class RankIssueDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
|
||||
@@ -191,6 +191,41 @@ export class IssueDto {
|
||||
@ApiProperty() version!: number;
|
||||
}
|
||||
|
||||
export class SavedFilterCriteriaResponseDto {
|
||||
@ApiPropertyOptional({ type: [String] }) types?: string[];
|
||||
@ApiPropertyOptional({ type: [String] }) areas?: string[];
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
priorities?: IssuePriority[];
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
assigneeId?: string | null;
|
||||
@ApiPropertyOptional() unassigned?: boolean;
|
||||
@ApiPropertyOptional({ type: String, nullable: true, format: 'uuid' })
|
||||
releaseId?: string | null;
|
||||
@ApiPropertyOptional({ type: [String] }) labels?: string[];
|
||||
@ApiPropertyOptional({ type: String, nullable: true })
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
export class SavedFilterDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
|
||||
@ApiProperty({ example: 'Bugs críticos de frontend' }) name!: string;
|
||||
@ApiProperty({ description: 'Visible to the whole project' })
|
||||
shared!: boolean;
|
||||
@ApiProperty({ type: SavedFilterCriteriaResponseDto })
|
||||
criteria!: SavedFilterCriteriaResponseDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
}
|
||||
|
||||
export class SavedFilterIdDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
}
|
||||
|
||||
export class IssuePageDto {
|
||||
@ApiProperty({ type: [IssueDto] }) items!: IssueDto[];
|
||||
@ApiProperty({ description: 'Total matches (for pagination)' })
|
||||
|
||||
@@ -182,6 +182,11 @@ export class ProjectsController {
|
||||
releaseId: query.releaseId,
|
||||
assigneeId: query.assigneeId,
|
||||
areas: query.area ? [query.area] : undefined,
|
||||
types: query.type,
|
||||
priorities: query.priority,
|
||||
labels: query.label,
|
||||
search: query.search,
|
||||
unassigned: query.unassigned,
|
||||
},
|
||||
query.perColumn,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import { SavedFiltersService } from '@task-stream/api/tracker/application';
|
||||
import { SaveFilterDto } from './dto/requests.dto';
|
||||
import { SavedFilterDto, SavedFilterIdDto } from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
/** Saved views of a project's board: mine, and the ones shared with it. */
|
||||
@ApiTags('Saved filters')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class SavedFiltersController {
|
||||
constructor(
|
||||
private readonly filters: SavedFiltersService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/filters')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "My views of the project's board, then the shared ones",
|
||||
})
|
||||
@ApiOkResponse({ type: [SavedFilterDto] })
|
||||
@ApiReadErrors()
|
||||
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.filters.list(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Post('projects/:project/filters')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: 'Save board filters under a name',
|
||||
description:
|
||||
'Project members only. `shared` makes it visible to the whole project (not for viewers).',
|
||||
})
|
||||
@ApiCreatedResponse({ type: SavedFilterIdDto })
|
||||
@ApiCommandErrors()
|
||||
async save(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: SaveFilterDto,
|
||||
) {
|
||||
return this.filters.save(me.id, {
|
||||
...body,
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('filters/:id')
|
||||
@HttpCode(204)
|
||||
@ApiParam({ name: 'id', format: 'uuid' })
|
||||
@ApiOperation({
|
||||
summary: 'Delete a saved view (whoever saved it, or an admin if shared)',
|
||||
})
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async delete(
|
||||
@CurrentUser() me: User,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
await this.filters.delete(me.id, id);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { ProjectsController } from './http/projects.controller';
|
||||
import { Refs } from './http/refs';
|
||||
import { ReleasesController } from './http/releases.controller';
|
||||
import { NotificationsController } from './http/notifications.controller';
|
||||
import { SavedFiltersController } from './http/saved-filters.controller';
|
||||
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -45,6 +47,7 @@ import { NotificationsController } from './http/notifications.controller';
|
||||
ReleasesController,
|
||||
ExploreController,
|
||||
NotificationsController,
|
||||
SavedFiltersController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -54,6 +57,7 @@ import { NotificationsController } from './http/notifications.controller';
|
||||
ReleaseResolver,
|
||||
SubscriptionResolver,
|
||||
NotificationResolver,
|
||||
SavedFilterResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
Reference in New Issue
Block a user