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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user