Files
task-stream/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts
Josep Piguillem dfa42a8a1a 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>
2026-10-17 10:15:28 +02:00

356 lines
10 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import {
BoardColumn,
BoardFilter,
BulkChange,
BulkResult,
ChangeStatusInput,
CommandResult,
CreateIssueInput,
Epic,
Issue,
IssuePage,
IssuesGateway,
IssueListFilter,
IssueSummary,
LinkIssuesInput,
MyIssuesFilter,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AddChecklistItemDocument,
AssignIssueDocument,
BacklogDocument,
BulkUpdateIssuesDocument,
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
DuplicateIssueDocument,
DueIssuesDocument,
EpicsDocument,
IssueChildrenDocument,
IssueDocument,
IssueListDocument,
LinkIssuesDocument,
MyIssuesDocument,
RankIssueDocument,
RemoveChecklistItemDocument,
RestoreIssueDocument,
TrashDocument,
TrashIssueDocument,
SearchIssuesDocument,
SetIssueParentDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UnpinIssueDocument,
UnwatchIssueDocument,
VoteIssueDocument,
WatchIssueDocument,
MyPinsDocument,
PinIssueDocument,
UpdateChecklistItemDocument,
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import {
toCommandResult,
toIssue,
toIssueLink,
toIssueSummary,
} from './mappers';
@Injectable()
export class GraphqlIssuesGateway extends IssuesGateway {
private readonly gql = inject(GraphqlOperations);
board(filter: BoardFilter): Observable<BoardColumn[]> {
return this.gql
.query(BoardDocument, {
idOrKey: filter.projectId,
releaseId: filter.releaseId ?? null,
assigneeId: filter.assigneeId ?? null,
area: filter.area ?? null,
types: filter.types?.length ? filter.types : null,
priorities: filter.priorities?.length
? (filter.priorities as never)
: null,
labels: filter.labels?.length ? filter.labels : null,
search: filter.search?.trim() || null,
unassigned: filter.unassigned ?? null,
sprintId: filter.sprintId ?? null,
})
.pipe(
map((d) =>
d.project.board.map((column) => ({
status: column.status,
total: column.total,
issues: column.issues.map(toIssueSummary),
})),
),
);
}
issue(idOrKey: string): Observable<Issue> {
return this.gql
.query(IssueDocument, { idOrKey })
.pipe(map((d) => toIssue(d.issue)));
}
mine(filter: MyIssuesFilter): Observable<IssuePage> {
return this.gql
.query(MyIssuesDocument, {
assignedToMe: filter.assignedToMe,
watching: filter.watching ?? false,
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) => ({
items: d.myIssues.items.map(toIssueSummary),
total: d.myIssues.total,
})),
);
}
list(filter: IssueListFilter): Observable<IssuePage> {
const { limit, ...criteria } = filter;
return this.gql
.query(IssueListDocument, {
filter: {
...criteria,
search: criteria.search?.trim() || undefined,
} as never,
limit,
})
.pipe(
map((d) => ({
items: d.issues.items.map(toIssueSummary),
total: d.issues.total,
})),
);
}
search(projectId: string, text: string): Observable<IssueSummary[]> {
return this.gql
.query(SearchIssuesDocument, { filter: { projectId, search: text } })
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
}
create(input: CreateIssueInput): Observable<CommandResult> {
return this.gql
.mutate(CreateIssueDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.createIssue)));
}
duplicate(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(DuplicateIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.duplicateIssue)));
}
update(input: UpdateIssueInput): Observable<CommandResult> {
return this.gql
.mutate(UpdateIssueDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.updateIssue)));
}
changeStatus(input: ChangeStatusInput): Observable<CommandResult> {
return this.gql
.mutate(ChangeIssueStatusDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.changeIssueStatus)));
}
assign(
issueId: string,
assigneeId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(AssignIssueDocument, { issueId, assigneeId })
.pipe(map((d) => toCommandResult(d.assignIssue)));
}
link(input: LinkIssuesInput): Observable<CommandResult> {
return this.gql
.mutate(LinkIssuesDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.linkIssues)));
}
unlink(input: LinkIssuesInput): Observable<CommandResult> {
return this.gql
.mutate(UnlinkIssuesDocument, { input: input as never })
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
}
vote(issueId: string, on: boolean): Observable<CommandResult> {
return this.gql
.mutate(VoteIssueDocument, { issueId, on })
.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
.mutate(WatchIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.watchIssue)))
: this.gql
.mutate(UnwatchIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.unwatchIssue)));
}
children(projectId: string, issueId: string): Observable<IssueSummary[]> {
return this.gql
.query(IssueChildrenDocument, {
filter: { projectId, parentId: issueId },
})
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
}
setParent(
issueId: string,
parentId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(SetIssueParentDocument, { issueId, parentId })
.pipe(map((d) => toCommandResult(d.setIssueParent)));
}
epics(projectId: string): Observable<Epic[]> {
return this.gql.query(EpicsDocument, { projectId }).pipe(
map((d) =>
d.epics.map((epic) => ({
issue: toIssueSummary(epic.issue),
progress: { ...epic.progress },
links: epic.issue.links.map((l) => toIssueLink(l)),
})),
),
);
}
due(projectId: string, from: string, to: string): Observable<IssueSummary[]> {
return this.gql
.query(DueIssuesDocument, {
filter: { projectId, dueFrom: from, dueTo: to },
})
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
}
trash(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(TrashIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.trashIssue)));
}
restore(issueId: string): Observable<CommandResult> {
return this.gql
.mutate(RestoreIssueDocument, { issueId })
.pipe(map((d) => toCommandResult(d.restoreIssue)));
}
trashed(projectId: string): Observable<IssueSummary[]> {
return this.gql
.query(TrashDocument, { projectId })
.pipe(map((d) => d.trash.map(toIssueSummary)));
}
bulkUpdate(issueIds: string[], change: BulkChange): Observable<BulkResult> {
return this.gql
.mutate(BulkUpdateIssuesDocument, {
input: {
issueIds,
status: change.status ?? null,
unassign: change.assigneeId === null ? true : null,
assigneeId: change.assigneeId ?? null,
noSprint: change.sprintId === null ? true : null,
sprintId: change.sprintId ?? null,
noRelease: change.releaseId === null ? true : null,
releaseId: change.releaseId ?? null,
priority: (change.priority ?? null) as never,
addLabels: change.addLabels ?? null,
},
})
.pipe(map((d) => d.bulkUpdateIssues));
}
backlog(projectId: string): Observable<IssuePage> {
return this.gql
.query(BacklogDocument, {
filter: { projectId, categories: ['TODO'], sort: 'RANK' },
})
.pipe(
map((d) => ({
items: d.issues.items.map(toIssueSummary),
total: d.issues.total,
})),
);
}
rank(
issueId: string,
aboveIssueId: string | null,
belowIssueId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(RankIssueDocument, {
input: { issueId, aboveIssueId, belowIssueId },
})
.pipe(map((d) => toCommandResult(d.rankIssue)));
}
addChecklistItem(issueId: string, text: string): Observable<CommandResult> {
return this.gql
.mutate(AddChecklistItemDocument, { input: { issueId, text } })
.pipe(map((d) => toCommandResult(d.addChecklistItem)));
}
updateChecklistItem(
issueId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): Observable<CommandResult> {
return this.gql
.mutate(UpdateChecklistItemDocument, {
input: { issueId, itemId, ...changes },
})
.pipe(map((d) => toCommandResult(d.updateChecklistItem)));
}
removeChecklistItem(
issueId: string,
itemId: string,
): Observable<CommandResult> {
return this.gql
.mutate(RemoveChecklistItemDocument, { issueId, itemId })
.pipe(map((d) => toCommandResult(d.removeChecklistItem)));
}
setRelease(
issueId: string,
releaseId: string | null,
): Observable<CommandResult> {
return this.gql
.mutate(SetIssueReleaseDocument, { issueId, releaseId })
.pipe(map((d) => toCommandResult(d.setIssueRelease)));
}
}