feat: saved views of my work, across my projects
A person saves named views of «my work»: projects, assigned to me, that I follow, status categories, priorities, bugs only, overdue and text. They are private (Mongo my_views, no history), listed and deleted by their owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views), and run as the cross-project search, which gains project, priority, bugsOnly and overdue filters (also in MCP my_issues). The home page shows them as a bar above «my work», with a drawer to save a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
481
libs/web/tracker/presenters/src/lib/ui/my-views.ts
Normal file
481
libs/web/tracker/presenters/src/lib/ui/my-views.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
MyViewsFacade,
|
||||
ProjectsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
MyViewCriteria,
|
||||
PRIORITY_ORDER,
|
||||
StatusCategory,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
CATEGORY,
|
||||
PRIORITY,
|
||||
projectKeyOf,
|
||||
projectTone,
|
||||
statusLook,
|
||||
typeLook,
|
||||
} from './copy';
|
||||
|
||||
const CATEGORIES = Object.values(StatusCategory);
|
||||
|
||||
/** Toggles a value in a list kept as a signal. */
|
||||
function toggle<T>(
|
||||
list: ReturnType<typeof signal<T[]>>,
|
||||
value: T,
|
||||
on: boolean,
|
||||
) {
|
||||
list.update((values) =>
|
||||
on ? [...new Set([...values, value])] : values.filter((v) => v !== value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* My saved views of «my work», across my projects: a bar to pick one, its
|
||||
* tickets, and the drawer to save a new one.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-my-views',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<nav class="ts-views" aria-label="Mis vistas">
|
||||
<span class="ts-views__label">Vistas</span>
|
||||
@for (view of views.views(); track view.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-views__chip"
|
||||
[class.is-on]="views.selected()?.id === view.id"
|
||||
[attr.aria-pressed]="views.selected()?.id === view.id"
|
||||
(click)="
|
||||
views.select(views.selected()?.id === view.id ? null : view.id)
|
||||
"
|
||||
>
|
||||
{{ view.name }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="ts-views__new" (click)="openForm()">
|
||||
+ Nueva vista
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (views.selected(); as view) {
|
||||
<section class="ts-panel ts-views__panel" aria-labelledby="view-title">
|
||||
<header class="ts-views__head">
|
||||
<h2 id="view-title">{{ view.name }}</h2>
|
||||
<span class="ts-muted">{{ summary() }}</span>
|
||||
<span class="ts-muted ts-mono">{{ views.total() }}</span>
|
||||
<span class="ts-spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="views.remove(view.id)"
|
||||
>
|
||||
Borrar vista
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="views.select(null)"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</header>
|
||||
@if (views.loading()) {
|
||||
<p class="ts-muted">Cargando…</p>
|
||||
} @else if (views.error(); as error) {
|
||||
<p class="ts-muted">{{ error }}</p>
|
||||
} @else {
|
||||
<ul class="ts-views__list">
|
||||
@for (issue of views.results(); track issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-views__row"
|
||||
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||
[style.--type]="typeTone(issue)"
|
||||
>
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-views__title"
|
||||
><span
|
||||
class="ts-views__status"
|
||||
[style.--tone]="statusTone(issue)"
|
||||
>{{ statusLabel(issue) }}</span
|
||||
>
|
||||
{{ issue.title }}</span
|
||||
>
|
||||
<span class="ts-views__who">{{
|
||||
issue.assignee?.name ?? 'Sin asignar'
|
||||
}}</span>
|
||||
<span
|
||||
class="ts-views__project"
|
||||
[style.--tone]="projectToneOf(issue.key)"
|
||||
>{{ keyOf(issue.key) }}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="ts-muted">Ningún ticket encaja ahora mismo.</li>
|
||||
}
|
||||
</ul>
|
||||
@if (views.total() > views.results().length) {
|
||||
<p class="ts-muted ts-views__more">
|
||||
Se muestran {{ views.results().length }} de {{ views.total() }}.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<ng-template #form>
|
||||
<form
|
||||
class="ts-form ts-views__form"
|
||||
novalidate
|
||||
(submit)="$event.preventDefault(); save()"
|
||||
>
|
||||
<pi-text-field [(value)]="name" placeholder="Bugs críticos">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
<fieldset>
|
||||
<legend>Proyectos <span>(ninguno: todos los míos)</span></legend>
|
||||
@for (project of projects.sorted(); track project.id) {
|
||||
<pi-checkbox
|
||||
[label]="project.key + ' · ' + project.name"
|
||||
[checked]="projectIds().includes(project.id)"
|
||||
(checkedChange)="pick(projectIds, project.id, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Quién</legend>
|
||||
<pi-checkbox
|
||||
label="Asignados a mí"
|
||||
[checked]="assignedToMe()"
|
||||
(checkedChange)="assignedToMe.set($event)"
|
||||
/>
|
||||
<pi-checkbox
|
||||
label="Que sigo"
|
||||
[checked]="watching()"
|
||||
(checkedChange)="watching.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Estado <span>(ninguno: los abiertos)</span></legend>
|
||||
@for (category of categories; track category) {
|
||||
<pi-checkbox
|
||||
[label]="categoryLabel(category)"
|
||||
[checked]="categoriesPicked().includes(category)"
|
||||
(checkedChange)="pick(categoriesPicked, category, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prioridad <span>(ninguna: todas)</span></legend>
|
||||
@for (priority of priorities; track priority) {
|
||||
<pi-checkbox
|
||||
[label]="priorityLabel(priority)"
|
||||
[checked]="prioritiesPicked().includes(priority)"
|
||||
(checkedChange)="pick(prioritiesPicked, priority, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Además</legend>
|
||||
<pi-checkbox
|
||||
label="Solo bugs"
|
||||
[checked]="bugsOnly()"
|
||||
(checkedChange)="bugsOnly.set($event)"
|
||||
/>
|
||||
<pi-checkbox
|
||||
label="Solo vencidos"
|
||||
[checked]="overdue()"
|
||||
(checkedChange)="overdue.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<pi-text-field [(value)]="search" placeholder="pago, Safari…">
|
||||
<pi-label>Texto (clave, título o descripción)</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="views.saving() ? 'Guardando…' : 'Guardar vista'"
|
||||
[disabled]="!name().trim() || views.saving()"
|
||||
(execute)="save()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ts-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ts-views__label {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-views__chip,
|
||||
.ts-views__new {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 999px;
|
||||
background: var(--ts-surface);
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-views__chip.is-on {
|
||||
border-color: var(--ts-brand);
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, var(--ts-surface));
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-views__new {
|
||||
border-style: dashed;
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
.ts-views__panel {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.ts-views__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.ts-views__list {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-views__row {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 9rem) 3.5rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-left: 3px solid var(--type, var(--ts-line));
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background: var(--pi-color-primary-25);
|
||||
}
|
||||
}
|
||||
.ts-views__title,
|
||||
.ts-views__who {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-views__who {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-views__status {
|
||||
margin-right: 0.375rem;
|
||||
color: var(--tone);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-views__project {
|
||||
justify-self: end;
|
||||
color: var(--tone);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-views__more {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-views__form fieldset {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.ts-views__form fieldset + pi-text-field {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ts-views__form legend {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
span {
|
||||
color: var(--ts-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ts-views__row {
|
||||
grid-template-columns: 5rem minmax(0, 1fr) 3rem;
|
||||
}
|
||||
.ts-views__who {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MyViewsComponent implements OnInit {
|
||||
protected readonly views = inject(MyViewsFacade);
|
||||
protected readonly projects = inject(ProjectsFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly form = viewChild.required<TemplateRef<unknown>>('form');
|
||||
|
||||
protected readonly categories = CATEGORIES;
|
||||
protected readonly priorities = PRIORITY_ORDER;
|
||||
|
||||
/* The form */
|
||||
protected readonly name = signal('');
|
||||
protected readonly projectIds = signal<string[]>([]);
|
||||
protected readonly assignedToMe = signal(false);
|
||||
protected readonly watching = signal(false);
|
||||
protected readonly categoriesPicked = signal<StatusCategory[]>([]);
|
||||
protected readonly prioritiesPicked = signal<IssuePriority[]>([]);
|
||||
protected readonly bugsOnly = signal(false);
|
||||
protected readonly overdue = signal(false);
|
||||
protected readonly search = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.views.open();
|
||||
}
|
||||
|
||||
protected readonly pick = toggle;
|
||||
|
||||
protected openForm(): void {
|
||||
this.name.set('');
|
||||
this.projectIds.set([]);
|
||||
this.assignedToMe.set(false);
|
||||
this.watching.set(false);
|
||||
this.categoriesPicked.set([]);
|
||||
this.prioritiesPicked.set([]);
|
||||
this.bugsOnly.set(false);
|
||||
this.overdue.set(false);
|
||||
this.search.set('');
|
||||
this.drawer.open('Nueva vista de mi trabajo', this.form());
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
const name = this.name().trim();
|
||||
if (!name) return;
|
||||
const criteria: MyViewCriteria = {
|
||||
projectIds: this.projectIds(),
|
||||
assignedToMe: this.assignedToMe(),
|
||||
watching: this.watching(),
|
||||
categories: this.categoriesPicked(),
|
||||
priorities: this.prioritiesPicked(),
|
||||
bugsOnly: this.bugsOnly(),
|
||||
overdue: this.overdue(),
|
||||
search: this.search().trim() || null,
|
||||
};
|
||||
this.views.save(name, criteria);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
/** What the picked view narrows to, in words. */
|
||||
protected readonly summary = computed(() => {
|
||||
const c = this.views.selected()?.criteria;
|
||||
if (!c) return '';
|
||||
const keys = (c.projectIds ?? [])
|
||||
.map((id) => this.projects.projects().find((p) => p.id === id)?.key)
|
||||
.filter(Boolean);
|
||||
return [
|
||||
keys.length ? keys.join(', ') : 'todos mis proyectos',
|
||||
c.assignedToMe ? 'asignados a mí' : null,
|
||||
c.watching ? 'que sigo' : null,
|
||||
c.categories?.length
|
||||
? c.categories.map((x) => CATEGORY[x].label.toLowerCase()).join('/')
|
||||
: 'abiertos',
|
||||
c.priorities?.length
|
||||
? c.priorities.map((p) => PRIORITY[p].label.toLowerCase()).join('/')
|
||||
: null,
|
||||
c.bugsOnly ? 'solo bugs' : null,
|
||||
c.overdue ? 'vencidos' : null,
|
||||
c.search ? `«${c.search}»` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
|
||||
protected readonly priorityLabel = (p: IssuePriority) => PRIORITY[p].label;
|
||||
protected readonly keyOf = projectKeyOf;
|
||||
protected readonly projectToneOf = (issueKey: string) =>
|
||||
projectTone(projectKeyOf(issueKey));
|
||||
|
||||
/** Each ticket with its own project's names and colours. */
|
||||
private readonly workflows = computed(
|
||||
() =>
|
||||
new Map(
|
||||
this.projects
|
||||
.projects()
|
||||
.map((p) => [p.id, new Workflow(p.settings)] as const),
|
||||
),
|
||||
);
|
||||
private workflowOf(issue: IssueSummary): Workflow {
|
||||
return this.workflows().get(issue.projectId) ?? new Workflow();
|
||||
}
|
||||
protected readonly statusLabel = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).label;
|
||||
protected readonly statusTone = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).tone;
|
||||
protected readonly typeTone = (issue: IssueSummary) =>
|
||||
typeLook(this.workflowOf(issue), issue.type).tone;
|
||||
}
|
||||
Reference in New Issue
Block a user