From 423f82c1a3aa01883a6a9754300e070871617727 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Sat, 17 Oct 2026 09:41:23 +0200 Subject: [PATCH] feat(web): priority, estimate and assignee change right on the board card The card's title is now the link (stretched over the card), and the three chips carry an invisible native select each, so they work by keyboard, on a phone and with a screen reader without opening the ticket. Dragging still works from anywhere on the card. Co-Authored-By: Claude Opus 5.5 --- docs/04-cliente-web.md | 4 +- .../src/lib/events/board.events.ts | 10 + .../src/lib/facades/board.facade.ts | 10 +- .../application/src/lib/stores/board.store.ts | 17 ++ .../presenters/src/lib/pages/board.page.ts | 8 + .../presenters/src/lib/ui/issue-card.ts | 209 +++++++++++++++--- 6 files changed, 225 insertions(+), 33 deletions(-) diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index be9bb29..d463e28 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -118,7 +118,7 @@ Qué gana la página: | `MyViewsFacade` | `MyViewsStore` | ruta `/` (portada) | Mis vistas guardadas de «Mi trabajo» (entre proyectos: proyectos, asignados a mí, que sigo, estados, prioridades, solo bugs, vencidos, texto) y los tickets de la elegida | | `ExploreFacade` | `ExploreStore` | ruta `explore` | Proyectos públicos («Explorar»), también sin sesión | | `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) | -| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo | +| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo | | `ListFacade` | `ListStore` | ruta `list` | Todos los tickets del proyecto en filas: filtros (enviados a la API), cambios de estado, prioridad y persona en la fila, edición en bloque; se recarga tras cada cambio propio y con los de otros | | `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones | @@ -304,7 +304,7 @@ visualización de la plataforma: | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/explore` | Proyectos públicos (sin sesión) | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace) | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno) | | `/p/:key/releases` | Releases | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | diff --git a/libs/web/tracker/application/src/lib/events/board.events.ts b/libs/web/tracker/application/src/lib/events/board.events.ts index 135d575..6e2a6af 100644 --- a/libs/web/tracker/application/src/lib/events/board.events.ts +++ b/libs/web/tracker/application/src/lib/events/board.events.ts @@ -6,9 +6,16 @@ import { CommandResult, CreateIssueInput, IssueChanged, + IssuePriority, SavedFilter, } from '@task-stream/web/tracker/domain'; +/** What a card can change without opening the ticket. */ +export type CardChange = + | { priority: IssuePriority } + | { storyPoints: number | null } + | { assigneeId: string | null }; + export const boardPageEvents = eventGroup({ source: 'Board Page', events: { @@ -21,6 +28,8 @@ export const boardPageEvents = eventGroup({ reason?: string | null; }>(), issueCreateRequested: type(), + /** From the card: priority, estimate or assignee (the live update repaints it). */ + issueEditRequested: type<{ issueId: string; change: CardChange }>(), /** Save the current filter under a name. */ viewSaveRequested: type<{ name: string; shared: boolean }>(), viewDeleteRequested: type<{ id: string }>(), @@ -33,6 +42,7 @@ export const boardApiEvents = eventGroup({ loaded: type(), loadFailed: type(), issueMoveFailed: type(), + issueEditFailed: type(), issueCreated: type(), issueCreateFailed: type(), viewsLoaded: type(), diff --git a/libs/web/tracker/application/src/lib/facades/board.facade.ts b/libs/web/tracker/application/src/lib/facades/board.facade.ts index 68c2114..159a506 100644 --- a/libs/web/tracker/application/src/lib/facades/board.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/board.facade.ts @@ -6,7 +6,7 @@ import { CreateIssueInput, sameCriteria, } from '@task-stream/web/tracker/domain'; -import { boardPageEvents } from '../events/board.events'; +import { boardPageEvents, CardChange } from '../events/board.events'; import { BoardStore } from '../stores/board.store'; /** Kanban board of a project, live. */ @@ -46,6 +46,11 @@ export class BoardFacade { this.dispatch.issueMoved({ issueId, to, reason }); } + /** A change from the card itself, without opening the ticket. */ + editIssue(issueId: string, change: CardChange): void { + this.dispatch.issueEditRequested({ issueId, change }); + } + createIssue(input: CreateIssueInput): void { this.dispatch.issueCreateRequested(input); } @@ -65,3 +70,6 @@ export class BoardFacade { export function provideBoardFacade(): Provider[] { return [BoardStore, BoardFacade]; } + +/** What a card can change by itself (see BoardFacade.editIssue). */ +export type { CardChange } from '../events/board.events'; diff --git a/libs/web/tracker/application/src/lib/stores/board.store.ts b/libs/web/tracker/application/src/lib/stores/board.store.ts index 8ecc6e4..bfb4aaa 100644 --- a/libs/web/tracker/application/src/lib/stores/board.store.ts +++ b/libs/web/tracker/application/src/lib/stores/board.store.ts @@ -186,6 +186,23 @@ export const BoardStore = signalStore( ), ), ), + edit$: events.on(boardPageEvents.issueEditRequested).pipe( + mergeMap(({ payload: { issueId, change } }) => + ('assigneeId' in change + ? issues.assign(issueId, change.assigneeId) + : issues.update({ issueId, ...change }) + ).pipe( + mapResponse({ + next: () => null, + error: (error) => + boardApiEvents.issueEditFailed(describeError(error)), + }), + ), + ), + ), + editFailed$: events + .on(boardApiEvents.issueEditFailed) + .pipe(tap(({ payload }) => notifications.error(payload))), /** The API refused the move: tell the user and put the card back. */ rollback$: events.on(boardApiEvents.issueMoveFailed).pipe( tap(({ payload }) => notifications.error(payload)), 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 09888ed..5e892f0 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -312,6 +312,9 @@ const NOBODY = '__nobody__'; [cdkDragData]="issue" [issue]="issue" [workflow]="project.workflow()" + [editable]="project.canContribute()" + [members]="cardMembers()" + (edited)="board.editIssue(issue.id, $event)" /> } @empty {

