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>
This commit is contained in:
@@ -2041,6 +2041,15 @@
|
|||||||
"$ref": "#/components/schemas/StatusCategory"
|
"$ref": "#/components/schemas/StatusCategory"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Matches key, title or description",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
@@ -900,7 +900,16 @@ type Query {
|
|||||||
"""
|
"""
|
||||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||||
"""
|
"""
|
||||||
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
|
myIssues(
|
||||||
|
assignedToMe: Boolean! = true
|
||||||
|
categories: [StatusCategory!]
|
||||||
|
limit: Int! = 50
|
||||||
|
offset: Int! = 0
|
||||||
|
|
||||||
|
"""Matches key, title or description (global search)"""
|
||||||
|
search: String
|
||||||
|
statuses: [String!]
|
||||||
|
): IssuePage!
|
||||||
|
|
||||||
"""My notifications, newest first, and how many are unread"""
|
"""My notifications, newest first, and how many are unread"""
|
||||||
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!
|
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!
|
||||||
|
|||||||
@@ -3724,6 +3724,8 @@ export interface operations {
|
|||||||
status?: string[];
|
status?: string[];
|
||||||
/** @description Whatever each project calls its statuses */
|
/** @description Whatever each project calls its statuses */
|
||||||
category?: components["schemas"]["StatusCategory"][];
|
category?: components["schemas"]["StatusCategory"][];
|
||||||
|
/** @description Matches key, title or description */
|
||||||
|
search?: string;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { filter, map } from 'rxjs';
|
|||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||||
import { NotificationsBell } from '@task-stream/web/tracker/presenters';
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
NotificationsBell,
|
||||||
|
} from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-root',
|
selector: 'ts-root',
|
||||||
@@ -23,6 +26,7 @@ import { NotificationsBell } from '@task-stream/web/tracker/presenters';
|
|||||||
AppShellComponent,
|
AppShellComponent,
|
||||||
UserMenuComponent,
|
UserMenuComponent,
|
||||||
NotificationsBell,
|
NotificationsBell,
|
||||||
|
CommandPalette,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<ts-app-shell>
|
<ts-app-shell>
|
||||||
@@ -48,6 +52,7 @@ import { NotificationsBell } from '@task-stream/web/tracker/presenters';
|
|||||||
@if (auth.isAuthenticated()) {
|
@if (auth.isAuthenticated()) {
|
||||||
<!-- One projectable node per @if branch, or neither reaches the slot. -->
|
<!-- One projectable node per @if branch, or neither reaches the slot. -->
|
||||||
<ng-container shellUser>
|
<ng-container shellUser>
|
||||||
|
<ts-command-palette />
|
||||||
<ts-notifications-bell />
|
<ts-notifications-bell />
|
||||||
<ts-user-menu />
|
<ts-user-menu />
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||||
|
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
|
|||||||
@@ -279,12 +279,12 @@ que funcionan también sin token.
|
|||||||
### Queries
|
### Queries
|
||||||
|
|
||||||
| Query | Devuelve |
|
| Query | Devuelve |
|
||||||
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||||
| `user(id)` | Un usuario |
|
| `user(id)` | Un usuario |
|
||||||
| `myProjects` | Proyectos de los que soy miembro |
|
| `myProjects` | Proyectos de los que soy miembro |
|
||||||
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
| `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||||
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||||
| `publicProjects` | Proyectos públicos (sin token) |
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface CrossProjectIssueFilter {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class SearchMyIssuesQueryHandler
|
|||||||
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
assigneeId: query.filter.assignedToMe ? query.viewerId : undefined,
|
||||||
statuses: query.filter.statuses,
|
statuses: query.filter.statuses,
|
||||||
categories: query.filter.categories,
|
categories: query.filter.categories,
|
||||||
|
search: query.filter.search?.trim().slice(0, 200) || undefined,
|
||||||
limit: Math.min(query.filter.limit, 200),
|
limit: Math.min(query.filter.limit, 200),
|
||||||
offset: query.filter.offset,
|
offset: query.filter.offset,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface MyIssuesFilter {
|
|||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
/** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
|
/** Matches key, title or description. */
|
||||||
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
|||||||
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
if (filter.statuses?.length) query['status'] = { $in: filter.statuses };
|
||||||
if (filter.categories?.length)
|
if (filter.categories?.length)
|
||||||
query['statusCategory'] = { $in: filter.categories };
|
query['statusCategory'] = { $in: filter.categories };
|
||||||
|
if (filter.search?.trim()) {
|
||||||
|
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
|
||||||
|
query['$or'] = [
|
||||||
|
{ key: pattern },
|
||||||
|
{ title: pattern },
|
||||||
|
{ description: pattern },
|
||||||
|
];
|
||||||
|
}
|
||||||
const [docs, total] = await Promise.all([
|
const [docs, total] = await Promise.all([
|
||||||
this.issues
|
this.issues
|
||||||
.find(query)
|
.find(query)
|
||||||
|
|||||||
@@ -104,11 +104,18 @@ export class IssueResolver {
|
|||||||
categories: StatusCategory[] | undefined,
|
categories: StatusCategory[] | undefined,
|
||||||
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
|
||||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||||
|
@Args('search', {
|
||||||
|
type: () => String,
|
||||||
|
nullable: true,
|
||||||
|
description: 'Matches key, title or description (global search)',
|
||||||
|
})
|
||||||
|
search?: string | null,
|
||||||
) {
|
) {
|
||||||
return this.issuesService.mine(me.id, {
|
return this.issuesService.mine(me.id, {
|
||||||
assignedToMe,
|
assignedToMe,
|
||||||
statuses: statuses ?? undefined,
|
statuses: statuses ?? undefined,
|
||||||
categories: categories ?? undefined,
|
categories: categories ?? undefined,
|
||||||
|
search: search ?? undefined,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -408,6 +408,12 @@ export class MyIssuesQueryDto extends PageQueryDto {
|
|||||||
@Transform(toList)
|
@Transform(toList)
|
||||||
@IsEnum(StatusCategory, { each: true })
|
@IsEnum(StatusCategory, { each: true })
|
||||||
category?: StatusCategory[];
|
category?: StatusCategory[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpcomingReleasesQueryDto {
|
export class UpcomingReleasesQueryDto {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export class IssuesController {
|
|||||||
assignedToMe: query.assignedToMe,
|
assignedToMe: query.assignedToMe,
|
||||||
statuses: query.status,
|
statuses: query.status,
|
||||||
categories: query.category,
|
categories: query.category,
|
||||||
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
offset: query.offset,
|
offset: query.offset,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from './lib/models/views';
|
|||||||
export * from './lib/models/estimation';
|
export * from './lib/models/estimation';
|
||||||
export * from './lib/models/swimlanes';
|
export * from './lib/models/swimlanes';
|
||||||
export * from './lib/models/roadmap';
|
export * from './lib/models/roadmap';
|
||||||
|
export * from './lib/models/shortcuts';
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface MyIssuesFilter {
|
|||||||
/** true: assigned to me. false: any ticket of my projects. */
|
/** true: assigned to me. false: any ticket of my projects. */
|
||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
|
/** Matches key, title or description. */
|
||||||
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
libs/web/tracker/domain/src/lib/models/shortcuts.spec.ts
Normal file
22
libs/web/tracker/domain/src/lib/models/shortcuts.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { GO_TO, isTyping, projectKeyIn } from './shortcuts';
|
||||||
|
|
||||||
|
describe('shortcuts', () => {
|
||||||
|
it('knows the project of a URL', () => {
|
||||||
|
expect(projectKeyIn('/p/WEB/issues/WEB-3')).toBe('WEB');
|
||||||
|
expect(projectKeyIn('/explorar')).toBeNull();
|
||||||
|
expect(GO_TO['k']).toEqual({ path: 'backlog', inProject: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves keys alone while typing', () => {
|
||||||
|
expect(isTyping({ tagName: 'TEXTAREA' } as unknown as EventTarget)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isTyping({
|
||||||
|
tagName: 'DIV',
|
||||||
|
isContentEditable: false,
|
||||||
|
} as unknown as EventTarget),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isTyping(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
libs/web/tracker/domain/src/lib/models/shortcuts.ts
Normal file
43
libs/web/tracker/domain/src/lib/models/shortcuts.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/** Keys that do something, as the help lists them. */
|
||||||
|
export const SHORTCUTS: { keys: string; what: string; where: string }[] = [
|
||||||
|
{
|
||||||
|
keys: 'Ctrl K · ⌘ K · /',
|
||||||
|
what: 'Buscar tickets y proyectos',
|
||||||
|
where: 'En todas partes',
|
||||||
|
},
|
||||||
|
{ keys: '?', what: 'Ver estos atajos', where: 'En todas partes' },
|
||||||
|
{ keys: 'g p', what: 'Ir a mis proyectos', where: 'En todas partes' },
|
||||||
|
{ keys: 'g e', what: 'Ir a explorar', where: 'En todas partes' },
|
||||||
|
{ keys: 'g b', what: 'Ir al tablero', where: 'Dentro de un proyecto' },
|
||||||
|
{ keys: 'g k', what: 'Ir al backlog', where: 'Dentro de un proyecto' },
|
||||||
|
{ keys: 'g s', what: 'Ir a los sprints', where: 'Dentro de un proyecto' },
|
||||||
|
{ keys: 'g r', what: 'Ir a la hoja de ruta', where: 'Dentro de un proyecto' },
|
||||||
|
{ keys: 'c', what: 'Crear un ticket', where: 'Tablero' },
|
||||||
|
{ keys: 'e', what: 'Editar el ticket', where: 'Ficha de un ticket' },
|
||||||
|
{ keys: 'Esc', what: 'Cerrar', where: 'Búsqueda y ayuda' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Where `g` + key goes; project pages are relative to `/p/KEY`. */
|
||||||
|
export const GO_TO: Record<string, { path: string; inProject: boolean }> = {
|
||||||
|
p: { path: '/', inProject: false },
|
||||||
|
e: { path: '/explorar', inProject: false },
|
||||||
|
b: { path: 'board', inProject: true },
|
||||||
|
k: { path: 'backlog', inProject: true },
|
||||||
|
s: { path: 'sprints', inProject: true },
|
||||||
|
r: { path: 'roadmap', inProject: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Typing in a field is typing, not a shortcut. */
|
||||||
|
export function isTyping(target: EventTarget | null): boolean {
|
||||||
|
const element = target as HTMLElement | null;
|
||||||
|
if (!element?.tagName) return false;
|
||||||
|
return (
|
||||||
|
['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) ||
|
||||||
|
element.isContentEditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project key in a URL like /p/WEB/board, if any. */
|
||||||
|
export function projectKeyIn(url: string): string | null {
|
||||||
|
return /^\/p\/([^/?#]+)/.exec(url)?.[1] ?? null;
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
assignedToMe: filter.assignedToMe,
|
assignedToMe: filter.assignedToMe,
|
||||||
categories: filter.categories as never,
|
categories: filter.categories as never,
|
||||||
limit: filter.limit,
|
limit: filter.limit,
|
||||||
|
search: filter.search?.trim() || null,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
map((d) => ({
|
map((d) => ({
|
||||||
|
|||||||
@@ -879,6 +879,7 @@ export type MyIssuesQueryVariables = Exact<{
|
|||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
|
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
search?: string | null | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type MyIssuesQuery = {
|
export type MyIssuesQuery = {
|
||||||
@@ -5520,6 +5521,14 @@ export const MyIssuesDocument = {
|
|||||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'search' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
@@ -5552,6 +5561,14 @@ export const MyIssuesDocument = {
|
|||||||
name: { kind: 'Name', value: 'limit' },
|
name: { kind: 'Name', value: 'limit' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'search' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'search' },
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
selectionSet: {
|
selectionSet: {
|
||||||
kind: 'SelectionSet',
|
kind: 'SelectionSet',
|
||||||
|
|||||||
@@ -134,11 +134,13 @@ query MyIssues(
|
|||||||
$assignedToMe: Boolean!
|
$assignedToMe: Boolean!
|
||||||
$categories: [StatusCategory!]
|
$categories: [StatusCategory!]
|
||||||
$limit: Int!
|
$limit: Int!
|
||||||
|
$search: String
|
||||||
) {
|
) {
|
||||||
myIssues(
|
myIssues(
|
||||||
assignedToMe: $assignedToMe
|
assignedToMe: $assignedToMe
|
||||||
categories: $categories
|
categories: $categories
|
||||||
limit: $limit
|
limit: $limit
|
||||||
|
search: $search
|
||||||
) {
|
) {
|
||||||
total
|
total
|
||||||
items {
|
items {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './lib/tracker.routes';
|
export * from './lib/tracker.routes';
|
||||||
export * from './lib/ui/notifications-bell';
|
export * from './lib/ui/notifications-bell';
|
||||||
|
export * from './lib/ui/command-palette';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
|
HostListener,
|
||||||
inject,
|
inject,
|
||||||
input,
|
input,
|
||||||
signal,
|
signal,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
boardFilterOf,
|
boardFilterOf,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
|
isTyping,
|
||||||
overWip,
|
overWip,
|
||||||
POINT_SCALE,
|
POINT_SCALE,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
@@ -843,6 +845,23 @@ export class BoardPage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `c`: new ticket (not while typing, nor with a drawer open). */
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
protected onKey(event: KeyboardEvent): void {
|
||||||
|
if (
|
||||||
|
event.key !== 'c' ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey ||
|
||||||
|
isTyping(event.target) ||
|
||||||
|
!this.project.canContribute() ||
|
||||||
|
document.querySelector('[role="dialog"]')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.openCreate();
|
||||||
|
}
|
||||||
|
|
||||||
protected openCreate(): void {
|
protected openCreate(): void {
|
||||||
const types = this.typeOptions();
|
const types = this.typeOptions();
|
||||||
if (!types.some((t) => t.value === this.newType()))
|
if (!types.some((t) => t.value === this.newType()))
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
IssueLink,
|
IssueLink,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
|
isTyping,
|
||||||
matches,
|
matches,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
mentionSegments,
|
mentionSegments,
|
||||||
@@ -1121,6 +1122,23 @@ export class IssuePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A screenshot pasted anywhere on the ticket is attached to it. */
|
/** A screenshot pasted anywhere on the ticket is attached to it. */
|
||||||
|
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
protected onKey(event: KeyboardEvent): void {
|
||||||
|
if (
|
||||||
|
event.key !== 'e' ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey ||
|
||||||
|
isTyping(event.target) ||
|
||||||
|
!this.project.canContribute() ||
|
||||||
|
document.querySelector('[role="dialog"]')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
this.openEdit();
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('document:paste', ['$event'])
|
@HostListener('document:paste', ['$event'])
|
||||||
protected pasted(event: ClipboardEvent): void {
|
protected pasted(event: ClipboardEvent): void {
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
|
|||||||
385
libs/web/tracker/presenters/src/lib/ui/command-palette.ts
Normal file
385
libs/web/tracker/presenters/src/lib/ui/command-palette.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
DestroyRef,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||||
|
import {
|
||||||
|
GO_TO,
|
||||||
|
isTyping,
|
||||||
|
IssuesGateway,
|
||||||
|
IssueSummary,
|
||||||
|
projectKeyIn,
|
||||||
|
SHORTCUTS,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import {
|
||||||
|
catchError,
|
||||||
|
debounceTime,
|
||||||
|
distinctUntilChanged,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
} from 'rxjs';
|
||||||
|
|
||||||
|
interface Result {
|
||||||
|
id: string;
|
||||||
|
kind: 'project' | 'issue';
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global search (Ctrl/⌘ K or /) over my projects and their tickets, and the
|
||||||
|
* keyboard shortcuts of the app (? lists them).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-command-palette',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-nav-link ts-palette__open"
|
||||||
|
title="Buscar (Ctrl K)"
|
||||||
|
(click)="open('search')"
|
||||||
|
>
|
||||||
|
Buscar <kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (mode(); as current) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-palette__backdrop"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
(click)="close()"
|
||||||
|
></button>
|
||||||
|
<div
|
||||||
|
class="ts-palette"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
[attr.aria-label]="current === 'help' ? 'Atajos de teclado' : 'Buscar'"
|
||||||
|
>
|
||||||
|
@if (current === 'search') {
|
||||||
|
<input
|
||||||
|
#search
|
||||||
|
class="ts-palette__input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
|
||||||
|
role="combobox"
|
||||||
|
aria-controls="ts-palette-results"
|
||||||
|
[attr.aria-expanded]="results().length > 0"
|
||||||
|
[attr.aria-activedescendant]="
|
||||||
|
results().length ? 'ts-palette-' + active() : null
|
||||||
|
"
|
||||||
|
[value]="text()"
|
||||||
|
(input)="text.set($any($event.target).value); active.set(0)"
|
||||||
|
(keydown)="onKey($event)"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
id="ts-palette-results"
|
||||||
|
class="ts-palette__results"
|
||||||
|
role="listbox"
|
||||||
|
>
|
||||||
|
@for (result of results(); track result.id; let i = $index) {
|
||||||
|
<li
|
||||||
|
[id]="'ts-palette-' + i"
|
||||||
|
role="option"
|
||||||
|
[attr.aria-selected]="i === active()"
|
||||||
|
[class.is-active]="i === active()"
|
||||||
|
tabindex="-1"
|
||||||
|
(mouseenter)="active.set(i)"
|
||||||
|
(click)="go(result)"
|
||||||
|
(keydown.enter)="go(result)"
|
||||||
|
>
|
||||||
|
<span class="ts-palette__kind">{{
|
||||||
|
result.kind === 'project' ? 'Proyecto' : 'Ticket'
|
||||||
|
}}</span>
|
||||||
|
<span class="ts-palette__label">{{ result.label }}</span>
|
||||||
|
<span class="ts-palette__detail">{{ result.detail }}</span>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="ts-palette__empty" role="presentation">
|
||||||
|
{{
|
||||||
|
text().trim().length < 2
|
||||||
|
? 'Escribe al menos dos letras'
|
||||||
|
: 'Nada encontrado'
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<p class="ts-palette__hint">
|
||||||
|
↑ ↓ para elegir · Enter para abrir · ? atajos · Esc cerrar
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<h2 class="ts-palette__title">Atajos de teclado</h2>
|
||||||
|
<table class="ts-palette__keys">
|
||||||
|
<tbody>
|
||||||
|
@for (shortcut of shortcuts; track shortcut.keys) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<kbd>{{ shortcut.keys }}</kbd>
|
||||||
|
</td>
|
||||||
|
<td>{{ shortcut.what }}</td>
|
||||||
|
<td class="ts-palette__detail">{{ shortcut.where }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-palette__open {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
kbd {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-palette__backdrop {
|
||||||
|
border: 0;
|
||||||
|
cursor: default;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgb(0 0 0 / 35%);
|
||||||
|
}
|
||||||
|
.ts-palette {
|
||||||
|
position: fixed;
|
||||||
|
top: 12vh;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 1001;
|
||||||
|
width: min(40rem, calc(100vw - 2rem));
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--ts-surface);
|
||||||
|
border: 1px solid var(--ts-line);
|
||||||
|
box-shadow: 0 20px 60px rgb(0 0 0 / 30%);
|
||||||
|
color: var(--ts-ink);
|
||||||
|
}
|
||||||
|
.ts-palette__input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--ts-line-strong);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--ts-bg);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.ts-palette__results {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4.5rem minmax(0, 1fr) auto;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
li.is-active {
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ts-palette__kind,
|
||||||
|
.ts-palette__detail,
|
||||||
|
.ts-palette__hint {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.ts-palette__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ts-palette__empty {
|
||||||
|
display: block !important;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
.ts-palette__hint {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
.ts-palette__title {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.ts-palette__keys {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
td {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-top: 1px solid var(--ts-line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class CommandPalette {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly projects = inject(ProjectsFacade);
|
||||||
|
private readonly issues = inject(IssuesGateway);
|
||||||
|
private readonly searchInput =
|
||||||
|
viewChild<ElementRef<HTMLInputElement>>('search');
|
||||||
|
|
||||||
|
protected readonly mode = signal<'search' | 'help' | null>(null);
|
||||||
|
protected readonly text = signal('');
|
||||||
|
protected readonly active = signal(0);
|
||||||
|
protected readonly shortcuts = SHORTCUTS;
|
||||||
|
/** Waiting for the second key of `g …`. */
|
||||||
|
private pendingGo = false;
|
||||||
|
private goTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
private readonly foundIssues = signal<IssueSummary[]>([]);
|
||||||
|
protected readonly results = computed<Result[]>(() => {
|
||||||
|
const query = this.text().trim().toLowerCase();
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
const projects = this.projects
|
||||||
|
.sorted()
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(query) ||
|
||||||
|
p.key.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((p) => ({
|
||||||
|
id: `p:${p.id}`,
|
||||||
|
kind: 'project' as const,
|
||||||
|
label: p.name,
|
||||||
|
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];
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.text)
|
||||||
|
.pipe(
|
||||||
|
map((text) => text.trim()),
|
||||||
|
debounceTime(250),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((search) =>
|
||||||
|
search.length < 2
|
||||||
|
? of([])
|
||||||
|
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
|
||||||
|
map((page) => page.items),
|
||||||
|
catchError(() => of([])),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
takeUntilDestroyed(inject(DestroyRef)),
|
||||||
|
)
|
||||||
|
.subscribe((found) => this.foundIssues.set(found));
|
||||||
|
effect(() => {
|
||||||
|
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
protected onDocumentKey(event: KeyboardEvent): void {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.open('search');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape' && this.mode()) {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.mode() ||
|
||||||
|
isTyping(event.target) ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.altKey
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (this.pendingGo) {
|
||||||
|
this.pendingGo = false;
|
||||||
|
const target = GO_TO[event.key.toLowerCase()];
|
||||||
|
if (target) {
|
||||||
|
event.preventDefault();
|
||||||
|
const key = projectKeyIn(this.router.url);
|
||||||
|
if (!target.inProject) void this.router.navigateByUrl(target.path);
|
||||||
|
else if (key)
|
||||||
|
void this.router.navigateByUrl(`/p/${key}/${target.path}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === '/') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.open('search');
|
||||||
|
} else if (event.key === '?') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.open('help');
|
||||||
|
} else if (event.key === 'g') {
|
||||||
|
this.pendingGo = true;
|
||||||
|
if (this.goTimer) clearTimeout(this.goTimer);
|
||||||
|
this.goTimer = setTimeout(() => (this.pendingGo = false), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open(mode: 'search' | 'help'): void {
|
||||||
|
if (mode === 'search' && !this.projects.sorted().length)
|
||||||
|
this.projects.load();
|
||||||
|
this.mode.set(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected close(): void {
|
||||||
|
this.mode.set(null);
|
||||||
|
this.text.set('');
|
||||||
|
this.active.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKey(event: KeyboardEvent): void {
|
||||||
|
const results = this.results();
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.active.set(Math.min(this.active() + 1, results.length - 1));
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.active.set(Math.max(this.active() - 1, 0));
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const result = results[this.active()];
|
||||||
|
if (result) this.go(result);
|
||||||
|
} else if (event.key === '?' && !this.text()) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.mode.set('help');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected go(result: Result): void {
|
||||||
|
this.close();
|
||||||
|
void this.router.navigateByUrl(result.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user