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:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
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))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user