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

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

View File

@@ -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 }>(),

View File

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

View File

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

View File

@@ -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[];

View File

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

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

View File

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

View File

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

View File

@@ -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);
}

View 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]);
},
});
}
}