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

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

View File

@@ -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)' })

View File

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

View File

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