Files
task-stream/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts
Josep Piguillem d6a434ca1d feat: global search and keyboard shortcuts
Ctrl/⌘ K (or /) opens a search over the projects and tickets of all my
projects, driven by the keyboard. g plus a letter moves between project
pages, c creates a ticket on the board, e edits the open ticket and ?
lists every shortcut. My work can now be searched by text through the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:32:07 +02:00

261 lines
7.5 KiB
TypeScript

import { inject, Injectable } from '@angular/core';
import {
BoardColumn,
BoardFilter,
BulkChange,
BulkResult,
ChangeStatusInput,
CommandResult,
CreateIssueInput,
Epic,
Issue,
IssuePage,
IssuesGateway,
IssueSummary,
LinkIssuesInput,
MyIssuesFilter,
UpdateIssueInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AddChecklistItemDocument,
AssignIssueDocument,
BacklogDocument,
BulkUpdateIssuesDocument,
BoardDocument,
ChangeIssueStatusDocument,
CreateIssueDocument,
EpicsDocument,
IssueChildrenDocument,
IssueDocument,
LinkIssuesDocument,
MyIssuesDocument,
RankIssueDocument,
RemoveChecklistItemDocument,
SearchIssuesDocument,
SetIssueParentDocument,
SetIssueReleaseDocument,
UnlinkIssuesDocument,
UnwatchIssueDocument,
WatchIssueDocument,
UpdateChecklistItemDocument,
UpdateIssueDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
import { toCommandResult, toIssue, 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,
categories: filter.categories as never,
limit: filter.limit,
search: filter.search?.trim() || null,
})
.pipe(
map((d) => ({
items: d.myIssues.items.map(toIssueSummary),
total: d.myIssues.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)));
}
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)));
}
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 },
})),
),
);
}
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,
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)));
}
}