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(() =>