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:
@@ -29,4 +29,6 @@ export * from './lib/webhooks/webhook-events';
|
||||
export * from './lib/git/git';
|
||||
export * from './lib/git/git-payloads';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/saved-filters/my-views';
|
||||
export * from './lib/ports/my-views.repository';
|
||||
export * from './lib/tracker-application.module';
|
||||
|
||||
@@ -60,6 +60,11 @@ export interface CrossProjectIssueFilter {
|
||||
watcherId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Project id → the only type ids taken there. */
|
||||
typesByProject?: Record<string, string[]>;
|
||||
/** Due before this day (`YYYY-MM-DD`, excluded). */
|
||||
dueBefore?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Saved views of «my work»: one person's preferences, no history. */
|
||||
export abstract class MyViewsRepository {
|
||||
/** By name. */
|
||||
abstract findByOwner(ownerId: string): Promise<MyViewReadModel[]>;
|
||||
abstract findById(id: string): Promise<MyViewReadModel | null>;
|
||||
abstract countByOwner(ownerId: string): Promise<number>;
|
||||
abstract insert(view: MyViewReadModel): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { SearchMyIssuesQuery } from './search-my-issues.query';
|
||||
@@ -15,13 +19,43 @@ export class SearchMyIssuesQueryHandler
|
||||
|
||||
/** Membership is the access rule: only projects the viewer belongs to. */
|
||||
async execute(query: SearchMyIssuesQuery): Promise<Page<IssueReadModel>> {
|
||||
const projects = await this.findProjects.findByMember(query.viewerId);
|
||||
const { filter } = query;
|
||||
const projects = (
|
||||
await this.findProjects.findByMember(query.viewerId)
|
||||
).filter(
|
||||
(p) => !filter.projectIds?.length || filter.projectIds.includes(p.id),
|
||||
);
|
||||
// Bugs are each project's types drawn with the bug icon.
|
||||
const bugTypes = filter.bugsOnly
|
||||
? Object.fromEntries(
|
||||
projects.map((p) => [
|
||||
p.id,
|
||||
(p.settings ?? DEFAULT_PROJECT_SETTINGS).issueTypes
|
||||
.filter((t) => t.icon === 'bug')
|
||||
.map((t) => t.id),
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
const open = [
|
||||
StatusCategory.TODO,
|
||||
StatusCategory.IN_PROGRESS,
|
||||
StatusCategory.QA,
|
||||
];
|
||||
return this.findIssues.searchAcross({
|
||||
projectIds: projects.map((project) => project.id),
|
||||
priorities: filter.priorities?.length ? filter.priorities : undefined,
|
||||
typesByProject: bugTypes,
|
||||
dueBefore: filter.overdue
|
||||
? new Date().toISOString().slice(0, 10)
|
||||
: undefined,
|
||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||
watcherId: query.filter.watching ? query.viewerId : undefined,
|
||||
statuses: query.filter.statuses,
|
||||
categories: query.filter.categories,
|
||||
categories: filter.overdue
|
||||
? (filter.categories?.length ? filter.categories : open).filter((c) =>
|
||||
open.includes(c),
|
||||
)
|
||||
: query.filter.categories,
|
||||
search: query.filter.search?.trim().slice(0, 200) || undefined,
|
||||
limit: Math.min(query.filter.limit, 200),
|
||||
offset: query.filter.offset,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Query } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -15,6 +16,13 @@ export interface MyIssuesFilter {
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
/** Only these of my projects. */
|
||||
projectIds?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Only tickets of a type drawn with the bug icon (in each project). */
|
||||
bugsOnly?: boolean;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,16 @@ import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
MyViewCriteriaReadModel,
|
||||
MyViewReadModel,
|
||||
SavedFilterCriteriaReadModel,
|
||||
SavedFilterReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
DeleteMyViewCommand,
|
||||
GetMyViewsQuery,
|
||||
SaveMyViewCommand,
|
||||
} from './saved-filters/my-views';
|
||||
import {
|
||||
DeleteSavedFilterCommand,
|
||||
GetSavedFiltersQuery,
|
||||
@@ -18,7 +25,7 @@ export interface SaveFilterInput {
|
||||
criteria: SavedFilterCriteriaReadModel;
|
||||
}
|
||||
|
||||
/** Saved board views: mine and the ones shared with the project. */
|
||||
/** Saved views: the board's (mine and shared) and my own of «my work». */
|
||||
@Injectable()
|
||||
export class SavedFiltersService {
|
||||
constructor(
|
||||
@@ -47,4 +54,22 @@ export class SavedFiltersService {
|
||||
new DeleteSavedFilterCommand(actorId, filterId),
|
||||
);
|
||||
}
|
||||
|
||||
/** My views of «my work», by name. */
|
||||
myViews(ownerId: string): Promise<MyViewReadModel[]> {
|
||||
return this.queryBus.execute(new GetMyViewsQuery(ownerId));
|
||||
}
|
||||
|
||||
saveMyView(
|
||||
ownerId: string,
|
||||
input: { name: string; criteria: MyViewCriteriaReadModel },
|
||||
): Promise<{ id: string }> {
|
||||
return this.commands.execute(
|
||||
new SaveMyViewCommand(ownerId, input.name, input.criteria),
|
||||
);
|
||||
}
|
||||
|
||||
deleteMyView(ownerId: string, viewId: string): Promise<void> {
|
||||
return this.commands.execute(new DeleteMyViewCommand(ownerId, viewId));
|
||||
}
|
||||
}
|
||||
|
||||
133
libs/api/tracker/application/src/lib/saved-filters/my-views.ts
Normal file
133
libs/api/tracker/application/src/lib/saved-filters/my-views.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
MyViewCriteriaReadModel,
|
||||
MyViewReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { MyViewsRepository } from '../ports/my-views.repository';
|
||||
|
||||
/*
|
||||
* Saved views of «my work»: across my projects, private, no history (like
|
||||
* the board's saved views and notification settings).
|
||||
*/
|
||||
|
||||
/** A person keeps at most this many. */
|
||||
export const MAX_MY_VIEWS = 20;
|
||||
|
||||
export class SaveMyViewCommand extends Command<{ id: string }> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly name: string,
|
||||
public readonly criteria: MyViewCriteriaReadModel,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteMyViewCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly viewId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMyViewsQuery extends Query<MyViewReadModel[]> {
|
||||
constructor(public readonly ownerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops empty criteria, trims the text and deduplicates lists. */
|
||||
export function normalizeMyView(
|
||||
criteria: MyViewCriteriaReadModel,
|
||||
): MyViewCriteriaReadModel {
|
||||
const list = <T extends string>(values?: T[] | null) =>
|
||||
values?.length ? [...new Set(values)] : undefined;
|
||||
const out: MyViewCriteriaReadModel = {
|
||||
projectIds: list(criteria.projectIds),
|
||||
assignedToMe: criteria.assignedToMe || undefined,
|
||||
watching: criteria.watching || undefined,
|
||||
categories: list(criteria.categories),
|
||||
priorities: list(criteria.priorities),
|
||||
bugsOnly: criteria.bugsOnly || undefined,
|
||||
overdue: criteria.overdue || undefined,
|
||||
search: criteria.search?.trim().slice(0, 200) || undefined,
|
||||
};
|
||||
return Object.fromEntries(
|
||||
Object.entries(out).filter(([, value]) => value !== undefined),
|
||||
) as MyViewCriteriaReadModel;
|
||||
}
|
||||
|
||||
@CommandHandler(SaveMyViewCommand)
|
||||
export class SaveMyViewCommandHandler
|
||||
implements ICommandHandler<SaveMyViewCommand>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
async execute(command: SaveMyViewCommand): Promise<{ id: string }> {
|
||||
const name = command.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 60,
|
||||
'A view needs a name (up to 60 characters)',
|
||||
);
|
||||
if ((await this.views.countByOwner(command.ownerId)) >= MAX_MY_VIEWS)
|
||||
throw new BusinessRuleError(
|
||||
`You already keep ${MAX_MY_VIEWS} views; delete one first`,
|
||||
);
|
||||
const id = randomUUID();
|
||||
await this.views.insert({
|
||||
id,
|
||||
ownerId: command.ownerId,
|
||||
name,
|
||||
criteria: normalizeMyView(command.criteria),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteMyViewCommand)
|
||||
export class DeleteMyViewCommandHandler
|
||||
implements ICommandHandler<DeleteMyViewCommand>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
async execute(command: DeleteMyViewCommand): Promise<void> {
|
||||
const view = await this.views.findById(command.viewId);
|
||||
// Someone else's view does not exist, as far as this person knows.
|
||||
if (!view || view.ownerId !== command.ownerId)
|
||||
throw new NotFoundError('View', command.viewId);
|
||||
await this.views.delete(view.id);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyViewsQuery)
|
||||
export class GetMyViewsQueryHandler
|
||||
implements IQueryHandler<GetMyViewsQuery, MyViewReadModel[]>
|
||||
{
|
||||
constructor(private readonly views: MyViewsRepository) {}
|
||||
|
||||
execute(query: GetMyViewsQuery): Promise<MyViewReadModel[]> {
|
||||
return this.views.findByOwner(query.ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
export const MY_VIEWS = [
|
||||
SaveMyViewCommandHandler,
|
||||
DeleteMyViewCommandHandler,
|
||||
GetMyViewsQueryHandler,
|
||||
];
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './notifications/notifications.commands';
|
||||
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
@@ -101,6 +102,7 @@ const SERVICES = [
|
||||
...SAGAS,
|
||||
...NOTIFICATIONS,
|
||||
...SAVED_FILTERS,
|
||||
...MY_VIEWS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IssuePriority } from '../issue/issue.enums';
|
||||
import { StatusCategory } from '../project/project-settings';
|
||||
import { UserRefReadModel } from './user-ref.read-model';
|
||||
|
||||
/** What a saved view of the board narrows the tickets to. Empty: everything. */
|
||||
@@ -27,3 +28,31 @@ export class SavedFilterReadModel {
|
||||
criteria!: SavedFilterCriteriaReadModel;
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a view of «my work» narrows to, across the projects I belong to.
|
||||
* Empty: every ticket of them.
|
||||
*/
|
||||
export class MyViewCriteriaReadModel {
|
||||
/** Only these projects (empty: all of mine). */
|
||||
projectIds?: string[];
|
||||
assignedToMe?: boolean;
|
||||
/** Only the tickets I follow. */
|
||||
watching?: boolean;
|
||||
categories?: StatusCategory[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Only tickets of a type drawn with the bug icon. */
|
||||
bugsOnly?: boolean;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/** A named view of «my work», private to whoever saved it. */
|
||||
export class MyViewReadModel {
|
||||
id!: string;
|
||||
ownerId!: string;
|
||||
name!: string;
|
||||
criteria!: MyViewCriteriaReadModel;
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
@@ -172,14 +172,27 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
||||
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
||||
if (filter.categories?.length)
|
||||
query['statusCategory'] = { $in: filter.categories };
|
||||
if (filter.priorities?.length)
|
||||
query['priority'] = { $in: filter.priorities };
|
||||
if (filter.dueBefore)
|
||||
query['dueDate'] = { $ne: null, $lt: filter.dueBefore };
|
||||
const and: Record<string, unknown>[] = [];
|
||||
if (filter.typesByProject)
|
||||
and.push({
|
||||
$or: Object.entries(filter.typesByProject).map(
|
||||
([projectId, types]) => ({
|
||||
projectId,
|
||||
type: { $in: types },
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (filter.search?.trim()) {
|
||||
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
|
||||
query['$or'] = [
|
||||
{ key: pattern },
|
||||
{ title: pattern },
|
||||
{ description: pattern },
|
||||
];
|
||||
and.push({
|
||||
$or: [{ key: pattern }, { title: pattern }, { description: pattern }],
|
||||
});
|
||||
}
|
||||
if (and.length) query['$and'] = and;
|
||||
const [docs, total] = await Promise.all([
|
||||
this.issues
|
||||
.find(query)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { MyViewsRepository } from '@task-stream/api/tracker/application';
|
||||
import { MyViewReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { MyViewDoc, ReadModels, fromDoc } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoMyViewsRepository extends MyViewsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.MyView)
|
||||
private readonly views: Model<MyViewDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByOwner(ownerId: string): Promise<MyViewReadModel[]> {
|
||||
const docs = await this.views
|
||||
.find({ ownerId })
|
||||
.sort({ name: 1 })
|
||||
.lean<MyViewDoc[]>();
|
||||
return docs.map(toView);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<MyViewReadModel | null> {
|
||||
const doc = await this.views.findOne({ id }).lean<MyViewDoc>();
|
||||
return doc ? toView(doc) : null;
|
||||
}
|
||||
|
||||
countByOwner(ownerId: string): Promise<number> {
|
||||
return this.views.countDocuments({ ownerId });
|
||||
}
|
||||
|
||||
async insert(view: MyViewReadModel): Promise<void> {
|
||||
await this.views.create(view);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.views.deleteOne({ id });
|
||||
}
|
||||
}
|
||||
|
||||
/** Mongo leaves empty objects out: a view with no criteria has none. */
|
||||
function toView(doc: MyViewDoc): MyViewReadModel {
|
||||
const view = fromDoc(doc) as MyViewReadModel;
|
||||
return { ...view, criteria: view.criteria ?? {} };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
SavedFilterReadModel,
|
||||
MyViewReadModel,
|
||||
SprintReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -40,6 +41,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type MyViewDoc = Doc<MyViewReadModel>;
|
||||
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
|
||||
export type AutomationDoc = Doc<Automation>;
|
||||
export interface AutomationRunDoc {
|
||||
@@ -94,6 +96,7 @@ export const ReadModels = {
|
||||
Notification: 'NotificationReadModel',
|
||||
NotificationSettings: 'NotificationSettings',
|
||||
SavedFilter: 'SavedFilter',
|
||||
MyView: 'MyView',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
@@ -302,6 +305,15 @@ export const SavedFilterSchema =
|
||||
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'my_views' })
|
||||
class MyViewDocument {
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, index: true }) ownerId!: string;
|
||||
@Prop({ type: String }) name!: string;
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
}
|
||||
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@@ -390,6 +402,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
{ name: ReadModels.MyView, schema: MyViewSchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PushSubscriptionsRepository,
|
||||
PushSender,
|
||||
SavedFiltersRepository,
|
||||
MyViewsRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
AutomationsRepository,
|
||||
@@ -72,6 +73,7 @@ import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository'
|
||||
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
||||
import { WebPushSender } from './push/web-push.sender';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
@@ -143,6 +145,7 @@ const PORTS = [
|
||||
},
|
||||
{ provide: PushSender, useClass: WebPushSender },
|
||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
|
||||
@@ -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