Nada por aquí

@@ -613,6 +616,11 @@ export class BoardPage { ...options(PRIORITY), ]; + /** Who a card can be assigned to. */ + protected readonly cardMembers = computed(() => + this.project.members().map((m) => ({ id: m.user.id, name: m.user.name })), + ); + /* Options of the new ticket form: the empty value means nobody / none. */ protected readonly assigneeOptions = computed[]>( () => [ diff --git a/libs/web/tracker/presenters/src/lib/ui/issue-card.ts b/libs/web/tracker/presenters/src/lib/ui/issue-card.ts index 89413d8..5ecd2b9 100644 --- a/libs/web/tracker/presenters/src/lib/ui/issue-card.ts +++ b/libs/web/tracker/presenters/src/lib/ui/issue-card.ts @@ -4,16 +4,35 @@ import { Component, computed, input, + output, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { PiIconComponent } from '@pi/ui'; import { DueState, dueState, + IssuePriority, IssueSummary, + POINT_SCALE, Workflow, } from '@task-stream/web/tracker/domain'; -import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; +import { CardChange } from '@task-stream/web/tracker/application'; +import { + initials, + pointOptions, + PRIORITY, + PRIORITY_LEVEL, + typeLook, +} from './copy'; + +/** Most urgent first, as the menus list them. */ +const PRIORITY_ORDER = [ + IssuePriority.CRITICAL, + IssuePriority.HIGH, + IssuePriority.MEDIUM, + IssuePriority.LOW, + IssuePriority.LOWEST, +]; /** A ticket on the board / in lists: type stripe, key, title, signals. */ @Component({ @@ -21,9 +40,8 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink, PiIconComponent, BlockedComponent], template: ` - @@ -33,21 +51,45 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; {{ issue().key }} @if (issue().blockedBy.length) { - + } @if (issue().area; as area) { {{ areaLabel(area) }} } -

{{ issue().title }}

+ +
{{ + issue().title + }}
- - + + + + + @if (editable()) { + + } @if (issue().parent; as parent) { {{ dueLabel(state) }} } - @if (issue().storyPoints !== null) { - {{ - issue().storyPoints - }} + @if (issue().storyPoints !== null || editable()) { + + {{ issue().storyPoints ?? '–' }} + @if (editable()) { + + } + } @if (issue().release; as release) { } - @if (issue().assignee; as assignee) { - {{ - initialsOf(assignee.name) - }} - } @else { - — - } + + @if (issue().assignee; as assignee) { + {{ + initialsOf(assignee.name) + }} + } @else { + — + } + @if (editable()) { + + } +
- + `, styles: ` :host { @@ -156,10 +240,6 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; color-mix(in srgb, var(--type) 70%, transparent); transform: translateY(-1px); } - &:focus-visible { - outline: 2px solid var(--type); - outline-offset: 2px; - } } .ts-card__top, .ts-card__bottom { @@ -198,6 +278,51 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy'; font-weight: 500; line-height: 1.35; color: var(--ts-ink); + text-decoration: none; + &::after { + content: ''; + position: absolute; + inset: 0; + } + &:focus-visible { + outline: none; + } + } + .ts-card:has(.ts-card__title:focus-visible) { + outline: 2px solid var(--type); + outline-offset: 2px; + } + /* Above the stretched link: what can be clicked on its own. */ + .ts-card__above, + .ts-card__edit.is-editable { + position: relative; + z-index: 1; + } + /* A native select laid invisibly over the chip: keyboard, phone and + screen reader for free, never clipped by the card. */ + .ts-card__edit { + display: inline-flex; + align-items: center; + select { + position: absolute; + inset: -0.25rem; + width: calc(100% + 0.5rem); + height: calc(100% + 0.5rem); + opacity: 0; + cursor: pointer; + font-size: 1rem; + } + &.is-editable:hover > :first-child, + &.is-editable:focus-within > :first-child { + outline: 2px solid color-mix(in srgb, var(--ts-brand) 60%, transparent); + outline-offset: 2px; + border-radius: 999px; + } + } + .ts-card__points.is-empty { + background: transparent; + border: 1px dashed var(--ts-line-strong); + color: var(--ts-muted); } .ts-card__meta { display: inline-flex; @@ -274,6 +399,30 @@ export class IssueCardComponent { return day.toLocaleDateString('es', { day: 'numeric', month: 'short' }); } readonly issue = input.required(); + /** Priority, estimate and assignee change right on the card. */ + readonly editable = input(false); + /** Who it can be assigned to. */ + readonly members = input<{ id: string; name: string }[]>([]); + /** A change picked on the card. */ + readonly edited = output(); + protected readonly priorities = PRIORITY_ORDER.map((p) => ({ + value: p, + label: PRIORITY[p].label, + })); + protected readonly points = computed(() => + pointOptions(POINT_SCALE, this.issue().storyPoints), + ); + + protected pick( + field: 'priority' | 'storyPoints' | 'assigneeId', + value: string, + ): void { + if (field === 'priority') + this.edited.emit({ priority: value as IssuePriority }); + else if (field === 'storyPoints') + this.edited.emit({ storyPoints: value === '' ? null : Number(value) }); + else this.edited.emit({ assigneeId: value || null }); + } /** The project's settings: names, icons and colours of types and areas. */ readonly workflow = input.required(); protected readonly type = computed(() =>