feat(web): «Deshacer» after moving, reassigning or trashing a ticket
A toast offers to undo for seven seconds (or Ctrl/⌘ Z outside a field) by running the opposite command: the card back to its column or the ticket to its status (the workflow may refuse it), the previous assignee, or the ticket back from the trash. The undo itself only says «Deshecho». On the board, from the card's assignee; on the ticket page, status, assignee and trash, which no longer asks for confirmation. The toast is the app's own (UndoToastComponent): pi-snack-bar cannot tell its button from the timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 |
|
| 💬 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 |
|
| 🔎 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 |
|
| 📋 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 |
|
| 🚦 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 |
|
| 🎲 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 |
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ API repartida en `libs/web/*`.
|
|||||||
| Librería | Contenido |
|
| Librería | Contenido |
|
||||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `web/shared/infrastructure` | Cliente Apollo (`provideGraphql`: HTTP + `graphql-ws`), `API_CONFIG`, puerto `AccessTokenSource` |
|
| `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/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/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` |
|
| `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 |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| `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 |
|
| `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» |
|
| `/` | 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 |
|
| `/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) |
|
| `/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/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=<id>` lleva a un comentario y lo señala |
|
| `/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=<id>` 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/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/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 |
|
| `/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 |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './lib/pi-ui.providers';
|
export * from './lib/pi-ui.providers';
|
||||||
export * from './lib/appearance';
|
export * from './lib/appearance';
|
||||||
export * from './lib/toast.service';
|
export * from './lib/toast.service';
|
||||||
|
export * from './lib/undo-toast';
|
||||||
export * from './lib/app-shell';
|
export * from './lib/app-shell';
|
||||||
export * from './lib/icons';
|
export * from './lib/icons';
|
||||||
export * from './lib/drawer.service';
|
export * from './lib/drawer.service';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
import { PiSnackBarComponent } from '@pi/ui';
|
import { PiSnackBarComponent } from '@pi/ui';
|
||||||
|
import { UndoToastComponent } from './undo-toast';
|
||||||
|
|
||||||
type ToastType = 'success' | 'error' | 'info';
|
type ToastType = 'success' | 'error' | 'info';
|
||||||
|
|
||||||
@@ -21,6 +22,23 @@ export class ToastService {
|
|||||||
this.open(message, 'info', 'info', 3000);
|
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(
|
private open(
|
||||||
message: string,
|
message: string,
|
||||||
type: ToastType,
|
type: ToastType,
|
||||||
|
|||||||
120
libs/web/shared/ui/src/lib/undo-toast.ts
Normal file
120
libs/web/shared/ui/src/lib/undo-toast.ts
Normal file
@@ -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: `
|
||||||
|
<span class="ts-undo__message">{{ data.message }}</span>
|
||||||
|
<button type="button" class="ts-undo__button" (click)="undo()">
|
||||||
|
Deshacer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-undo__close"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
(click)="ref.dismiss()"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
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<UndoToastData>(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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,10 +26,19 @@ export const boardPageEvents = eventGroup({
|
|||||||
issueId: string;
|
issueId: string;
|
||||||
to: string;
|
to: string;
|
||||||
reason?: string | null;
|
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<CreateIssueInput>(),
|
issueCreateRequested: type<CreateIssueInput>(),
|
||||||
/** From the card: priority, estimate or assignee (the live update repaints it). */
|
/** 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. */
|
/** Save the current filter under a name. */
|
||||||
viewSaveRequested: type<{ name: string; shared: boolean }>(),
|
viewSaveRequested: type<{ name: string; shared: boolean }>(),
|
||||||
viewDeleteRequested: type<{ id: string }>(),
|
viewDeleteRequested: type<{ id: string }>(),
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ export const issuePageEvents = eventGroup({
|
|||||||
source: 'Issue Page',
|
source: 'Issue Page',
|
||||||
events: {
|
events: {
|
||||||
opened: type<{ idOrKey: string }>(),
|
opened: type<{ idOrKey: string }>(),
|
||||||
statusChangeRequested: type<ChangeStatusInput>(),
|
/** `undoing`: it is the «Deshacer» of a change, which offers none. */
|
||||||
assignRequested: type<{ issueId: string; assigneeId: string | null }>(),
|
statusChangeRequested: type<ChangeStatusInput & { undoing?: boolean }>(),
|
||||||
|
assignRequested: type<{
|
||||||
|
issueId: string;
|
||||||
|
assigneeId: string | null;
|
||||||
|
undoing?: boolean;
|
||||||
|
}>(),
|
||||||
updateRequested: type<UpdateIssueInput>(),
|
updateRequested: type<UpdateIssueInput>(),
|
||||||
linkRequested: type<LinkIssuesInput>(),
|
linkRequested: type<LinkIssuesInput>(),
|
||||||
/** Relate by the other ticket's key (e.g. BE-3), resolved to its id first. */
|
/** 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 }>(),
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
pinRequested: type<{ issueId: string; pinned: 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. */
|
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
|
||||||
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
|
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
|
||||||
subtaskCreateRequested: type<{
|
subtaskCreateRequested: type<{
|
||||||
@@ -90,6 +99,8 @@ export const issueApiEvents = eventGroup({
|
|||||||
commandSucceeded: type<string>(),
|
commandSucceeded: type<string>(),
|
||||||
/** A checklist step was saved (no toast for something so small). */
|
/** A checklist step was saved (no toast for something so small). */
|
||||||
checklistSaved: type<void>(),
|
checklistSaved: type<void>(),
|
||||||
|
/** Changed, and «Deshacer» offered instead of a plain toast. */
|
||||||
|
undoOffered: type<void>(),
|
||||||
commandFailed: type<string>(),
|
commandFailed: type<string>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export class BoardFacade {
|
|||||||
|
|
||||||
/** Optimistic: the card moves at once and comes back if the API refuses. */
|
/** Optimistic: the card moves at once and comes back if the API refuses. */
|
||||||
moveIssue(issueId: string, to: string, reason?: string | null): void {
|
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. */
|
/** A change from the card itself, without opening the ticket. */
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { ProjectFacade, provideProjectFacade } from './project.facade';
|
|||||||
|
|
||||||
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
||||||
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
||||||
const issue = {
|
const issueFake = {
|
||||||
id: 'i1',
|
id: 'i1',
|
||||||
key: 'WEB-1',
|
key: 'WEB-1',
|
||||||
projectId: 'p1',
|
projectId: 'p1',
|
||||||
@@ -54,7 +54,7 @@ function gateways() {
|
|||||||
},
|
},
|
||||||
issues: {
|
issues: {
|
||||||
board: vi.fn(() => of([])),
|
board: vi.fn(() => of([])),
|
||||||
issue: vi.fn(() => of(issue)),
|
issue: vi.fn(() => of(issueFake)),
|
||||||
changeStatus: vi.fn(() => of(ok)),
|
changeStatus: vi.fn(() => of(ok)),
|
||||||
watch: vi.fn(() => of(ok)),
|
watch: vi.fn(() => of(ok)),
|
||||||
children: vi.fn(() => of([])),
|
children: vi.fn(() => of([])),
|
||||||
@@ -130,7 +130,7 @@ function setup() {
|
|||||||
{ provide: SprintsGateway, useValue: fakes.sprints },
|
{ provide: SprintsGateway, useValue: fakes.sprints },
|
||||||
{
|
{
|
||||||
provide: Notifications,
|
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' }) } },
|
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
|
||||||
],
|
],
|
||||||
@@ -348,6 +348,25 @@ describe('IssueFacade', () => {
|
|||||||
expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true);
|
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', () => {
|
it('breaks the ticket down: subtasks, parent and checklist', () => {
|
||||||
const { issue, fakes } = setup();
|
const { issue, fakes } = setup();
|
||||||
// The parent is looked up by its key first.
|
// The parent is looked up by its key first.
|
||||||
@@ -503,7 +522,7 @@ describe('InboxFacade', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: Notifications,
|
provide: Notifications,
|
||||||
useValue: { success: vi.fn(), error: vi.fn() },
|
useValue: { success: vi.fn(), error: vi.fn(), undoable: vi.fn() },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: AuthFacade,
|
provide: AuthFacade,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
export abstract class Notifications {
|
export abstract class Notifications {
|
||||||
abstract success(message: string): void;
|
abstract success(message: string): void;
|
||||||
abstract error(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. */
|
/** Message of a failed GraphQL/HTTP call, for the user. */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mapResponse } from '@ngrx/operators';
|
|||||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||||
import {
|
import {
|
||||||
Events,
|
Events,
|
||||||
|
injectDispatch,
|
||||||
on,
|
on,
|
||||||
withEventHandlers,
|
withEventHandlers,
|
||||||
withReducer,
|
withReducer,
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
boardRealtimeEvents,
|
boardRealtimeEvents,
|
||||||
} from '../events/board.events';
|
} from '../events/board.events';
|
||||||
import { describeError, Notifications } from '../notifications';
|
import { describeError, Notifications } from '../notifications';
|
||||||
import { applyIssueChange, moveIssue } from './board.reducers';
|
import { applyIssueChange, findIssue, moveIssue } from './board.reducers';
|
||||||
|
|
||||||
interface BoardState {
|
interface BoardState {
|
||||||
filter: BoardFilter | null;
|
filter: BoardFilter | null;
|
||||||
@@ -92,6 +93,7 @@ export const BoardStore = signalStore(
|
|||||||
realtime = inject(TrackerRealtimeGateway),
|
realtime = inject(TrackerRealtimeGateway),
|
||||||
notifications = inject(Notifications),
|
notifications = inject(Notifications),
|
||||||
savedFilters = inject(SavedFiltersGateway),
|
savedFilters = inject(SavedFiltersGateway),
|
||||||
|
dispatch = injectDispatch(boardPageEvents),
|
||||||
) => {
|
) => {
|
||||||
const loadViews = (projectId: string) =>
|
const loadViews = (projectId: string) =>
|
||||||
savedFilters.list(projectId).pipe(
|
savedFilters.list(projectId).pipe(
|
||||||
@@ -170,35 +172,68 @@ export const BoardStore = signalStore(
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
move$: events.on(boardPageEvents.issueMoved).pipe(
|
move$: events.on(boardPageEvents.issueMoved).pipe(
|
||||||
mergeMap(({ payload }) =>
|
mergeMap(({ payload }) => {
|
||||||
issues
|
const key = findIssue(store.columns(), payload.issueId)?.key ?? '';
|
||||||
|
return issues
|
||||||
.changeStatus({
|
.changeStatus({
|
||||||
issueId: payload.issueId,
|
issueId: payload.issueId,
|
||||||
status: payload.to,
|
status: payload.to,
|
||||||
reason: payload.reason,
|
reason: payload.reason,
|
||||||
})
|
})
|
||||||
.pipe(
|
.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({
|
mapResponse({
|
||||||
next: () => null,
|
next: () => null,
|
||||||
error: (error) =>
|
error: (error) =>
|
||||||
boardApiEvents.issueMoveFailed(describeError(error)),
|
boardApiEvents.issueMoveFailed(describeError(error)),
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
edit$: events.on(boardPageEvents.issueEditRequested).pipe(
|
edit$: events.on(boardPageEvents.issueEditRequested).pipe(
|
||||||
mergeMap(({ payload: { issueId, change } }) =>
|
mergeMap(({ payload: { issueId, change, undoing } }) => {
|
||||||
('assigneeId' in change
|
// Not optimistic: the card still shows who had it.
|
||||||
? issues.assign(issueId, change.assigneeId)
|
const card = findIssue(store.columns(), issueId);
|
||||||
: issues.update({ issueId, ...change })
|
const before = card?.assignee?.id ?? null;
|
||||||
|
return (
|
||||||
|
'assigneeId' in change
|
||||||
|
? issues.assign(issueId, change.assigneeId)
|
||||||
|
: issues.update({ issueId, ...change })
|
||||||
).pipe(
|
).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({
|
mapResponse({
|
||||||
next: () => null,
|
next: () => null,
|
||||||
error: (error) =>
|
error: (error) =>
|
||||||
boardApiEvents.issueEditFailed(describeError(error)),
|
boardApiEvents.issueEditFailed(describeError(error)),
|
||||||
}),
|
}),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
editFailed$: events
|
editFailed$: events
|
||||||
.on(boardApiEvents.issueEditFailed)
|
.on(boardApiEvents.issueEditFailed)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mapResponse } from '@ngrx/operators';
|
|||||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||||
import {
|
import {
|
||||||
Events,
|
Events,
|
||||||
|
injectDispatch,
|
||||||
on,
|
on,
|
||||||
withEventHandlers,
|
withEventHandlers,
|
||||||
withReducer,
|
withReducer,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
throwError,
|
throwError,
|
||||||
timer,
|
timer,
|
||||||
|
Observable,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {
|
import {
|
||||||
issueApiEvents,
|
issueApiEvents,
|
||||||
@@ -124,7 +126,28 @@ export const IssueStore = signalStore(
|
|||||||
notifications = inject(Notifications),
|
notifications = inject(Notifications),
|
||||||
attachments = inject(AttachmentsGateway),
|
attachments = inject(AttachmentsGateway),
|
||||||
sprints = inject(SprintsGateway),
|
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<unknown>,
|
||||||
|
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. */
|
/** One file after another: the ticket's version moves with each. */
|
||||||
const uploadAll = (
|
const uploadAll = (
|
||||||
issueId: string,
|
issueId: string,
|
||||||
@@ -247,6 +270,7 @@ export const IssueStore = signalStore(
|
|||||||
events.on(
|
events.on(
|
||||||
issueApiEvents.commandSucceeded,
|
issueApiEvents.commandSucceeded,
|
||||||
issueApiEvents.checklistSaved,
|
issueApiEvents.checklistSaved,
|
||||||
|
issueApiEvents.undoOffered,
|
||||||
issuePageEvents.imagePasted,
|
issuePageEvents.imagePasted,
|
||||||
),
|
),
|
||||||
// Back after a cut: comments or changes may have been missed.
|
// Back after a cut: comments or changes may have been missed.
|
||||||
@@ -285,30 +309,41 @@ export const IssueStore = signalStore(
|
|||||||
map((change) => issueRealtimeEvents.commentChanged(change)),
|
map((change) => issueRealtimeEvents.commentChanged(change)),
|
||||||
),
|
),
|
||||||
commands$: merge(
|
commands$: merge(
|
||||||
events
|
events.on(issuePageEvents.statusChangeRequested).pipe(
|
||||||
.on(issuePageEvents.statusChangeRequested)
|
mergeMap(({ payload: { undoing, ...input } }) => {
|
||||||
.pipe(
|
// The page waits for the API: the store still has the old one.
|
||||||
mergeMap(({ payload }) =>
|
const before = store.issue()?.status;
|
||||||
runCommand(
|
return runUndoable(
|
||||||
issues.changeStatus(payload),
|
issues.changeStatus(input),
|
||||||
'Estado actualizado',
|
'Estado actualizado',
|
||||||
done,
|
undoing || !before || before === input.status
|
||||||
failed,
|
? null
|
||||||
),
|
: () =>
|
||||||
),
|
dispatch.statusChangeRequested({
|
||||||
),
|
issueId: input.issueId,
|
||||||
events
|
status: before,
|
||||||
.on(issuePageEvents.assignRequested)
|
undoing: true,
|
||||||
.pipe(
|
}),
|
||||||
mergeMap(({ payload }) =>
|
);
|
||||||
runCommand(
|
}),
|
||||||
issues.assign(payload.issueId, payload.assigneeId),
|
),
|
||||||
payload.assigneeId ? 'Asignado' : 'Sin asignar',
|
events.on(issuePageEvents.assignRequested).pipe(
|
||||||
done,
|
mergeMap(({ payload }) => {
|
||||||
failed,
|
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
|
events
|
||||||
.on(issuePageEvents.updateRequested)
|
.on(issuePageEvents.updateRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -321,22 +356,29 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
events
|
events.on(issuePageEvents.trashRequested).pipe(
|
||||||
.on(issuePageEvents.trashRequested)
|
mergeMap(({ payload }) =>
|
||||||
.pipe(
|
payload.trashed
|
||||||
mergeMap(({ payload }) =>
|
? runUndoable(
|
||||||
runCommand(
|
issues.trash(payload.issueId),
|
||||||
payload.trashed
|
'Ticket en la papelera',
|
||||||
? issues.trash(payload.issueId)
|
payload.undoing
|
||||||
: issues.restore(payload.issueId),
|
? null
|
||||||
payload.trashed
|
: () =>
|
||||||
? 'Ticket en la papelera'
|
dispatch.trashRequested({
|
||||||
: 'Ticket recuperado',
|
issueId: payload.issueId,
|
||||||
done,
|
trashed: false,
|
||||||
failed,
|
undoing: true,
|
||||||
),
|
}),
|
||||||
),
|
)
|
||||||
|
: runCommand(
|
||||||
|
issues.restore(payload.issueId),
|
||||||
|
payload.undoing ? 'Deshecho' : 'Ticket recuperado',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
events
|
events
|
||||||
.on(issuePageEvents.pinRequested)
|
.on(issuePageEvents.pinRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -1599,13 +1599,9 @@ export class IssuePage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** No confirmation: the toast offers «Deshacer», and the trash keeps it 30 days. */
|
||||||
protected trash(): void {
|
protected trash(): void {
|
||||||
if (
|
this.facade.setTrashed(true);
|
||||||
confirm(
|
|
||||||
'¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
this.facade.setTrashed(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
|
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
|
||||||
|
|||||||
Reference in New Issue
Block a user