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:
2026-10-17 11:43:54 +02:00
parent 70e4fbd399
commit 42a21412d9
13 changed files with 326 additions and 69 deletions

View File

@@ -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';

View File

@@ -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,

View 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))
);
}

View File

@@ -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<CreateIssueInput>(),
/** 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 }>(),

View File

@@ -15,8 +15,13 @@ export const issuePageEvents = eventGroup({
source: 'Issue Page',
events: {
opened: type<{ idOrKey: string }>(),
statusChangeRequested: type<ChangeStatusInput>(),
assignRequested: type<{ issueId: string; assigneeId: string | null }>(),
/** `undoing`: it is the «Deshacer» of a change, which offers none. */
statusChangeRequested: type<ChangeStatusInput & { undoing?: boolean }>(),
assignRequested: type<{
issueId: string;
assigneeId: string | null;
undoing?: boolean;
}>(),
updateRequested: type<UpdateIssueInput>(),
linkRequested: type<LinkIssuesInput>(),
/** 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<string>(),
/** A checklist step was saved (no toast for something so small). */
checklistSaved: type<void>(),
/** Changed, and «Deshacer» offered instead of a plain toast. */
undoOffered: type<void>(),
commandFailed: type<string>(),
},
});

View File

@@ -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. */

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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)

View File

@@ -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<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. */
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(

View File

@@ -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). */