diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 4e78645..bbbedf3 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -17,6 +17,7 @@ | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera | | 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | | 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | +| ↩️ Deshacer | Mover, reasignar o mandar a la papelera ofrece «Deshacer» unos segundos (también Ctrl/⌘ Z), con la orden contraria | | 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | | 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | | 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index af2d217..ca5243f 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -8,7 +8,7 @@ API repartida en `libs/web/*`. | Librería | Contenido | | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` | -| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService`, `DrawerService`, `AppShellComponent` | +| `web/shared/ui` | Integración de `@pi/ui` (`providePiUi`), modo oscuro, iconos, `ToastService` (con `undoable`: aviso con «Deshacer», `UndoToastComponent`), `DrawerService`, `AppShellComponent` | | `web/identity/domain` | `Session`, `CurrentUser`, `AuthConfig` y puertos `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow` | | `web/identity/application` | `AuthFacade` (pública); por dentro `AuthStore` y eventos `authPageEvents`, `authApiEvents`, `sessionEvents` | | `web/identity/infrastructure` | `/api/auth/*` (HTTP), `me` (GraphQL), `localStorage`, PKCE con Web Crypto; enlaza `AccessTokenSource` con la `AuthFacade` | @@ -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, prioridad, estimación y persona cambiadas desde la tarjeta, nuevo ticket, cambios en vivo | +| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista (con «Deshacer»), 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,8 +304,8 @@ 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), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), 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). 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). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala | +| `/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). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | +| `/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). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer» | | `/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`) | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | diff --git a/libs/web/shared/ui/src/index.ts b/libs/web/shared/ui/src/index.ts index 7355e81..a81f563 100644 --- a/libs/web/shared/ui/src/index.ts +++ b/libs/web/shared/ui/src/index.ts @@ -1,6 +1,7 @@ export * from './lib/pi-ui.providers'; export * from './lib/appearance'; export * from './lib/toast.service'; +export * from './lib/undo-toast'; export * from './lib/app-shell'; export * from './lib/icons'; export * from './lib/drawer.service'; diff --git a/libs/web/shared/ui/src/lib/toast.service.ts b/libs/web/shared/ui/src/lib/toast.service.ts index 02332b5..2aaf37d 100644 --- a/libs/web/shared/ui/src/lib/toast.service.ts +++ b/libs/web/shared/ui/src/lib/toast.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { PiSnackBarComponent } from '@pi/ui'; +import { UndoToastComponent } from './undo-toast'; type ToastType = 'success' | 'error' | 'info'; @@ -21,6 +22,23 @@ export class ToastService { this.open(message, 'info', 'info', 3000); } + /** + * Done, with «Deshacer» for a few seconds (also Ctrl/⌘ Z): `undo` runs if + * the person asks for it before the toast goes. + */ + undoable(message: string, undo: () => void): void { + this.snackBar + .openFromComponent(UndoToastComponent, { + data: { message }, + duration: 7000, + horizontalPosition: 'end', + verticalPosition: 'bottom', + panelClass: 'ts-undo-toast', + }) + .onAction() + .subscribe(() => undo()); + } + private open( message: string, type: ToastType, diff --git a/libs/web/shared/ui/src/lib/undo-toast.ts b/libs/web/shared/ui/src/lib/undo-toast.ts new file mode 100644 index 0000000..6380392 --- /dev/null +++ b/libs/web/shared/ui/src/lib/undo-toast.ts @@ -0,0 +1,120 @@ +import { + ChangeDetectionStrategy, + Component, + HostListener, + inject, +} from '@angular/core'; +import { + MAT_SNACK_BAR_DATA, + MatSnackBarRef, +} from '@angular/material/snack-bar'; + +/** What an undoable toast says. */ +export interface UndoToastData { + message: string; +} + +/** + * A toast with «Deshacer» (or Ctrl/⌘ Z while it shows). `pi-snack-bar` + * cannot tell its button from the timeout, so this one is the app's own; it + * closes with an action when undone. + */ +@Component({ + selector: 'ts-undo-toast', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + {{ data.message }} + + + `, + styles: ` + /* A solid surface: it must stand out over the board in both themes. */ + :host { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.625rem 0.625rem 1.25rem; + border: 1px solid var(--ts-line-strong, rgb(0 0 0 / 15%)); + border-radius: var( + --pi-theme-notification-radius, + var(--pi-border-radius-large, 0.75rem) + ); + background-color: var(--ts-surface, #fff); + box-shadow: 0 14px 32px -14px rgb(0 0 0 / 40%); + color: var(--ts-ink, inherit); + font-weight: 500; + } + .ts-undo__button { + color: var(--ts-brand, var(--pi-color-primary)); + } + .ts-undo__message { + flex: 1; + min-width: 0; + } + /* Plain buttons, not @pi/ui's sized ones. */ + button { + min-width: 0 !important; + height: auto !important; + padding: 0.25rem 0.5rem !important; + border: 0; + border-radius: 0.375rem; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + &:hover, + &:focus-visible { + background: color-mix(in srgb, currentColor 12%, transparent); + } + } + .ts-undo__button { + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + } + .ts-undo__close { + font-size: 1.125rem; + line-height: 1; + } + `, +}) +export class UndoToastComponent { + protected readonly data = inject(MAT_SNACK_BAR_DATA); + protected readonly ref = inject(MatSnackBarRef); + + protected undo(): void { + this.ref.dismissWithAction(); + } + + @HostListener('document:keydown', ['$event']) + protected onKey(event: KeyboardEvent): void { + if ( + (event.ctrlKey || event.metaKey) && + !event.shiftKey && + event.key.toLowerCase() === 'z' && + !isTyping(event.target) + ) { + event.preventDefault(); + this.undo(); + } + } +} + +/** Ctrl Z in a field undoes the typing, not the last action. */ +function isTyping(target: EventTarget | null): boolean { + const element = target as HTMLElement | null; + return ( + !!element && + (element.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)) + ); +} 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 6e2a6af..e9cf2ed 100644 --- a/libs/web/tracker/application/src/lib/events/board.events.ts +++ b/libs/web/tracker/application/src/lib/events/board.events.ts @@ -26,10 +26,19 @@ export const boardPageEvents = eventGroup({ issueId: string; to: string; reason?: string | null; + /** Where it was: «Deshacer» takes it back. */ + from?: string; + /** It is the «Deshacer» of a move: no undo for it. */ + undoing?: boolean; }>(), issueCreateRequested: type(), /** From the card: priority, estimate or assignee (the live update repaints it). */ - issueEditRequested: type<{ issueId: string; change: CardChange }>(), + issueEditRequested: type<{ + issueId: string; + change: CardChange; + /** It is the «Deshacer» of a change: no undo for it. */ + undoing?: boolean; + }>(), /** Save the current filter under a name. */ viewSaveRequested: type<{ name: string; shared: boolean }>(), viewDeleteRequested: type<{ id: string }>(), diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index 89e4ba7..a97a286 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -15,8 +15,13 @@ export const issuePageEvents = eventGroup({ source: 'Issue Page', events: { opened: type<{ idOrKey: string }>(), - statusChangeRequested: type(), - assignRequested: type<{ issueId: string; assigneeId: string | null }>(), + /** `undoing`: it is the «Deshacer» of a change, which offers none. */ + statusChangeRequested: type(), + assignRequested: type<{ + issueId: string; + assigneeId: string | null; + undoing?: boolean; + }>(), updateRequested: type(), linkRequested: type(), /** Relate by the other ticket's key (e.g. BE-3), resolved to its id first. */ @@ -44,7 +49,11 @@ export const issuePageEvents = eventGroup({ }>(), watchRequested: type<{ issueId: string; watching: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(), - trashRequested: type<{ issueId: string; trashed: boolean }>(), + trashRequested: type<{ + issueId: string; + trashed: boolean; + undoing?: boolean; + }>(), /** Place the ticket under another one by its key (e.g. WEB-3); null: out. */ parentRequested: type<{ issueId: string; parentKey: string | null }>(), subtaskCreateRequested: type<{ @@ -90,6 +99,8 @@ export const issueApiEvents = eventGroup({ commandSucceeded: type(), /** A checklist step was saved (no toast for something so small). */ checklistSaved: type(), + /** Changed, and «Deshacer» offered instead of a plain toast. */ + undoOffered: type(), commandFailed: 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 159a506..f2c961f 100644 --- a/libs/web/tracker/application/src/lib/facades/board.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/board.facade.ts @@ -43,7 +43,10 @@ export class BoardFacade { /** Optimistic: the card moves at once and comes back if the API refuses. */ moveIssue(issueId: string, to: string, reason?: string | null): void { - this.dispatch.issueMoved({ issueId, to, reason }); + const from = this.store + .columns() + .find((column) => column.issues.some((i) => i.id === issueId))?.status; + this.dispatch.issueMoved({ issueId, to, reason, from }); } /** A change from the card itself, without opening the ticket. */ diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index cf3b6fd..5f2e057 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -30,7 +30,7 @@ import { ProjectFacade, provideProjectFacade } from './project.facade'; const ok = { id: 'x', key: null, aggregateVersion: 1 }; const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project; -const issue = { +const issueFake = { id: 'i1', key: 'WEB-1', projectId: 'p1', @@ -54,7 +54,7 @@ function gateways() { }, issues: { board: vi.fn(() => of([])), - issue: vi.fn(() => of(issue)), + issue: vi.fn(() => of(issueFake)), changeStatus: vi.fn(() => of(ok)), watch: vi.fn(() => of(ok)), children: vi.fn(() => of([])), @@ -130,7 +130,7 @@ function setup() { { provide: SprintsGateway, useValue: fakes.sprints }, { provide: Notifications, - useValue: { success: vi.fn(), error: vi.fn() }, + useValue: { success: vi.fn(), error: vi.fn(), undoable: vi.fn() }, }, { provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } }, ], @@ -348,6 +348,25 @@ describe('IssueFacade', () => { expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true); }); + it('offers to undo a move, and the undo goes back without offering another', () => { + const { issue, fakes, notifications } = setup(); + fakes.issues.issue.mockImplementation((() => + of({ ...issueFake, status: 'TODO' })) as never); + issue.open('WEB-1'); + issue.changeStatus('IN_PROGRESS'); + + const undoable = vi.mocked(notifications.undoable); + expect(undoable).toHaveBeenCalledTimes(1); + expect(notifications.success).not.toHaveBeenCalled(); + undoable.mock.calls[0][1](); + expect(fakes.issues.changeStatus).toHaveBeenLastCalledWith({ + issueId: 'i1', + status: 'TODO', + }); + expect(undoable).toHaveBeenCalledTimes(1); + expect(notifications.success).toHaveBeenCalledWith('Deshecho'); + }); + it('breaks the ticket down: subtasks, parent and checklist', () => { const { issue, fakes } = setup(); // The parent is looked up by its key first. @@ -503,7 +522,7 @@ describe('InboxFacade', () => { }, { provide: Notifications, - useValue: { success: vi.fn(), error: vi.fn() }, + useValue: { success: vi.fn(), error: vi.fn(), undoable: vi.fn() }, }, { provide: AuthFacade, diff --git a/libs/web/tracker/application/src/lib/notifications.ts b/libs/web/tracker/application/src/lib/notifications.ts index 0cd457b..72e547f 100644 --- a/libs/web/tracker/application/src/lib/notifications.ts +++ b/libs/web/tracker/application/src/lib/notifications.ts @@ -2,6 +2,8 @@ export abstract class Notifications { abstract success(message: string): void; abstract error(message: string): void; + /** Done, offering to undo it for a few seconds: `undo` runs if asked. */ + abstract undoable(message: string, undo: () => void): void; } /** Message of a failed GraphQL/HTTP call, for the user. */ 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 bfb4aaa..b4cc21a 100644 --- a/libs/web/tracker/application/src/lib/stores/board.store.ts +++ b/libs/web/tracker/application/src/lib/stores/board.store.ts @@ -3,6 +3,7 @@ import { mapResponse } from '@ngrx/operators'; import { signalStore, withComputed, withState } from '@ngrx/signals'; import { Events, + injectDispatch, on, withEventHandlers, withReducer, @@ -33,7 +34,7 @@ import { boardRealtimeEvents, } from '../events/board.events'; import { describeError, Notifications } from '../notifications'; -import { applyIssueChange, moveIssue } from './board.reducers'; +import { applyIssueChange, findIssue, moveIssue } from './board.reducers'; interface BoardState { filter: BoardFilter | null; @@ -92,6 +93,7 @@ export const BoardStore = signalStore( realtime = inject(TrackerRealtimeGateway), notifications = inject(Notifications), savedFilters = inject(SavedFiltersGateway), + dispatch = injectDispatch(boardPageEvents), ) => { const loadViews = (projectId: string) => savedFilters.list(projectId).pipe( @@ -170,35 +172,68 @@ export const BoardStore = signalStore( }), ), move$: events.on(boardPageEvents.issueMoved).pipe( - mergeMap(({ payload }) => - issues + mergeMap(({ payload }) => { + const key = findIssue(store.columns(), payload.issueId)?.key ?? ''; + return issues .changeStatus({ issueId: payload.issueId, status: payload.to, reason: payload.reason, }) .pipe( + tap(() => { + const { from } = payload; + if (payload.undoing) notifications.success('Deshecho'); + else if (from && from !== payload.to) + notifications.undoable(`${key} movido`, () => + dispatch.issueMoved({ + issueId: payload.issueId, + to: from, + undoing: true, + }), + ); + }), mapResponse({ next: () => null, error: (error) => boardApiEvents.issueMoveFailed(describeError(error)), }), - ), - ), + ); + }), ), edit$: events.on(boardPageEvents.issueEditRequested).pipe( - mergeMap(({ payload: { issueId, change } }) => - ('assigneeId' in change - ? issues.assign(issueId, change.assigneeId) - : issues.update({ issueId, ...change }) + mergeMap(({ payload: { issueId, change, undoing } }) => { + // Not optimistic: the card still shows who had it. + const card = findIssue(store.columns(), issueId); + const before = card?.assignee?.id ?? null; + return ( + 'assigneeId' in change + ? issues.assign(issueId, change.assigneeId) + : issues.update({ issueId, ...change }) ).pipe( + tap(() => { + if (!('assigneeId' in change) || !card) return; + if (undoing) notifications.success('Deshecho'); + else if (before !== change.assigneeId) + notifications.undoable( + change.assigneeId + ? `${card.key} reasignado` + : `${card.key} sin asignar`, + () => + dispatch.issueEditRequested({ + issueId, + change: { assigneeId: before }, + undoing: true, + }), + ); + }), mapResponse({ next: () => null, error: (error) => boardApiEvents.issueEditFailed(describeError(error)), }), - ), - ), + ); + }), ), editFailed$: events .on(boardApiEvents.issueEditFailed) diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 339ad5c..df7bc59 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -3,6 +3,7 @@ import { mapResponse } from '@ngrx/operators'; import { signalStore, withComputed, withState } from '@ngrx/signals'; import { Events, + injectDispatch, on, withEventHandlers, withReducer, @@ -36,6 +37,7 @@ import { tap, throwError, timer, + Observable, } from 'rxjs'; import { issueApiEvents, @@ -124,7 +126,28 @@ export const IssueStore = signalStore( notifications = inject(Notifications), attachments = inject(AttachmentsGateway), sprints = inject(SprintsGateway), + dispatch = injectDispatch(issuePageEvents), ) => { + /** + * Runs a change that can be taken back: on success, «Deshacer» for a + * few seconds runs `undo`; the undo itself only says it is done. + */ + const runUndoable = ( + command$: Observable, + message: string, + undo: (() => void) | null, + ) => + command$.pipe( + mapResponse({ + next: () => { + if (!undo) return issueApiEvents.commandSucceeded('Deshecho'); + notifications.undoable(message, undo); + return issueApiEvents.undoOffered(); + }, + error: (error) => + issueApiEvents.commandFailed(describeError(error)), + }), + ); /** One file after another: the ticket's version moves with each. */ const uploadAll = ( issueId: string, @@ -247,6 +270,7 @@ export const IssueStore = signalStore( events.on( issueApiEvents.commandSucceeded, issueApiEvents.checklistSaved, + issueApiEvents.undoOffered, issuePageEvents.imagePasted, ), // Back after a cut: comments or changes may have been missed. @@ -285,30 +309,41 @@ export const IssueStore = signalStore( map((change) => issueRealtimeEvents.commentChanged(change)), ), commands$: merge( - events - .on(issuePageEvents.statusChangeRequested) - .pipe( - mergeMap(({ payload }) => - runCommand( - issues.changeStatus(payload), - 'Estado actualizado', - done, - failed, - ), - ), - ), - events - .on(issuePageEvents.assignRequested) - .pipe( - mergeMap(({ payload }) => - runCommand( - issues.assign(payload.issueId, payload.assigneeId), - payload.assigneeId ? 'Asignado' : 'Sin asignar', - done, - failed, - ), - ), - ), + events.on(issuePageEvents.statusChangeRequested).pipe( + mergeMap(({ payload: { undoing, ...input } }) => { + // The page waits for the API: the store still has the old one. + const before = store.issue()?.status; + return runUndoable( + issues.changeStatus(input), + 'Estado actualizado', + undoing || !before || before === input.status + ? null + : () => + dispatch.statusChangeRequested({ + issueId: input.issueId, + status: before, + undoing: true, + }), + ); + }), + ), + events.on(issuePageEvents.assignRequested).pipe( + mergeMap(({ payload }) => { + const before = store.issue()?.assignee?.id ?? null; + return runUndoable( + issues.assign(payload.issueId, payload.assigneeId), + payload.assigneeId ? 'Asignado' : 'Sin asignar', + payload.undoing || before === payload.assigneeId + ? null + : () => + dispatch.assignRequested({ + issueId: payload.issueId, + assigneeId: before, + undoing: true, + }), + ); + }), + ), events .on(issuePageEvents.updateRequested) .pipe( @@ -321,22 +356,29 @@ export const IssueStore = signalStore( ), ), ), - events - .on(issuePageEvents.trashRequested) - .pipe( - mergeMap(({ payload }) => - runCommand( - payload.trashed - ? issues.trash(payload.issueId) - : issues.restore(payload.issueId), - payload.trashed - ? 'Ticket en la papelera' - : 'Ticket recuperado', - done, - failed, - ), - ), + events.on(issuePageEvents.trashRequested).pipe( + mergeMap(({ payload }) => + payload.trashed + ? runUndoable( + issues.trash(payload.issueId), + 'Ticket en la papelera', + payload.undoing + ? null + : () => + dispatch.trashRequested({ + issueId: payload.issueId, + trashed: false, + undoing: true, + }), + ) + : runCommand( + issues.restore(payload.issueId), + payload.undoing ? 'Deshecho' : 'Ticket recuperado', + done, + failed, + ), ), + ), events .on(issuePageEvents.pinRequested) .pipe( 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 b8a3897..a7af7ca 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -1599,13 +1599,9 @@ export class IssuePage { ); } + /** No confirmation: the toast offers «Deshacer», and the trash keeps it 30 days. */ protected trash(): void { - if ( - confirm( - '¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.', - ) - ) - this.facade.setTrashed(true); + this.facade.setTrashed(true); } /** `e`: edit the ticket (not while typing, nor with a drawer open). */