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:
172
libs/web/tracker/presenters/src/lib/ui/home/pinned.ts
Normal file
172
libs/web/tracker/presenters/src/lib/ui/home/pinned.ts
Normal 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]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user