From d6a434ca1dd6b5b029199d83b9c8df7ddf45709e Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 18:32:07 +0200 Subject: [PATCH] feat: global search and keyboard shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/openapi.json | 9 + apps/api/schema.gql | 11 +- apps/mcp/src/app/api/taskstream.schema.ts | 2 + apps/web/src/app/app.ts | 7 +- docs/01-vision-general.md | 1 + docs/03-api.md | 38 +- .../src/lib/ports/find-issues.repository.ts | 1 + .../queries/search-my-issues.query-handler.ts | 1 + .../src/lib/queries/search-my-issues.query.ts | 2 + .../src/lib/mongo/find.repositories.ts | 8 + .../src/lib/graphql/issue.resolver.ts | 7 + .../src/lib/http/dto/requests.dto.ts | 6 + .../src/lib/http/issues.controller.ts | 1 + libs/web/tracker/domain/src/index.ts | 1 + .../tracker/domain/src/lib/models/inputs.ts | 2 + .../domain/src/lib/models/shortcuts.spec.ts | 22 + .../domain/src/lib/models/shortcuts.ts | 43 ++ .../lib/adapters/graphql-issues.gateway.ts | 1 + .../src/lib/graphql/generated.ts | 17 + .../src/lib/graphql/issues.graphql | 2 + libs/web/tracker/presenters/src/index.ts | 1 + .../presenters/src/lib/pages/board.page.ts | 19 + .../presenters/src/lib/pages/issue.page.ts | 18 + .../presenters/src/lib/ui/command-palette.ts | 385 ++++++++++++++++++ 24 files changed, 584 insertions(+), 21 deletions(-) create mode 100644 libs/web/tracker/domain/src/lib/models/shortcuts.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/shortcuts.ts create mode 100644 libs/web/tracker/presenters/src/lib/ui/command-palette.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 2a276ca..2b91aa0 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2041,6 +2041,15 @@ "$ref": "#/components/schemas/StatusCategory" } } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "Matches key, title or description", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index bf80598..2e688ca 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -900,7 +900,16 @@ type Query { """ 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""" myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index f033a39..a85d885 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -3724,6 +3724,8 @@ export interface operations { status?: string[]; /** @description Whatever each project calls its statuses */ category?: components["schemas"]["StatusCategory"][]; + /** @description Matches key, title or description */ + search?: string; }; header?: never; path?: never; diff --git a/apps/web/src/app/app.ts b/apps/web/src/app/app.ts index 7dd8678..ad1d2bc 100644 --- a/apps/web/src/app/app.ts +++ b/apps/web/src/app/app.ts @@ -11,7 +11,10 @@ import { filter, map } from 'rxjs'; import { AuthFacade } from '@task-stream/web/identity/application'; import { UserMenuComponent } from '@task-stream/web/identity/presenters'; 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({ selector: 'ts-root', @@ -23,6 +26,7 @@ import { NotificationsBell } from '@task-stream/web/tracker/presenters'; AppShellComponent, UserMenuComponent, NotificationsBell, + CommandPalette, ], template: ` @@ -48,6 +52,7 @@ import { NotificationsBell } from '@task-stream/web/tracker/presenters'; @if (auth.isAuthenticated()) { + diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index e833238..18e177a 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -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 | | 📋 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 | +| ⌨️ 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 | | 🧩 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 | diff --git a/docs/03-api.md b/docs/03-api.md index c5407ec..94af509 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -278,25 +278,25 @@ que funcionan también sin token. ### Queries -| Query | Devuelve | -| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `me` | El usuario autenticado, con sus roles en Pireon | -| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | -| `user(id)` | Un usuario | -| `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]`) | -| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | -| `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` | -| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | -| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir | -| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto | -| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` | -| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto | -| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | -| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | -| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | -| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | +| Query | Devuelve | +| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `me` | El usuario autenticado, con sus roles en Pireon | +| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) | +| `user(id)` | Un usuario | +| `myProjects` | Proyectos de los que soy miembro | +| `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 | +| `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` | +| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | +| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir | +| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto | +| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` | +| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto | +| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | +| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | +| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | +| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | ### Mutations diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index b38128a..6a9ed71 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -43,6 +43,7 @@ export interface CrossProjectIssueFilter { assigneeId?: string; statuses?: string[]; categories?: StatusCategory[]; + search?: string; limit: number; offset: number; } diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts index 044a40d..0242526 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts @@ -21,6 +21,7 @@ export class SearchMyIssuesQueryHandler assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, statuses: query.filter.statuses, categories: query.filter.categories, + search: query.filter.search?.trim().slice(0, 200) || undefined, limit: Math.min(query.filter.limit, 200), offset: query.filter.offset, }); diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts index 87b539c..3c6d8c2 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts @@ -11,6 +11,8 @@ export interface MyIssuesFilter { statuses?: string[]; /** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */ categories?: StatusCategory[]; + /** Matches key, title or description. */ + search?: string; limit: number; offset: number; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index cd47f37..a166bba 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -161,6 +161,14 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.categories?.length) 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([ this.issues .find(query) diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 7c1142a..c462809 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -104,11 +104,18 @@ export class IssueResolver { categories: StatusCategory[] | undefined, @Args('limit', { type: () => Int, defaultValue: 50 }) limit: 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, { assignedToMe, statuses: statuses ?? undefined, categories: categories ?? undefined, + search: search ?? undefined, limit, offset, }); diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 2ecfe79..7a7344c 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -408,6 +408,12 @@ export class MyIssuesQueryDto extends PageQueryDto { @Transform(toList) @IsEnum(StatusCategory, { each: true }) category?: StatusCategory[]; + + @ApiPropertyOptional({ description: 'Matches key, title or description' }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; } export class UpcomingReleasesQueryDto { diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index cf7c6a1..00f8929 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -130,6 +130,7 @@ export class IssuesController { assignedToMe: query.assignedToMe, statuses: query.status, categories: query.category, + search: query.search, limit: query.limit, offset: query.offset, }); diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index 5ef8445..6febb1b 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -9,3 +9,4 @@ export * from './lib/models/views'; export * from './lib/models/estimation'; export * from './lib/models/swimlanes'; export * from './lib/models/roadmap'; +export * from './lib/models/shortcuts'; diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 2b8d015..d416f04 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -73,6 +73,8 @@ export interface MyIssuesFilter { /** true: assigned to me. false: any ticket of my projects. */ assignedToMe: boolean; categories?: StatusCategory[]; + /** Matches key, title or description. */ + search?: string; limit: number; } diff --git a/libs/web/tracker/domain/src/lib/models/shortcuts.spec.ts b/libs/web/tracker/domain/src/lib/models/shortcuts.spec.ts new file mode 100644 index 0000000..04e15fe --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/shortcuts.spec.ts @@ -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); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/shortcuts.ts b/libs/web/tracker/domain/src/lib/models/shortcuts.ts new file mode 100644 index 0000000..b9bef9f --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/shortcuts.ts @@ -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 = { + 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; +} diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index af005a7..6316158 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -87,6 +87,7 @@ export class GraphqlIssuesGateway extends IssuesGateway { assignedToMe: filter.assignedToMe, categories: filter.categories as never, limit: filter.limit, + search: filter.search?.trim() || null, }) .pipe( map((d) => ({ diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 46aad04..5b6c2a6 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -879,6 +879,7 @@ export type MyIssuesQueryVariables = Exact<{ assignedToMe: boolean; categories?: Array | StatusCategory | null | undefined; limit: number; + search?: string | null | undefined; }>; export type MyIssuesQuery = { @@ -5520,6 +5521,14 @@ export const MyIssuesDocument = { 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: { kind: 'SelectionSet', @@ -5552,6 +5561,14 @@ export const MyIssuesDocument = { name: { kind: 'Name', value: 'limit' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'search' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'search' }, + }, + }, ], selectionSet: { kind: 'SelectionSet', diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index e051bcb..acda925 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -134,11 +134,13 @@ query MyIssues( $assignedToMe: Boolean! $categories: [StatusCategory!] $limit: Int! + $search: String ) { myIssues( assignedToMe: $assignedToMe categories: $categories limit: $limit + search: $search ) { total items { diff --git a/libs/web/tracker/presenters/src/index.ts b/libs/web/tracker/presenters/src/index.ts index 10e9366..f65147a 100644 --- a/libs/web/tracker/presenters/src/index.ts +++ b/libs/web/tracker/presenters/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/tracker.routes'; export * from './lib/ui/notifications-bell'; +export * from './lib/ui/command-palette'; diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 6794d9e..d5eb07f 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -9,6 +9,7 @@ import { Component, computed, effect, + HostListener, inject, input, signal, @@ -43,6 +44,7 @@ import { boardFilterOf, IssuePriority, IssueSummary, + isTyping, overWip, POINT_SCALE, 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 { const types = this.typeOptions(); if (!types.some((t) => t.value === this.newType())) diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 77741dd..9d1c6a1 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -43,6 +43,7 @@ import { IssueLink, IssueLinkType, IssuePriority, + isTyping, matches, mentionQuery, mentionSegments, @@ -1121,6 +1122,23 @@ export class IssuePage { } /** 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']) protected pasted(event: ClipboardEvent): void { const target = event.target as HTMLElement | null; diff --git a/libs/web/tracker/presenters/src/lib/ui/command-palette.ts b/libs/web/tracker/presenters/src/lib/ui/command-palette.ts new file mode 100644 index 0000000..c129f09 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/ui/command-palette.ts @@ -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: ` + + + @if (mode(); as current) { + + + } + `, + 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>('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 | null = null; + + private readonly foundIssues = signal([]); + protected readonly results = computed(() => { + 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); + } +}