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:
2026-10-17 10:15:28 +02:00
parent 423f82c1a3
commit dfa42a8a1a
32 changed files with 1612 additions and 46 deletions

View File

@@ -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

View File

@@ -120,6 +120,7 @@ fragment IssueSummaryFields on Issue {
fragment IssueFields on Issue {
...IssueSummaryFields
pinned
description
figmaUrl
createdAt

View File

@@ -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: [

View File

@@ -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