feat: saved views of my work, across my projects
A person saves named views of «my work»: projects, assigned to me, that I follow, status categories, priorities, bugs only, overdue and text. They are private (Mongo my_views, no history), listed and deleted by their owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views), and run as the cross-project search, which gains project, priority, bugsOnly and overdue filters (also in MCP my_issues). The home page shows them as a bar above «my work», with a drawer to save a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -784,3 +784,39 @@ export class PushSubscriptionInput {
|
||||
@Field() @IsString() @MaxLength(200) p256dh!: string;
|
||||
@Field() @IsString() @MaxLength(100) auth!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class MyViewCriteriaInput {
|
||||
@Field(() => [ID], { nullable: true, description: 'Empty: all my projects' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsUUID('all', { each: true })
|
||||
projectIds?: string[];
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() assignedToMe?: boolean;
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() watching?: boolean;
|
||||
@Field(() => [StatusCategory], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(StatusCategory, { each: true })
|
||||
categories?: StatusCategory[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priorities?: IssuePriority[];
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() bugsOnly?: boolean;
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() overdue?: boolean;
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class SaveMyViewInput {
|
||||
@Field() @IsString() @MinLength(1) @MaxLength(60) name!: string;
|
||||
@Field(() => MyViewCriteriaInput)
|
||||
@ValidateNested()
|
||||
@Type(() => MyViewCriteriaInput)
|
||||
criteria!: MyViewCriteriaInput;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -121,6 +122,26 @@ export class IssueResolver {
|
||||
description: 'Matches key, title or description (global search)',
|
||||
})
|
||||
search?: string | null,
|
||||
@Args('projectIds', {
|
||||
type: () => [ID],
|
||||
nullable: true,
|
||||
description: 'Only these of my projects',
|
||||
})
|
||||
projectIds?: string[] | null,
|
||||
@Args('priorities', { type: () => [IssuePriority], nullable: true })
|
||||
priorities?: IssuePriority[] | null,
|
||||
@Args('bugsOnly', {
|
||||
type: () => Boolean,
|
||||
nullable: true,
|
||||
description: 'Only types drawn with the bug icon',
|
||||
})
|
||||
bugsOnly?: boolean | null,
|
||||
@Args('overdue', {
|
||||
type: () => Boolean,
|
||||
nullable: true,
|
||||
description: 'Only open tickets past their due date',
|
||||
})
|
||||
overdue?: boolean | null,
|
||||
) {
|
||||
return this.issuesService.mine(me.id, {
|
||||
assignedToMe,
|
||||
@@ -128,6 +149,10 @@ export class IssueResolver {
|
||||
statuses: statuses ?? undefined,
|
||||
categories: categories ?? undefined,
|
||||
search: search ?? undefined,
|
||||
projectIds: projectIds ?? undefined,
|
||||
priorities: priorities ?? undefined,
|
||||
bugsOnly: bugsOnly ?? undefined,
|
||||
overdue: overdue ?? undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ 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';
|
||||
import { SaveFilterInput, SaveMyViewInput } from './inputs';
|
||||
import { MyViewGql, SavedFilterGql } from './types';
|
||||
|
||||
/** Saved views of a project's board. */
|
||||
@Resolver(() => SavedFilterGql)
|
||||
@@ -41,4 +41,36 @@ export class SavedFilterResolver {
|
||||
await this.filters.delete(me.id, id);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Query(() => [MyViewGql], {
|
||||
description: 'My saved views of my work, across my projects',
|
||||
})
|
||||
myViews(@CurrentUser() me: User) {
|
||||
return this.filters.myViews(me.id);
|
||||
}
|
||||
|
||||
@Mutation(() => ID, { description: 'Save a view of my work under a name' })
|
||||
async saveMyView(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: SaveMyViewInput,
|
||||
) {
|
||||
return (
|
||||
await this.filters.saveMyView(me.id, {
|
||||
name: input.name,
|
||||
criteria: {
|
||||
...input.criteria,
|
||||
search: input.criteria.search ?? undefined,
|
||||
},
|
||||
})
|
||||
).id;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: 'Delete one of my views' })
|
||||
async deleteMyView(
|
||||
@CurrentUser() me: User,
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
) {
|
||||
await this.filters.deleteMyView(me.id, id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +456,34 @@ export class SavedFilterGql {
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('MyViewCriteria', {
|
||||
description:
|
||||
'What a view of my work narrows to; empty: every ticket of my projects',
|
||||
})
|
||||
export class MyViewCriteriaGql {
|
||||
@Field(() => [ID], { nullable: true, description: 'Empty: all my projects' })
|
||||
projectIds?: string[];
|
||||
@Field({ nullable: true }) assignedToMe?: boolean;
|
||||
@Field({ nullable: true }) watching?: boolean;
|
||||
@Field(() => [StatusCategory], { nullable: true })
|
||||
categories?: StatusCategory[];
|
||||
@Field(() => [IssuePriority], { nullable: true })
|
||||
priorities?: IssuePriority[];
|
||||
@Field({ nullable: true, description: 'Only types with the bug icon' })
|
||||
bugsOnly?: boolean;
|
||||
@Field({ nullable: true, description: 'Only open tickets past due' })
|
||||
overdue?: boolean;
|
||||
@Field(() => String, { nullable: true }) search?: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('MyView', { description: 'A named view of my work (private)' })
|
||||
export class MyViewGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field() name!: string;
|
||||
@Field(() => MyViewCriteriaGql) criteria!: MyViewCriteriaGql;
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('IssuePage')
|
||||
export class IssuePageGql {
|
||||
@Field(() => [IssueGql]) items!: IssueGql[];
|
||||
|
||||
@@ -500,6 +500,38 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description:
|
||||
'Only these of my projects (ids). Repeat or separate with commas',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsUUID('all', { each: true })
|
||||
project?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toList)
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priority?: IssuePriority[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only types with the bug icon' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
bugsOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Only open tickets past their due date' })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
overdue?: boolean;
|
||||
}
|
||||
|
||||
export class UpcomingReleasesQueryDto {
|
||||
@@ -898,6 +930,69 @@ export class SavedFilterCriteriaDto {
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
export class MyViewCriteriaDto {
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'Only these of my projects (empty: all)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsUUID('all', { each: true })
|
||||
projectIds?: string[];
|
||||
@ApiPropertyOptional() @IsOptional() @IsBoolean() assignedToMe?: boolean;
|
||||
@ApiPropertyOptional({ description: 'Only the tickets I follow' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
watching?: boolean;
|
||||
@ApiPropertyOptional({
|
||||
enum: StatusCategory,
|
||||
enumName: 'StatusCategory',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(StatusCategory, { each: true })
|
||||
categories?: StatusCategory[];
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(IssuePriority, { each: true })
|
||||
priorities?: IssuePriority[];
|
||||
@ApiPropertyOptional({ description: 'Only types with the bug icon' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
bugsOnly?: boolean;
|
||||
@ApiPropertyOptional({ description: 'Only open tickets past due' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
overdue?: boolean;
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SaveMyViewDto {
|
||||
@ApiProperty({
|
||||
example: 'Bugs críticos de todos mis proyectos',
|
||||
maxLength: 60,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(60)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: MyViewCriteriaDto })
|
||||
@ValidateNested()
|
||||
@Type(() => MyViewCriteriaDto)
|
||||
criteria!: MyViewCriteriaDto;
|
||||
}
|
||||
|
||||
export class SaveFilterDto {
|
||||
@ApiProperty({ example: 'Bugs críticos de frontend', maxLength: 60 })
|
||||
@IsString()
|
||||
|
||||
@@ -526,6 +526,37 @@ export class SavedFilterDto {
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
}
|
||||
|
||||
export class MyViewCriteriaResponseDto {
|
||||
@ApiPropertyOptional({ type: [String], format: 'uuid' })
|
||||
projectIds?: string[];
|
||||
@ApiPropertyOptional() assignedToMe?: boolean;
|
||||
@ApiPropertyOptional() watching?: boolean;
|
||||
@ApiPropertyOptional({
|
||||
enum: StatusCategory,
|
||||
enumName: 'StatusCategory',
|
||||
isArray: true,
|
||||
})
|
||||
categories?: StatusCategory[];
|
||||
@ApiPropertyOptional({
|
||||
enum: IssuePriority,
|
||||
enumName: 'IssuePriority',
|
||||
isArray: true,
|
||||
})
|
||||
priorities?: IssuePriority[];
|
||||
@ApiPropertyOptional() bugsOnly?: boolean;
|
||||
@ApiPropertyOptional() overdue?: boolean;
|
||||
@ApiPropertyOptional() search?: string;
|
||||
}
|
||||
|
||||
export class MyViewDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ example: 'Bugs críticos de todos mis proyectos' })
|
||||
name!: string;
|
||||
@ApiProperty({ type: MyViewCriteriaResponseDto })
|
||||
criteria!: MyViewCriteriaResponseDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
}
|
||||
|
||||
export class SavedFilterIdDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,10 @@ export class IssuesController {
|
||||
statuses: query.status,
|
||||
categories: query.category,
|
||||
search: query.search,
|
||||
projectIds: query.project,
|
||||
priorities: query.priority,
|
||||
bugsOnly: query.bugsOnly,
|
||||
overdue: query.overdue,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
|
||||
@@ -26,8 +26,12 @@ import {
|
||||
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 { SaveFilterDto, SaveMyViewDto } from './dto/requests.dto';
|
||||
import {
|
||||
MyViewDto,
|
||||
SavedFilterDto,
|
||||
SavedFilterIdDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
/** Saved views of a project's board: mine, and the ones shared with it. */
|
||||
@@ -93,4 +97,39 @@ export class SavedFiltersController {
|
||||
) {
|
||||
await this.filters.delete(me.id, id);
|
||||
}
|
||||
|
||||
@Get('me/views')
|
||||
@ApiOperation({
|
||||
summary: 'My saved views of my work, across my projects (by name)',
|
||||
})
|
||||
@ApiOkResponse({ type: [MyViewDto] })
|
||||
@ApiReadErrors()
|
||||
myViews(@CurrentUser() me: User) {
|
||||
return this.filters.myViews(me.id);
|
||||
}
|
||||
|
||||
@Post('me/views')
|
||||
@ApiOperation({
|
||||
summary: 'Save a view of my work under a name',
|
||||
description:
|
||||
'Run it with GET /api/me/issues and the same criteria (project, priority, bugsOnly, overdue…).',
|
||||
})
|
||||
@ApiCreatedResponse({ type: SavedFilterIdDto })
|
||||
@ApiCommandErrors()
|
||||
saveMyView(@CurrentUser() me: User, @Body() body: SaveMyViewDto) {
|
||||
return this.filters.saveMyView(me.id, body);
|
||||
}
|
||||
|
||||
@Delete('me/views/:id')
|
||||
@HttpCode(204)
|
||||
@ApiParam({ name: 'id', format: 'uuid' })
|
||||
@ApiOperation({ summary: 'Delete one of my views' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async deleteMyView(
|
||||
@CurrentUser() me: User,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
await this.filters.deleteMyView(me.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user