feat: pinned tickets, at hand on the home page and in search
Each person pins up to 20 tickets from the ticket's page («Fijar»). They
show as a strip under the projects on the home page and in the search
palette before typing. Private and without history, like the views of
«my work» (Mongo `pinned_issues`); tickets in the trash or out of reach
are left out, and when the list is full the unreadable ones are dropped
first.
API: GET /api/me/pins, PUT/DELETE /api/issues/{issue}/pin; GraphQL
myPins, pinIssue, unpinIssue and Issue.pinned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@ 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/saved-filters/pins';
|
||||
export * from './lib/ports/pins.repository';
|
||||
export * from './lib/pins.service';
|
||||
export * from './lib/tracker-application.module';
|
||||
export * from './lib/ports/presence.store';
|
||||
export * from './lib/presence.service';
|
||||
|
||||
31
libs/api/tracker/application/src/lib/pins.service.ts
Normal file
31
libs/api/tracker/application/src/lib/pins.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
GetMyPinsQuery,
|
||||
IsIssuePinnedQuery,
|
||||
PinIssueCommand,
|
||||
} from './saved-filters/pins';
|
||||
|
||||
/** Pinned tickets: the few someone keeps at hand, across projects. */
|
||||
@Injectable()
|
||||
export class PinsService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
/** Mine, most recently pinned first. */
|
||||
mine(ownerId: string): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetMyPinsQuery(ownerId));
|
||||
}
|
||||
|
||||
isPinned(ownerId: string, issueId: string): Promise<boolean> {
|
||||
return this.queryBus.execute(new IsIssuePinnedQuery(ownerId, issueId));
|
||||
}
|
||||
|
||||
pin(ownerId: string, issueId: string, pinned: boolean): Promise<void> {
|
||||
return this.commands.execute(new PinIssueCommand(ownerId, issueId, pinned));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Pinned tickets: one person's shortcuts, no history. */
|
||||
export abstract class PinsRepository {
|
||||
/** Most recently pinned first. */
|
||||
abstract findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]>;
|
||||
abstract isPinned(ownerId: string, issueId: string): Promise<boolean>;
|
||||
abstract countByOwner(ownerId: string): Promise<number>;
|
||||
/** Pinning again keeps the first date. */
|
||||
abstract pin(pin: PinnedIssueReadModel): Promise<void>;
|
||||
abstract unpin(ownerId: string, issueId: string): Promise<void>;
|
||||
}
|
||||
142
libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts
Normal file
142
libs/api/tracker/application/src/lib/saved-filters/pins.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
CommandBus,
|
||||
CqrsModule,
|
||||
IQueryHandler,
|
||||
QueryBus,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
IssueReadModel,
|
||||
PinnedIssueReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { PinsRepository } from '../ports/pins.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
import {
|
||||
GetMyPinsQuery,
|
||||
IsIssuePinnedQuery,
|
||||
MAX_PINS,
|
||||
PINS,
|
||||
PinIssueCommand,
|
||||
} from './pins';
|
||||
|
||||
class InMemoryPins extends PinsRepository {
|
||||
rows: PinnedIssueReadModel[] = [];
|
||||
async findByOwner(ownerId: string) {
|
||||
return this.rows
|
||||
.filter((p) => p.ownerId === ownerId)
|
||||
.sort((a, b) => b.pinnedAt.localeCompare(a.pinnedAt));
|
||||
}
|
||||
async isPinned(ownerId: string, issueId: string) {
|
||||
return this.rows.some(
|
||||
(p) => p.ownerId === ownerId && p.issueId === issueId,
|
||||
);
|
||||
}
|
||||
async countByOwner(ownerId: string) {
|
||||
return this.rows.filter((p) => p.ownerId === ownerId).length;
|
||||
}
|
||||
async pin(pin: PinnedIssueReadModel) {
|
||||
if (!(await this.isPinned(pin.ownerId, pin.issueId))) this.rows.push(pin);
|
||||
}
|
||||
async unpin(ownerId: string, issueId: string) {
|
||||
this.rows = this.rows.filter(
|
||||
(p) => !(p.ownerId === ownerId && p.issueId === issueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** What each person can read: `ana` all but `hidden`, in the trash `old`. */
|
||||
const issues = new Map<string, IssueReadModel>();
|
||||
const readable = (viewerId: string | null, id: string) =>
|
||||
viewerId === 'ana' && id !== 'hidden';
|
||||
|
||||
@QueryHandler(GetIssuesByIdsQuery)
|
||||
class FakeIssuesByIds implements IQueryHandler<GetIssuesByIdsQuery> {
|
||||
async execute(query: GetIssuesByIdsQuery) {
|
||||
return query.ids
|
||||
.filter((id) => readable(query.viewerId, id))
|
||||
.map(
|
||||
(id) =>
|
||||
issues.get(id) ??
|
||||
({
|
||||
id,
|
||||
trashedAt: id === 'old' ? '2026-01-01' : null,
|
||||
} as IssueReadModel),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const rows = new InMemoryPins();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...PINS,
|
||||
FakeIssuesByIds,
|
||||
{ provide: PinsRepository, useValue: rows },
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
return {
|
||||
rows,
|
||||
commands: moduleRef.get(CommandBus),
|
||||
queries: moduleRef.get(QueryBus),
|
||||
};
|
||||
}
|
||||
|
||||
describe('pinned tickets', () => {
|
||||
it('pins, lists most recent first and unpins', async () => {
|
||||
const { commands, queries } = await setup();
|
||||
await commands.execute(new PinIssueCommand('ana', 'a', true));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
await commands.execute(new PinIssueCommand('ana', 'b', true));
|
||||
// Twice is still once.
|
||||
await commands.execute(new PinIssueCommand('ana', 'b', true));
|
||||
const mine = await queries.execute(new GetMyPinsQuery('ana'));
|
||||
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['b', 'a']);
|
||||
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
|
||||
true,
|
||||
);
|
||||
await commands.execute(new PinIssueCommand('ana', 'a', false));
|
||||
expect(await queries.execute(new IsIssuePinnedQuery('ana', 'a'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pin what I cannot read, nor what is in the trash', async () => {
|
||||
const { commands } = await setup();
|
||||
await expect(
|
||||
commands.execute(new PinIssueCommand('ana', 'hidden', true)),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
await expect(
|
||||
commands.execute(new PinIssueCommand('ana', 'old', true)),
|
||||
).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('keeps a pin to a trashed ticket but does not list it', async () => {
|
||||
const { commands, queries, rows } = await setup();
|
||||
await commands.execute(new PinIssueCommand('ana', 'a', true));
|
||||
await rows.pin({ ownerId: 'ana', issueId: 'old', pinnedAt: '2026-01-02' });
|
||||
const mine = await queries.execute(new GetMyPinsQuery('ana'));
|
||||
expect(mine.map((i: IssueReadModel) => i.id)).toEqual(['a']);
|
||||
expect(rows.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it(`stops at ${MAX_PINS}, after forgetting the ones out of reach`, async () => {
|
||||
const { commands, rows } = await setup();
|
||||
for (let i = 0; i < MAX_PINS - 1; i++)
|
||||
await rows.pin({ ownerId: 'ana', issueId: `t${i}`, pinnedAt: `${i}` });
|
||||
await rows.pin({ ownerId: 'ana', issueId: 'hidden', pinnedAt: 'x' });
|
||||
// Full, but one is out of reach: it makes room.
|
||||
await commands.execute(new PinIssueCommand('ana', 'new', true));
|
||||
expect(await rows.isPinned('ana', 'hidden')).toBe(false);
|
||||
expect(await rows.countByOwner('ana')).toBe(MAX_PINS);
|
||||
await expect(
|
||||
commands.execute(new PinIssueCommand('ana', 'one-more', true)),
|
||||
).rejects.toBeInstanceOf(BusinessRuleError);
|
||||
});
|
||||
});
|
||||
150
libs/api/tracker/application/src/lib/saved-filters/pins.ts
Normal file
150
libs/api/tracker/application/src/lib/saved-filters/pins.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryBus,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { PinsRepository } from '../ports/pins.repository';
|
||||
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
|
||||
|
||||
/*
|
||||
* Pinned tickets: the few someone keeps at hand, on their home page and in
|
||||
* the search palette. Private, across projects, no history (like the views
|
||||
* of «my work»).
|
||||
*/
|
||||
|
||||
/** A person keeps at most this many. */
|
||||
export const MAX_PINS = 20;
|
||||
|
||||
export class PinIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly issueId: string,
|
||||
/** False: unpin. */
|
||||
public readonly pinned: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** My pinned tickets that I can still read and are not in the trash. */
|
||||
export class GetMyPinsQuery extends Query<IssueReadModel[]> {
|
||||
constructor(public readonly ownerId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class IsIssuePinnedQuery extends Query<boolean> {
|
||||
constructor(
|
||||
public readonly ownerId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(PinIssueCommand)
|
||||
export class PinIssueCommandHandler
|
||||
implements ICommandHandler<PinIssueCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly pins: PinsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(command: PinIssueCommand): Promise<void> {
|
||||
const { ownerId, issueId } = command;
|
||||
if (!command.pinned) return this.pins.unpin(ownerId, issueId);
|
||||
const [issue] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(ownerId, [issueId]),
|
||||
);
|
||||
// A ticket I cannot read does not exist, as far as I know.
|
||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||
if (await this.pins.isPinned(ownerId, issueId)) return;
|
||||
if (
|
||||
(await this.pins.countByOwner(ownerId)) >= MAX_PINS &&
|
||||
(await this.dropUnreadable(ownerId)) >= MAX_PINS
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
`You already keep ${MAX_PINS} pinned tickets; unpin one first`,
|
||||
);
|
||||
await this.pins.pin({
|
||||
ownerId,
|
||||
issueId,
|
||||
pinnedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the pins of tickets I can no longer read (I left the project,
|
||||
* it was deleted) so they do not hold a place; returns how many remain.
|
||||
*/
|
||||
private async dropUnreadable(ownerId: string): Promise<number> {
|
||||
const pins = await this.pins.findByOwner(ownerId);
|
||||
const readable = new Set(
|
||||
(
|
||||
await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(
|
||||
ownerId,
|
||||
pins.map((p) => p.issueId),
|
||||
),
|
||||
)
|
||||
).map((issue) => issue.id),
|
||||
);
|
||||
for (const pin of pins)
|
||||
if (!readable.has(pin.issueId))
|
||||
await this.pins.unpin(ownerId, pin.issueId);
|
||||
return readable.size;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyPinsQuery)
|
||||
export class GetMyPinsQueryHandler
|
||||
implements IQueryHandler<GetMyPinsQuery, IssueReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly pins: PinsRepository,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
async execute(query: GetMyPinsQuery): Promise<IssueReadModel[]> {
|
||||
const pins = await this.pins.findByOwner(query.ownerId);
|
||||
if (pins.length === 0) return [];
|
||||
const issues: IssueReadModel[] = await this.queryBus.execute(
|
||||
new GetIssuesByIdsQuery(
|
||||
query.ownerId,
|
||||
pins.map((p) => p.issueId),
|
||||
),
|
||||
);
|
||||
// In the trash or out of reach: kept, in case it comes back.
|
||||
const byId = new Map(
|
||||
issues.filter((i) => !i.trashedAt).map((i) => [i.id, i]),
|
||||
);
|
||||
return pins.flatMap((p) => byId.get(p.issueId) ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(IsIssuePinnedQuery)
|
||||
export class IsIssuePinnedQueryHandler
|
||||
implements IQueryHandler<IsIssuePinnedQuery, boolean>
|
||||
{
|
||||
constructor(private readonly pins: PinsRepository) {}
|
||||
|
||||
execute(query: IsIssuePinnedQuery): Promise<boolean> {
|
||||
return this.pins.isPinned(query.ownerId, query.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
export const PINS = [
|
||||
PinIssueCommandHandler,
|
||||
GetMyPinsQueryHandler,
|
||||
IsIssuePinnedQueryHandler,
|
||||
];
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { MY_VIEWS } from './saved-filters/my-views';
|
||||
import { PINS } from './saved-filters/pins';
|
||||
import { PinsService } from './pins.service';
|
||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||
import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
@@ -78,6 +80,7 @@ const SERVICES = [
|
||||
BlockersService,
|
||||
NotificationsService,
|
||||
SavedFiltersService,
|
||||
PinsService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
@@ -108,6 +111,7 @@ const SERVICES = [
|
||||
...NOTIFICATIONS,
|
||||
...SAVED_FILTERS,
|
||||
...MY_VIEWS,
|
||||
...PINS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
...TRANSFER_HANDLERS,
|
||||
|
||||
@@ -56,3 +56,10 @@ export class MyViewReadModel {
|
||||
criteria!: MyViewCriteriaReadModel;
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
/** A ticket someone keeps at hand: private to them, no history. */
|
||||
export class PinnedIssueReadModel {
|
||||
ownerId!: string;
|
||||
issueId!: string;
|
||||
pinnedAt!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { PinsRepository } from '@task-stream/api/tracker/application';
|
||||
import { PinnedIssueReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { PinnedIssueDoc, ReadModels } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoPinsRepository extends PinsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.PinnedIssue)
|
||||
private readonly pins: Model<PinnedIssueDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByOwner(ownerId: string): Promise<PinnedIssueReadModel[]> {
|
||||
const docs = await this.pins
|
||||
.find({ ownerId })
|
||||
.sort({ pinnedAt: -1 })
|
||||
.lean<PinnedIssueDoc[]>();
|
||||
return docs.map(({ ownerId, issueId, pinnedAt }) => ({
|
||||
ownerId,
|
||||
issueId,
|
||||
pinnedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async isPinned(ownerId: string, issueId: string): Promise<boolean> {
|
||||
return !!(await this.pins.exists({ ownerId, issueId }));
|
||||
}
|
||||
|
||||
countByOwner(ownerId: string): Promise<number> {
|
||||
return this.pins.countDocuments({ ownerId });
|
||||
}
|
||||
|
||||
async pin(pin: PinnedIssueReadModel): Promise<void> {
|
||||
await this.pins.updateOne(
|
||||
{ ownerId: pin.ownerId, issueId: pin.issueId },
|
||||
{ $setOnInsert: pin },
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async unpin(ownerId: string, issueId: string): Promise<void> {
|
||||
await this.pins.deleteOne({ ownerId, issueId });
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ReleaseReadModel,
|
||||
SavedFilterReadModel,
|
||||
MyViewReadModel,
|
||||
PinnedIssueReadModel,
|
||||
SprintReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -42,6 +43,7 @@ export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type MyViewDoc = Doc<MyViewReadModel>;
|
||||
export type PinnedIssueDoc = PinnedIssueReadModel & { _id?: Types.ObjectId };
|
||||
export type OutgoingWebhookDoc = Doc<OutgoingWebhook>;
|
||||
export type AutomationDoc = Doc<Automation>;
|
||||
export interface AutomationRunDoc {
|
||||
@@ -104,6 +106,7 @@ export const ReadModels = {
|
||||
NotificationSettings: 'NotificationSettings',
|
||||
SavedFilter: 'SavedFilter',
|
||||
MyView: 'MyView',
|
||||
PinnedIssue: 'PinnedIssue',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
@@ -322,6 +325,16 @@ class MyViewDocument {
|
||||
}
|
||||
export const MyViewSchema = SchemaFactory.createForClass(MyViewDocument);
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'pinned_issues' })
|
||||
class PinnedIssueDocument {
|
||||
@Prop({ type: String }) ownerId!: string;
|
||||
@Prop({ type: String }) issueId!: string;
|
||||
@Prop({ type: String }) pinnedAt!: string;
|
||||
}
|
||||
export const PinnedIssueSchema =
|
||||
SchemaFactory.createForClass(PinnedIssueDocument);
|
||||
PinnedIssueSchema.index({ ownerId: 1, issueId: 1 }, { unique: true });
|
||||
|
||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||
class GitIntegrationDocument {
|
||||
/** The project's id. */
|
||||
@@ -424,6 +437,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
{ name: ReadModels.MyView, schema: MyViewSchema },
|
||||
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PushSender,
|
||||
SavedFiltersRepository,
|
||||
MyViewsRepository,
|
||||
PinsRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
AutomationsRepository,
|
||||
@@ -78,6 +79,7 @@ import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.rep
|
||||
import { WebPushSender } from './push/web-push.sender';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
@@ -154,6 +156,7 @@ const PORTS = [
|
||||
{ provide: PushSender, useClass: WebPushSender },
|
||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||
{ provide: MyViewsRepository, useClass: MongoMyViewsRepository },
|
||||
{ provide: PinsRepository, useClass: MongoPinsRepository },
|
||||
{
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
BlockersService,
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PinsService,
|
||||
PresenceService,
|
||||
WorkflowsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -60,6 +61,7 @@ export class IssueResolver {
|
||||
private readonly workflows: WorkflowsService,
|
||||
private readonly blockers: BlockersService,
|
||||
private readonly presence: PresenceService,
|
||||
private readonly pins: PinsService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => [PresenceGql], {
|
||||
@@ -245,6 +247,16 @@ export class IssueResolver {
|
||||
return !!me && issue.voterIds.includes(me.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => Boolean, {
|
||||
description: 'Whether I pinned it (false without a token)',
|
||||
})
|
||||
pinned(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() issue: IssueReadModel,
|
||||
): Promise<boolean> | boolean {
|
||||
return !!me && this.pins.isPinned(me.id, issue.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
|
||||
linkedIssues(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@@ -317,6 +329,35 @@ export class IssueResolver {
|
||||
return this.issuesService.watch(me.id, issueId, true);
|
||||
}
|
||||
|
||||
@Query(() => [IssueGql], {
|
||||
description:
|
||||
'My pinned tickets, most recently pinned first (trashed or out of reach left out)',
|
||||
})
|
||||
myPins(@CurrentUser() me: User) {
|
||||
return this.pins.mine(me.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description:
|
||||
'Pin a ticket: keep it at hand on my home page and in search (private, at most 20)',
|
||||
})
|
||||
async pinIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
): Promise<boolean> {
|
||||
await this.pins.pin(me.id, issueId, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: 'Unpin a ticket' })
|
||||
async unpinIssue(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
): Promise<boolean> {
|
||||
await this.pins.pin(me.id, issueId, false);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
|
||||
unwatchIssue(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import {
|
||||
CommentsService,
|
||||
IssuesService,
|
||||
PinsService,
|
||||
PresenceService,
|
||||
ProjectsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
@@ -94,6 +95,7 @@ export class IssuesController {
|
||||
private readonly refs: Refs,
|
||||
private readonly presence: PresenceService,
|
||||
private readonly projects: ProjectsService,
|
||||
private readonly pins: PinsService,
|
||||
) {}
|
||||
|
||||
@Get('me/home')
|
||||
@@ -163,6 +165,41 @@ export class IssuesController {
|
||||
await this.presence.leave(me.id, await this.refs.issueId(me.id, issue));
|
||||
}
|
||||
|
||||
@Get('me/pins')
|
||||
@ApiOperation({
|
||||
summary: 'My pinned tickets, most recently pinned first',
|
||||
description:
|
||||
'Across my projects; tickets in the trash or out of my reach are left out.',
|
||||
})
|
||||
@ApiOkResponse({ type: [IssueDto] })
|
||||
@ApiReadErrors()
|
||||
async myPins(@CurrentUser() me: User) {
|
||||
return this.refs.issueDtos(await this.pins.mine(me.id), viewerOf(me));
|
||||
}
|
||||
|
||||
@Put('issues/:issue/pin')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
summary: 'Pin a ticket: keep it at hand on my home page and in search',
|
||||
description: 'Private to me; at most 20. Pinning it again changes nothing.',
|
||||
})
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async pin(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), true);
|
||||
}
|
||||
|
||||
@Delete('issues/:issue/pin')
|
||||
@HttpCode(204)
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'Unpin a ticket' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async unpin(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
||||
}
|
||||
|
||||
@Put('issues/:issue/watch')
|
||||
@ISSUE
|
||||
@ApiOperation({
|
||||
|
||||
@@ -112,6 +112,7 @@ export const APP_ICONS: Record<string, string> = {
|
||||
task: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8.5 12 2.5 2.5 4.5-5"/></svg>',
|
||||
palette:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a9 9 0 0 0 0 18c1.1 0 1.5-.8 1.5-1.5 0-1.2-1-1.5-1-2.5s.7-1.5 1.7-1.5H17a4 4 0 0 0 4-4c0-4.7-4-8.5-9-8.5Z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/></svg>',
|
||||
pin: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4h6M10 4v5l-3 4h10l-3-4V4M12 13v7"/></svg>',
|
||||
eye: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>',
|
||||
'eye-off':
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18M10.6 5.1A10.4 10.4 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4.1M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a10 10 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>',
|
||||
|
||||
@@ -43,6 +43,7 @@ export const issuePageEvents = eventGroup({
|
||||
replyTo?: string | null;
|
||||
}>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||
trashRequested: type<{ issueId: string; trashed: boolean }>(),
|
||||
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
|
||||
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
|
||||
|
||||
@@ -120,6 +120,13 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
|
||||
setPinned(pinned: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.pinRequested({ issueId, pinned }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Places the open ticket under another one by its key; null: back to the top. */
|
||||
/**
|
||||
* Tickets to link to or hang the open one from: the most recently updated
|
||||
|
||||
@@ -337,6 +337,20 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.pinRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.pin(payload.issueId, payload.pinned),
|
||||
payload.pinned
|
||||
? 'Fijado: lo tienes a mano en el inicio y al buscar'
|
||||
: 'Ya no está fijado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.watchRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface Issue extends IssueSummary {
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
checklist: ChecklistItem[];
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
devLinks: DevLink[];
|
||||
|
||||
@@ -140,6 +140,10 @@ export abstract class IssuesGateway {
|
||||
): Observable<CommandResult>;
|
||||
/** The signed-in person follows (or stops following) the ticket. */
|
||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||
/** My pinned tickets, most recently pinned first. */
|
||||
abstract pins(): Observable<IssueSummary[]>;
|
||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/** Tickets hanging from `issueId`. */
|
||||
abstract children(
|
||||
projectId: string,
|
||||
|
||||
@@ -43,9 +43,12 @@ import {
|
||||
SetIssueParentDocument,
|
||||
SetIssueReleaseDocument,
|
||||
UnlinkIssuesDocument,
|
||||
UnpinIssueDocument,
|
||||
UnwatchIssueDocument,
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
MyPinsDocument,
|
||||
PinIssueDocument,
|
||||
UpdateChecklistItemDocument,
|
||||
UpdateIssueDocument,
|
||||
} from '../graphql/generated';
|
||||
@@ -192,6 +195,19 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map((d) => toCommandResult(d.voteIssue)));
|
||||
}
|
||||
|
||||
pins(): Observable<IssueSummary[]> {
|
||||
return this.gql
|
||||
.query(MyPinsDocument, {})
|
||||
.pipe(map((d) => d.myPins.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
pin(issueId: string, pinned: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = pinned
|
||||
? this.gql.mutate(PinIssueDocument, { issueId })
|
||||
: this.gql.mutate(UnpinIssueDocument, { issueId });
|
||||
return done$.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
watch(issueId: string, watching: boolean): Observable<CommandResult> {
|
||||
return watching
|
||||
? this.gql
|
||||
|
||||
@@ -120,6 +120,7 @@ fragment IssueSummaryFields on Issue {
|
||||
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
description
|
||||
figmaUrl
|
||||
createdAt
|
||||
|
||||
@@ -624,6 +624,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
};
|
||||
|
||||
export type IssueFieldsFragment = {
|
||||
pinned: boolean;
|
||||
description: string | null;
|
||||
figmaUrl: string | null;
|
||||
createdAt: string;
|
||||
@@ -846,6 +847,7 @@ export type IssueQueryVariables = Exact<{
|
||||
|
||||
export type IssueQuery = {
|
||||
issue: {
|
||||
pinned: boolean;
|
||||
description: string | null;
|
||||
figmaUrl: string | null;
|
||||
createdAt: string;
|
||||
@@ -1012,6 +1014,52 @@ export type UnwatchIssueMutation = {
|
||||
unwatchIssue: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type MyPinsQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyPinsQuery = {
|
||||
myPins: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
team: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
votes: number;
|
||||
voted: boolean;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
blockedBy: Array<{ id: string; key: string; title: string }>;
|
||||
customFields: Array<{ fieldId: string; value: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PinIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type PinIssueMutation = { pinIssue: boolean };
|
||||
|
||||
export type UnpinIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type UnpinIssueMutation = { unpinIssue: boolean };
|
||||
|
||||
export type SetIssueReleaseMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
releaseId?: string | null | undefined;
|
||||
@@ -3467,6 +3515,7 @@ export const IssueFieldsFragmentDoc = {
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
@@ -5681,6 +5730,7 @@ export const IssueDocument = {
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
@@ -6553,6 +6603,237 @@ export const UnwatchIssueDocument = {
|
||||
UnwatchIssueMutation,
|
||||
UnwatchIssueMutationVariables
|
||||
>;
|
||||
export const MyPinsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'MyPins' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'myPins' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Issue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'release' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprint' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'blockedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'votes' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'voted' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'customFields' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
||||
export const PinIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'PinIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'pinIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<PinIssueMutation, PinIssueMutationVariables>;
|
||||
export const UnpinIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UnpinIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unpinIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<UnpinIssueMutation, UnpinIssueMutationVariables>;
|
||||
export const SetIssueReleaseDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -95,6 +95,20 @@ mutation UnwatchIssue($issueId: ID!) {
|
||||
}
|
||||
}
|
||||
|
||||
query MyPins {
|
||||
myPins {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation PinIssue($issueId: ID!) {
|
||||
pinIssue(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation UnpinIssue($issueId: ID!) {
|
||||
unpinIssue(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
|
||||
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -205,6 +205,21 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(execute)="facade.setWatching(!facade.watching())"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole()) {
|
||||
<pi-icon-button
|
||||
icon="pin"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
[text]="issue.pinned ? 'Fijado' : 'Fijar'"
|
||||
[attr.aria-pressed]="issue.pinned"
|
||||
[attr.title]="
|
||||
issue.pinned
|
||||
? 'Quitar de mis fijados'
|
||||
: 'Tenerlo a mano en el inicio y al buscar'
|
||||
"
|
||||
(execute)="facade.setPinned(!issue.pinned)"
|
||||
/>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
|
||||
@@ -53,6 +53,7 @@ import { ResumeComponent } from '../ui/home/resume';
|
||||
import { WeekAgendaComponent } from '../ui/home/week-agenda';
|
||||
import { HomeKpisComponent } from '../ui/home/kpis';
|
||||
import { MyViewsComponent } from '../ui/my-views';
|
||||
import { PinnedComponent } from '../ui/home/pinned';
|
||||
|
||||
/** How often the home page refreshes its reading while open. */
|
||||
const REFRESH_MS = 60_000;
|
||||
@@ -75,6 +76,7 @@ const REFRESH_MS = 60_000;
|
||||
WeekAgendaComponent,
|
||||
HomeKpisComponent,
|
||||
MyViewsComponent,
|
||||
PinnedComponent,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
@@ -155,6 +157,7 @@ const REFRESH_MS = 60_000;
|
||||
</section>
|
||||
|
||||
@if (facade.projects().length) {
|
||||
<ts-pinned />
|
||||
@if (myHome.home(); as h) {
|
||||
<ts-home-kpis
|
||||
[mine]="home.mineTotal()"
|
||||
@@ -271,6 +274,7 @@ const REFRESH_MS = 60_000;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
ts-first-steps,
|
||||
ts-pinned,
|
||||
ts-home-kpis,
|
||||
ts-week-agenda,
|
||||
ts-my-views {
|
||||
|
||||
@@ -30,6 +30,15 @@ import {
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
|
||||
/** A ticket as a result of the palette. */
|
||||
const issueResult = (i: IssueSummary): Result => ({
|
||||
id: `i:${i.id}`,
|
||||
kind: 'issue',
|
||||
label: `${i.key} · ${i.title}`,
|
||||
detail: i.assignee?.name ?? '',
|
||||
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
|
||||
});
|
||||
|
||||
interface Result {
|
||||
id: string;
|
||||
kind: 'project' | 'issue';
|
||||
@@ -102,7 +111,11 @@ interface Result {
|
||||
(keydown.enter)="go(result)"
|
||||
>
|
||||
<span class="ts-palette__kind">{{
|
||||
result.kind === 'project' ? 'Proyecto' : 'Ticket'
|
||||
result.kind === 'project'
|
||||
? 'Proyecto'
|
||||
: text().trim().length < 2
|
||||
? 'Fijado'
|
||||
: 'Ticket'
|
||||
}}</span>
|
||||
<span class="ts-palette__label">{{ result.label }}</span>
|
||||
<span class="ts-palette__detail">{{ result.detail }}</span>
|
||||
@@ -242,6 +255,7 @@ export class CommandPalette {
|
||||
private readonly router = inject(Router);
|
||||
private readonly projects = inject(ProjectsFacade);
|
||||
private readonly issues = inject(IssuesGateway);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('search');
|
||||
|
||||
@@ -254,9 +268,11 @@ export class CommandPalette {
|
||||
private goTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly foundIssues = signal<IssueSummary[]>([]);
|
||||
/** Mine, shown before typing; read again each time it opens. */
|
||||
protected readonly pins = signal<IssueSummary[]>([]);
|
||||
protected readonly results = computed<Result[]>(() => {
|
||||
const query = this.text().trim().toLowerCase();
|
||||
if (query.length < 2) return [];
|
||||
if (query.length < 2) return this.pins().map(issueResult);
|
||||
const projects = this.projects
|
||||
.sorted()
|
||||
.filter(
|
||||
@@ -272,14 +288,7 @@ export class CommandPalette {
|
||||
detail: p.key,
|
||||
url: `/p/${p.key}/board`,
|
||||
}));
|
||||
const issues = this.foundIssues().map((i) => ({
|
||||
id: `i:${i.id}`,
|
||||
kind: 'issue' as const,
|
||||
label: `${i.key} · ${i.title}`,
|
||||
detail: i.assignee?.name ?? '',
|
||||
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
|
||||
}));
|
||||
return [...projects, ...issues];
|
||||
return [...projects, ...this.foundIssues().map(issueResult)];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -296,7 +305,7 @@ export class CommandPalette {
|
||||
catchError(() => of([])),
|
||||
),
|
||||
),
|
||||
takeUntilDestroyed(inject(DestroyRef)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((found) => this.foundIssues.set(found));
|
||||
effect(() => {
|
||||
@@ -349,8 +358,16 @@ export class CommandPalette {
|
||||
}
|
||||
|
||||
protected open(mode: 'search' | 'help'): void {
|
||||
if (mode === 'search' && !this.projects.sorted().length)
|
||||
this.projects.load();
|
||||
if (mode === 'search') {
|
||||
if (!this.projects.sorted().length) this.projects.load();
|
||||
this.issues
|
||||
.pins()
|
||||
.pipe(
|
||||
catchError(() => of([])),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((pins) => this.pins.set(pins));
|
||||
}
|
||||
this.mode.set(mode);
|
||||
}
|
||||
|
||||
|
||||
172
libs/web/tracker/presenters/src/lib/ui/home/pinned.ts
Normal file
172
libs/web/tracker/presenters/src/lib/ui/home/pinned.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiIconComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
StatusCategory,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
/**
|
||||
* The tickets I pinned, at hand on the home page: one strip, only when
|
||||
* there are some (they are pinned from the ticket's page).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-pinned',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiIconComponent],
|
||||
template: `
|
||||
@if (pins().length) {
|
||||
<section class="ts-pinned" aria-labelledby="pinned-title">
|
||||
<h2 id="pinned-title">
|
||||
<pi-icon icon="pin" size="small" />
|
||||
Fijados
|
||||
</h2>
|
||||
<ul role="list">
|
||||
@for (pin of pins(); track pin.id) {
|
||||
<li [class.is-done]="pin.statusCategory === done">
|
||||
<a [routerLink]="['/p', projectOf(pin.key), 'issues', pin.key]">
|
||||
<span class="ts-pinned__key">{{ pin.key }}</span>
|
||||
<span class="ts-pinned__title">{{ pin.title }}</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-pinned__unpin"
|
||||
[attr.aria-label]="'Quitar ' + pin.key + ' de fijados'"
|
||||
title="Quitar de fijados"
|
||||
(click)="unpin(pin)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
host: { '[style.display]': "pins().length ? 'block' : 'none'" },
|
||||
styles: `
|
||||
.ts-pinned {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
h2 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: min(100%, 22rem);
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 999px;
|
||||
background: var(--ts-surface);
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: color-mix(in srgb, var(--ts-brand) 45%, var(--ts-line));
|
||||
}
|
||||
&.is-done .ts-pinned__title {
|
||||
color: var(--ts-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
a {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
padding: 0.375rem 0.25rem 0.375rem 0.875rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ts-pinned__key {
|
||||
flex: none;
|
||||
color: var(--ts-muted);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-pinned__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A plain glyph, not @pi/ui's sized button. */
|
||||
.ts-pinned__unpin {
|
||||
flex: none;
|
||||
width: 1.75rem !important;
|
||||
min-width: 0 !important;
|
||||
height: 1.75rem !important;
|
||||
margin-right: 0.25rem;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--ts-muted);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PinnedComponent {
|
||||
private readonly gateway = inject(IssuesGateway);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly pins = signal<IssueSummary[]>([]);
|
||||
protected readonly done = StatusCategory.DONE;
|
||||
|
||||
constructor() {
|
||||
this.gateway
|
||||
.pins()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({ next: (pins) => this.pins.set(pins), error: () => null });
|
||||
}
|
||||
|
||||
protected projectOf(key: string): string {
|
||||
return key.slice(0, key.lastIndexOf('-'));
|
||||
}
|
||||
|
||||
protected unpin(pin: IssueSummary): void {
|
||||
this.pins.update((list) => list.filter((p) => p.id !== pin.id));
|
||||
this.gateway
|
||||
.pin(pin.id, false)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
error: () => {
|
||||
this.toast.error(`No se pudo quitar ${pin.key} de fijados`);
|
||||
this.pins.update((list) => [pin, ...list]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user