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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './lib/facades/explore.facade';
|
||||
export * from './lib/facades/inbox.facade';
|
||||
export * from './lib/facades/epics.facade';
|
||||
export * from './lib/facades/insights.facade';
|
||||
export * from './lib/facades/my-views.facade';
|
||||
export * from './lib/facades/backlog.facade';
|
||||
export * from './lib/facades/list.facade';
|
||||
export * from './lib/facades/propose.facade';
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
IssuePage,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const myViewsPageEvents = eventGroup({
|
||||
source: 'My Work Views',
|
||||
events: {
|
||||
opened: type<void>(),
|
||||
/** null: back to «Mis tickets». */
|
||||
selected: type<string | null>(),
|
||||
saveRequested: type<{ name: string; criteria: MyViewCriteria }>(),
|
||||
deleteRequested: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const myViewsApiEvents = eventGroup({
|
||||
source: 'My Views API',
|
||||
events: {
|
||||
listed: type<MyView[]>(),
|
||||
listFailed: type<string>(),
|
||||
resultsLoaded: type<IssuePage>(),
|
||||
resultsFailed: type<string>(),
|
||||
saved: type<string>(),
|
||||
saveFailed: type<string>(),
|
||||
deleted: type<string>(),
|
||||
deleteFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { MyViewCriteria } from '@task-stream/web/tracker/domain';
|
||||
import { myViewsPageEvents } from '../events/my-views.events';
|
||||
import { MyViewsStore, VIEW_LIMIT } from '../stores/my-views.store';
|
||||
|
||||
/** My saved views of «my work», across my projects. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MyViewsFacade {
|
||||
private readonly store = inject(MyViewsStore);
|
||||
private readonly dispatch = injectDispatch(myViewsPageEvents);
|
||||
|
||||
/** By name. */
|
||||
readonly views = this.store.views;
|
||||
/** The one picked (null: «Mis tickets»). */
|
||||
readonly selected = this.store.selected;
|
||||
/** Its tickets, most recently updated first (up to `limit`). */
|
||||
readonly results = this.store.results;
|
||||
readonly total = this.store.total;
|
||||
readonly limit = VIEW_LIMIT;
|
||||
readonly loading = this.store.loading;
|
||||
readonly saving = this.store.saving;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads my views (and refreshes the one picked). */
|
||||
open(): void {
|
||||
this.dispatch.opened();
|
||||
}
|
||||
|
||||
select(viewId: string | null): void {
|
||||
this.dispatch.selected(viewId);
|
||||
}
|
||||
|
||||
save(name: string, criteria: MyViewCriteria): void {
|
||||
this.dispatch.saveRequested({ name, criteria });
|
||||
}
|
||||
|
||||
remove(viewId: string): void {
|
||||
this.dispatch.deleteRequested(viewId);
|
||||
}
|
||||
}
|
||||
180
libs/web/tracker/application/src/lib/stores/my-views.store.ts
Normal file
180
libs/web/tracker/application/src/lib/stores/my-views.store.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
MyIssuesFilter,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
OPEN_CATEGORIES,
|
||||
SavedFiltersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { filter, map, merge, mergeMap, switchMap, take, tap } from 'rxjs';
|
||||
import { myViewsApiEvents, myViewsPageEvents } from '../events/my-views.events';
|
||||
import { describeError, Notifications } from '../notifications';
|
||||
|
||||
/** Tickets a view lists at most. */
|
||||
export const VIEW_LIMIT = 100;
|
||||
|
||||
/** A view's criteria as the «my work» search; no statuses: the open ones. */
|
||||
export function viewFilter(criteria: MyViewCriteria): MyIssuesFilter {
|
||||
return {
|
||||
assignedToMe: !!criteria.assignedToMe,
|
||||
watching: !!criteria.watching,
|
||||
categories: criteria.categories?.length
|
||||
? criteria.categories
|
||||
: [...OPEN_CATEGORIES],
|
||||
priorities: criteria.priorities ?? undefined,
|
||||
projectIds: criteria.projectIds ?? undefined,
|
||||
bugsOnly: !!criteria.bugsOnly,
|
||||
overdue: !!criteria.overdue,
|
||||
search: criteria.search ?? undefined,
|
||||
limit: VIEW_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
interface MyViewsState {
|
||||
views: MyView[];
|
||||
selectedId: string | null;
|
||||
results: IssueSummary[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** My saved views of «my work» and the tickets of the one picked. Root-scoped. */
|
||||
export const MyViewsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<MyViewsState>({
|
||||
views: [],
|
||||
selectedId: null,
|
||||
results: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed((store) => ({
|
||||
selected: computed(
|
||||
() => store.views().find((v) => v.id === store.selectedId()) ?? null,
|
||||
),
|
||||
})),
|
||||
withReducer(
|
||||
on(myViewsApiEvents.listed, ({ payload }, state) => ({
|
||||
views: payload,
|
||||
// A view deleted elsewhere is no longer picked.
|
||||
selectedId: payload.some((v) => v.id === state.selectedId)
|
||||
? state.selectedId
|
||||
: null,
|
||||
})),
|
||||
on(myViewsPageEvents.selected, ({ payload }) => ({
|
||||
selectedId: payload,
|
||||
results: [],
|
||||
total: 0,
|
||||
loading: payload !== null,
|
||||
error: null,
|
||||
})),
|
||||
on(myViewsApiEvents.resultsLoaded, ({ payload }) => ({
|
||||
results: payload.items,
|
||||
total: payload.total,
|
||||
loading: false,
|
||||
})),
|
||||
on(myViewsApiEvents.resultsFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
on(myViewsPageEvents.saveRequested, () => ({ saving: true })),
|
||||
on(myViewsApiEvents.saved, () => ({ saving: false })),
|
||||
on(myViewsApiEvents.saveFailed, () => ({ saving: false })),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
filters = inject(SavedFiltersGateway),
|
||||
issues = inject(IssuesGateway),
|
||||
notifications = inject(Notifications),
|
||||
) => ({
|
||||
list$: merge(
|
||||
events.on(myViewsPageEvents.opened),
|
||||
events.on(myViewsApiEvents.saved, myViewsApiEvents.deleted),
|
||||
).pipe(
|
||||
switchMap(() =>
|
||||
filters.myViews().pipe(
|
||||
mapResponse({
|
||||
next: (views) => myViewsApiEvents.listed(views),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.listFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The picked view's tickets: on picking it, and again on opening.
|
||||
results$: merge(
|
||||
events
|
||||
.on(myViewsPageEvents.selected)
|
||||
.pipe(map(({ payload }) => payload)),
|
||||
events.on(myViewsPageEvents.opened).pipe(map(() => store.selectedId())),
|
||||
).pipe(
|
||||
filter((id): id is string => !!id),
|
||||
switchMap((id) => {
|
||||
const view = store.views().find((v) => v.id === id);
|
||||
if (!view) return [];
|
||||
return issues.mine(viewFilter(view.criteria)).pipe(
|
||||
mapResponse({
|
||||
next: (page) => myViewsApiEvents.resultsLoaded(page),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.resultsFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
save$: events.on(myViewsPageEvents.saveRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
filters.saveMyView(payload).pipe(
|
||||
mapResponse({
|
||||
next: (id) => myViewsApiEvents.saved(id),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.saveFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Once listed again, the new view is the one shown.
|
||||
selectSaved$: events.on(myViewsApiEvents.saved).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
events.on(myViewsApiEvents.listed).pipe(
|
||||
take(1),
|
||||
map(() => myViewsPageEvents.selected(payload)),
|
||||
),
|
||||
),
|
||||
),
|
||||
delete$: events.on(myViewsPageEvents.deleteRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
filters.removeMyView(payload).pipe(
|
||||
mapResponse({
|
||||
next: () => myViewsApiEvents.deleted(payload),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.deleteFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
feedback$: events
|
||||
.on(
|
||||
myViewsApiEvents.saveFailed,
|
||||
myViewsApiEvents.deleteFailed,
|
||||
myViewsApiEvents.listFailed,
|
||||
)
|
||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -122,6 +122,12 @@ export interface MyIssuesFilter {
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
projectIds?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Only types drawn with the bug icon. */
|
||||
bugsOnly?: boolean;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,29 @@ export interface SavedFilterCriteria {
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
/** What a view of «my work» narrows to, across my projects. */
|
||||
export interface MyViewCriteria {
|
||||
/** Only these projects (empty: all of mine). */
|
||||
projectIds?: string[] | null;
|
||||
assignedToMe?: boolean | null;
|
||||
watching?: boolean | null;
|
||||
categories?: StatusCategory[] | null;
|
||||
priorities?: IssuePriority[] | null;
|
||||
/** Only types drawn with the bug icon. */
|
||||
bugsOnly?: boolean | null;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean | null;
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
/** A named view of «my work», mine only. */
|
||||
export interface MyView {
|
||||
id: string;
|
||||
name: string;
|
||||
criteria: MyViewCriteria;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A named view of the board: private, or shared with the project. */
|
||||
export interface SavedFilter {
|
||||
id: string;
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
SavedFilter,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
SavedFilterCriteria,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
@@ -243,6 +245,13 @@ export abstract class SavedFiltersGateway {
|
||||
criteria: SavedFilterCriteria;
|
||||
}): Observable<string>;
|
||||
abstract remove(id: string): Observable<void>;
|
||||
/** My views of «my work», by name. */
|
||||
abstract myViews(): Observable<MyView[]>;
|
||||
abstract saveMyView(input: {
|
||||
name: string;
|
||||
criteria: MyViewCriteria;
|
||||
}): Observable<string>;
|
||||
abstract removeMyView(id: string): Observable<void>;
|
||||
}
|
||||
|
||||
export abstract class CommentsGateway {
|
||||
|
||||
@@ -102,6 +102,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
categories: filter.categories as never,
|
||||
limit: filter.limit,
|
||||
search: filter.search?.trim() || null,
|
||||
projectIds: filter.projectIds?.length ? filter.projectIds : null,
|
||||
priorities: filter.priorities?.length
|
||||
? (filter.priorities as never)
|
||||
: null,
|
||||
bugsOnly: filter.bugsOnly ?? null,
|
||||
overdue: filter.overdue ?? null,
|
||||
})
|
||||
.pipe(
|
||||
map((d) => ({
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
SavedFilter,
|
||||
SavedFilterCriteria,
|
||||
SavedFiltersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
DeleteMyViewDocument,
|
||||
MyViewsDocument,
|
||||
SaveMyViewDocument,
|
||||
DeleteSavedFilterDocument,
|
||||
SavedFiltersDocument,
|
||||
SaveFilterDocument,
|
||||
@@ -38,4 +43,25 @@ export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
|
||||
.mutate(DeleteSavedFilterDocument, { id })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
myViews(): Observable<MyView[]> {
|
||||
return this.gql
|
||||
.query(MyViewsDocument, {})
|
||||
.pipe(map((d) => d.myViews as MyView[]));
|
||||
}
|
||||
|
||||
saveMyView(input: {
|
||||
name: string;
|
||||
criteria: MyViewCriteria;
|
||||
}): Observable<string> {
|
||||
return this.gql
|
||||
.mutate(SaveMyViewDocument, { input: input as never })
|
||||
.pipe(map((d) => d.saveMyView));
|
||||
}
|
||||
|
||||
removeMyView(id: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(DeleteMyViewDocument, { id })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,18 @@ export type LinkIssuesInput = {
|
||||
type: IssueLinkType;
|
||||
};
|
||||
|
||||
export type MyViewCriteriaInput = {
|
||||
assignedToMe?: boolean | null | undefined;
|
||||
bugsOnly?: boolean | null | undefined;
|
||||
categories?: Array<StatusCategory> | null | undefined;
|
||||
overdue?: boolean | null | undefined;
|
||||
priorities?: Array<IssuePriority> | null | undefined;
|
||||
/** Empty: all my projects */
|
||||
projectIds?: Array<string> | null | undefined;
|
||||
search?: string | null | undefined;
|
||||
watching?: boolean | null | undefined;
|
||||
};
|
||||
|
||||
/** Why someone is told */
|
||||
export type NotificationKind =
|
||||
| 'ADDED_TO_PROJECT'
|
||||
@@ -293,6 +305,11 @@ export type SaveFilterInput = {
|
||||
shared?: boolean;
|
||||
};
|
||||
|
||||
export type SaveMyViewInput = {
|
||||
criteria: MyViewCriteriaInput;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SavedFilterCriteriaInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
@@ -1023,6 +1040,10 @@ export type MyIssuesQueryVariables = Exact<{
|
||||
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
|
||||
limit: number;
|
||||
search?: string | null | undefined;
|
||||
projectIds?: Array<string> | string | null | undefined;
|
||||
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
|
||||
bugsOnly?: boolean | null | undefined;
|
||||
overdue?: boolean | null | undefined;
|
||||
}>;
|
||||
|
||||
export type MyIssuesQuery = {
|
||||
@@ -2117,6 +2138,38 @@ export type DeleteSavedFilterMutationVariables = Exact<{
|
||||
|
||||
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
|
||||
|
||||
export type MyViewsQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyViewsQuery = {
|
||||
myViews: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
criteria: {
|
||||
projectIds: Array<string> | null;
|
||||
assignedToMe: boolean | null;
|
||||
watching: boolean | null;
|
||||
categories: Array<StatusCategory> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
bugsOnly: boolean | null;
|
||||
overdue: boolean | null;
|
||||
search: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SaveMyViewMutationVariables = Exact<{
|
||||
input: SaveMyViewInput;
|
||||
}>;
|
||||
|
||||
export type SaveMyViewMutation = { saveMyView: string };
|
||||
|
||||
export type DeleteMyViewMutationVariables = Exact<{
|
||||
id: string;
|
||||
}>;
|
||||
|
||||
export type DeleteMyViewMutation = { deleteMyView: boolean };
|
||||
|
||||
export type SprintFieldsFragment = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -6604,6 +6657,53 @@ export const MyIssuesDocument = {
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
},
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
},
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'IssuePriority' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -6652,6 +6752,38 @@ export const MyIssuesDocument = {
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -12637,6 +12769,161 @@ export const DeleteSavedFilterDocument = {
|
||||
DeleteSavedFilterMutation,
|
||||
DeleteSavedFilterMutationVariables
|
||||
>;
|
||||
export const MyViewsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'MyViews' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'myViews' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'criteria' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignedToMe' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'watching' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'categories' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyViewsQuery, MyViewsQueryVariables>;
|
||||
export const SaveMyViewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'SaveMyView' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'SaveMyViewInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'saveMyView' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<SaveMyViewMutation, SaveMyViewMutationVariables>;
|
||||
export const DeleteMyViewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'DeleteMyView' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'deleteMyView' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
DeleteMyViewMutation,
|
||||
DeleteMyViewMutationVariables
|
||||
>;
|
||||
export const SprintsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -136,6 +136,10 @@ query MyIssues(
|
||||
$categories: [StatusCategory!]
|
||||
$limit: Int!
|
||||
$search: String
|
||||
$projectIds: [ID!]
|
||||
$priorities: [IssuePriority!]
|
||||
$bugsOnly: Boolean
|
||||
$overdue: Boolean
|
||||
) {
|
||||
myIssues(
|
||||
assignedToMe: $assignedToMe
|
||||
@@ -143,6 +147,10 @@ query MyIssues(
|
||||
categories: $categories
|
||||
limit: $limit
|
||||
search: $search
|
||||
projectIds: $projectIds
|
||||
priorities: $priorities
|
||||
bugsOnly: $bugsOnly
|
||||
overdue: $overdue
|
||||
) {
|
||||
total
|
||||
items {
|
||||
|
||||
@@ -32,3 +32,29 @@ mutation SaveFilter($input: SaveFilterInput!) {
|
||||
mutation DeleteSavedFilter($id: ID!) {
|
||||
deleteSavedFilter(id: $id)
|
||||
}
|
||||
|
||||
query MyViews {
|
||||
myViews {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
criteria {
|
||||
projectIds
|
||||
assignedToMe
|
||||
watching
|
||||
categories
|
||||
priorities
|
||||
bugsOnly
|
||||
overdue
|
||||
search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation SaveMyView($input: SaveMyViewInput!) {
|
||||
saveMyView(input: $input)
|
||||
}
|
||||
|
||||
mutation DeleteMyView($id: ID!) {
|
||||
deleteMyView(id: $id)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
import { CalendarFeedComponent } from '../ui/calendar-feed';
|
||||
import { MyViewsComponent } from '../ui/my-views';
|
||||
|
||||
const VISIBLE = 12;
|
||||
|
||||
@@ -49,6 +50,7 @@ const VISIBLE = 12;
|
||||
PiIconComponent,
|
||||
PiSkeletonComponent,
|
||||
CalendarFeedComponent,
|
||||
MyViewsComponent,
|
||||
],
|
||||
template: `
|
||||
@if (!home.loaded()) {
|
||||
@@ -60,6 +62,7 @@ const VISIBLE = 12;
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<ts-my-views />
|
||||
<div class="ts-work">
|
||||
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
|
||||
<header class="ts-work__head">
|
||||
|
||||
481
libs/web/tracker/presenters/src/lib/ui/my-views.ts
Normal file
481
libs/web/tracker/presenters/src/lib/ui/my-views.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
MyViewsFacade,
|
||||
ProjectsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
MyViewCriteria,
|
||||
PRIORITY_ORDER,
|
||||
StatusCategory,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
CATEGORY,
|
||||
PRIORITY,
|
||||
projectKeyOf,
|
||||
projectTone,
|
||||
statusLook,
|
||||
typeLook,
|
||||
} from './copy';
|
||||
|
||||
const CATEGORIES = Object.values(StatusCategory);
|
||||
|
||||
/** Toggles a value in a list kept as a signal. */
|
||||
function toggle<T>(
|
||||
list: ReturnType<typeof signal<T[]>>,
|
||||
value: T,
|
||||
on: boolean,
|
||||
) {
|
||||
list.update((values) =>
|
||||
on ? [...new Set([...values, value])] : values.filter((v) => v !== value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* My saved views of «my work», across my projects: a bar to pick one, its
|
||||
* tickets, and the drawer to save a new one.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-my-views',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<nav class="ts-views" aria-label="Mis vistas">
|
||||
<span class="ts-views__label">Vistas</span>
|
||||
@for (view of views.views(); track view.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-views__chip"
|
||||
[class.is-on]="views.selected()?.id === view.id"
|
||||
[attr.aria-pressed]="views.selected()?.id === view.id"
|
||||
(click)="
|
||||
views.select(views.selected()?.id === view.id ? null : view.id)
|
||||
"
|
||||
>
|
||||
{{ view.name }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="ts-views__new" (click)="openForm()">
|
||||
+ Nueva vista
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (views.selected(); as view) {
|
||||
<section class="ts-panel ts-views__panel" aria-labelledby="view-title">
|
||||
<header class="ts-views__head">
|
||||
<h2 id="view-title">{{ view.name }}</h2>
|
||||
<span class="ts-muted">{{ summary() }}</span>
|
||||
<span class="ts-muted ts-mono">{{ views.total() }}</span>
|
||||
<span class="ts-spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="views.remove(view.id)"
|
||||
>
|
||||
Borrar vista
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="views.select(null)"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</header>
|
||||
@if (views.loading()) {
|
||||
<p class="ts-muted">Cargando…</p>
|
||||
} @else if (views.error(); as error) {
|
||||
<p class="ts-muted">{{ error }}</p>
|
||||
} @else {
|
||||
<ul class="ts-views__list">
|
||||
@for (issue of views.results(); track issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-views__row"
|
||||
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||
[style.--type]="typeTone(issue)"
|
||||
>
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-views__title"
|
||||
><span
|
||||
class="ts-views__status"
|
||||
[style.--tone]="statusTone(issue)"
|
||||
>{{ statusLabel(issue) }}</span
|
||||
>
|
||||
{{ issue.title }}</span
|
||||
>
|
||||
<span class="ts-views__who">{{
|
||||
issue.assignee?.name ?? 'Sin asignar'
|
||||
}}</span>
|
||||
<span
|
||||
class="ts-views__project"
|
||||
[style.--tone]="projectToneOf(issue.key)"
|
||||
>{{ keyOf(issue.key) }}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="ts-muted">Ningún ticket encaja ahora mismo.</li>
|
||||
}
|
||||
</ul>
|
||||
@if (views.total() > views.results().length) {
|
||||
<p class="ts-muted ts-views__more">
|
||||
Se muestran {{ views.results().length }} de {{ views.total() }}.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<ng-template #form>
|
||||
<form
|
||||
class="ts-form ts-views__form"
|
||||
novalidate
|
||||
(submit)="$event.preventDefault(); save()"
|
||||
>
|
||||
<pi-text-field [(value)]="name" placeholder="Bugs críticos">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
<fieldset>
|
||||
<legend>Proyectos <span>(ninguno: todos los míos)</span></legend>
|
||||
@for (project of projects.sorted(); track project.id) {
|
||||
<pi-checkbox
|
||||
[label]="project.key + ' · ' + project.name"
|
||||
[checked]="projectIds().includes(project.id)"
|
||||
(checkedChange)="pick(projectIds, project.id, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Quién</legend>
|
||||
<pi-checkbox
|
||||
label="Asignados a mí"
|
||||
[checked]="assignedToMe()"
|
||||
(checkedChange)="assignedToMe.set($event)"
|
||||
/>
|
||||
<pi-checkbox
|
||||
label="Que sigo"
|
||||
[checked]="watching()"
|
||||
(checkedChange)="watching.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Estado <span>(ninguno: los abiertos)</span></legend>
|
||||
@for (category of categories; track category) {
|
||||
<pi-checkbox
|
||||
[label]="categoryLabel(category)"
|
||||
[checked]="categoriesPicked().includes(category)"
|
||||
(checkedChange)="pick(categoriesPicked, category, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prioridad <span>(ninguna: todas)</span></legend>
|
||||
@for (priority of priorities; track priority) {
|
||||
<pi-checkbox
|
||||
[label]="priorityLabel(priority)"
|
||||
[checked]="prioritiesPicked().includes(priority)"
|
||||
(checkedChange)="pick(prioritiesPicked, priority, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Además</legend>
|
||||
<pi-checkbox
|
||||
label="Solo bugs"
|
||||
[checked]="bugsOnly()"
|
||||
(checkedChange)="bugsOnly.set($event)"
|
||||
/>
|
||||
<pi-checkbox
|
||||
label="Solo vencidos"
|
||||
[checked]="overdue()"
|
||||
(checkedChange)="overdue.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<pi-text-field [(value)]="search" placeholder="pago, Safari…">
|
||||
<pi-label>Texto (clave, título o descripción)</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="views.saving() ? 'Guardando…' : 'Guardar vista'"
|
||||
[disabled]="!name().trim() || views.saving()"
|
||||
(execute)="save()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ts-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ts-views__label {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-views__chip,
|
||||
.ts-views__new {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 999px;
|
||||
background: var(--ts-surface);
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-views__chip.is-on {
|
||||
border-color: var(--ts-brand);
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, var(--ts-surface));
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-views__new {
|
||||
border-style: dashed;
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
.ts-views__panel {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.ts-views__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.ts-views__list {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-views__row {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 9rem) 3.5rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-left: 3px solid var(--type, var(--ts-line));
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background: var(--pi-color-primary-25);
|
||||
}
|
||||
}
|
||||
.ts-views__title,
|
||||
.ts-views__who {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-views__who {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-views__status {
|
||||
margin-right: 0.375rem;
|
||||
color: var(--tone);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-views__project {
|
||||
justify-self: end;
|
||||
color: var(--tone);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-views__more {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-views__form fieldset {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.ts-views__form fieldset + pi-text-field {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ts-views__form legend {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
span {
|
||||
color: var(--ts-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ts-views__row {
|
||||
grid-template-columns: 5rem minmax(0, 1fr) 3rem;
|
||||
}
|
||||
.ts-views__who {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MyViewsComponent implements OnInit {
|
||||
protected readonly views = inject(MyViewsFacade);
|
||||
protected readonly projects = inject(ProjectsFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly form = viewChild.required<TemplateRef<unknown>>('form');
|
||||
|
||||
protected readonly categories = CATEGORIES;
|
||||
protected readonly priorities = PRIORITY_ORDER;
|
||||
|
||||
/* The form */
|
||||
protected readonly name = signal('');
|
||||
protected readonly projectIds = signal<string[]>([]);
|
||||
protected readonly assignedToMe = signal(false);
|
||||
protected readonly watching = signal(false);
|
||||
protected readonly categoriesPicked = signal<StatusCategory[]>([]);
|
||||
protected readonly prioritiesPicked = signal<IssuePriority[]>([]);
|
||||
protected readonly bugsOnly = signal(false);
|
||||
protected readonly overdue = signal(false);
|
||||
protected readonly search = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.views.open();
|
||||
}
|
||||
|
||||
protected readonly pick = toggle;
|
||||
|
||||
protected openForm(): void {
|
||||
this.name.set('');
|
||||
this.projectIds.set([]);
|
||||
this.assignedToMe.set(false);
|
||||
this.watching.set(false);
|
||||
this.categoriesPicked.set([]);
|
||||
this.prioritiesPicked.set([]);
|
||||
this.bugsOnly.set(false);
|
||||
this.overdue.set(false);
|
||||
this.search.set('');
|
||||
this.drawer.open('Nueva vista de mi trabajo', this.form());
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
const name = this.name().trim();
|
||||
if (!name) return;
|
||||
const criteria: MyViewCriteria = {
|
||||
projectIds: this.projectIds(),
|
||||
assignedToMe: this.assignedToMe(),
|
||||
watching: this.watching(),
|
||||
categories: this.categoriesPicked(),
|
||||
priorities: this.prioritiesPicked(),
|
||||
bugsOnly: this.bugsOnly(),
|
||||
overdue: this.overdue(),
|
||||
search: this.search().trim() || null,
|
||||
};
|
||||
this.views.save(name, criteria);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
/** What the picked view narrows to, in words. */
|
||||
protected readonly summary = computed(() => {
|
||||
const c = this.views.selected()?.criteria;
|
||||
if (!c) return '';
|
||||
const keys = (c.projectIds ?? [])
|
||||
.map((id) => this.projects.projects().find((p) => p.id === id)?.key)
|
||||
.filter(Boolean);
|
||||
return [
|
||||
keys.length ? keys.join(', ') : 'todos mis proyectos',
|
||||
c.assignedToMe ? 'asignados a mí' : null,
|
||||
c.watching ? 'que sigo' : null,
|
||||
c.categories?.length
|
||||
? c.categories.map((x) => CATEGORY[x].label.toLowerCase()).join('/')
|
||||
: 'abiertos',
|
||||
c.priorities?.length
|
||||
? c.priorities.map((p) => PRIORITY[p].label.toLowerCase()).join('/')
|
||||
: null,
|
||||
c.bugsOnly ? 'solo bugs' : null,
|
||||
c.overdue ? 'vencidos' : null,
|
||||
c.search ? `«${c.search}»` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
|
||||
protected readonly priorityLabel = (p: IssuePriority) => PRIORITY[p].label;
|
||||
protected readonly keyOf = projectKeyOf;
|
||||
protected readonly projectToneOf = (issueKey: string) =>
|
||||
projectTone(projectKeyOf(issueKey));
|
||||
|
||||
/** Each ticket with its own project's names and colours. */
|
||||
private readonly workflows = computed(
|
||||
() =>
|
||||
new Map(
|
||||
this.projects
|
||||
.projects()
|
||||
.map((p) => [p.id, new Workflow(p.settings)] as const),
|
||||
),
|
||||
);
|
||||
private workflowOf(issue: IssueSummary): Workflow {
|
||||
return this.workflows().get(issue.projectId) ?? new Workflow();
|
||||
}
|
||||
protected readonly statusLabel = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).label;
|
||||
protected readonly statusTone = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).tone;
|
||||
protected readonly typeTone = (issue: IssueSummary) =>
|
||||
typeLook(this.workflowOf(issue), issue.type).tone;
|
||||
}
|
||||
Reference in New Issue
Block a user