diff --git a/projects/step-core/src/lib/components/toast-container/toast-container.component.html b/projects/step-core/src/lib/components/toast-container/toast-container.component.html new file mode 100644 index 000000000..c7d157795 --- /dev/null +++ b/projects/step-core/src/lib/components/toast-container/toast-container.component.html @@ -0,0 +1,14 @@ +
+ @for (toast of _toasts(); track toast.message) { + + } +
diff --git a/projects/step-core/src/lib/components/toast-container/toast-container.component.scss b/projects/step-core/src/lib/components/toast-container/toast-container.component.scss new file mode 100644 index 000000000..89ec06de1 --- /dev/null +++ b/projects/step-core/src/lib/components/toast-container/toast-container.component.scss @@ -0,0 +1,9 @@ +.toast-container { + position: fixed; + top: 2rem; + right: 2rem; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 1rem; +} diff --git a/projects/step-core/src/lib/components/toast-container/toast-container.component.ts b/projects/step-core/src/lib/components/toast-container/toast-container.component.ts new file mode 100644 index 000000000..51c9e0f3f --- /dev/null +++ b/projects/step-core/src/lib/components/toast-container/toast-container.component.ts @@ -0,0 +1,11 @@ +import { Component, inject } from '@angular/core'; +import { ToastService } from '../../services/toast.service'; + +@Component({ + selector: 'step-toast-container', + templateUrl: './toast-container.component.html', + styleUrls: ['./toast-container.component.scss'], +}) +export class ToastContainerComponent { + protected readonly _toasts = inject(ToastService).toastMessages; +} diff --git a/projects/step-core/src/lib/components/toast/toast.component.html b/projects/step-core/src/lib/components/toast/toast.component.html new file mode 100644 index 000000000..41abfe055 --- /dev/null +++ b/projects/step-core/src/lib/components/toast/toast.component.html @@ -0,0 +1,25 @@ +@if (message) { +
+
+ @if (entity) { +   + } + + {{ part.displayValue }} + {{ part }} + +
+
+
+ @for (action of actions; track action.label) { + + {{ action.label }} + + } +
+
+ +
+
+
+} diff --git a/projects/step-core/src/lib/components/toast/toast.component.scss b/projects/step-core/src/lib/components/toast/toast.component.scss new file mode 100644 index 000000000..d834caa61 --- /dev/null +++ b/projects/step-core/src/lib/components/toast/toast.component.scss @@ -0,0 +1,72 @@ +@use 'projects/step-core/styles/core-variables' as var; + +.toast { + display: flex; + align-items: center; + background-color: var.$white; + border: 1px solid var.$light-white-gray; + padding: 0.5rem; + border-radius: 1rem; + box-shadow: 0 0.2rem 1rem rgba(0, 0, 0, 0.1); + position: relative; + flex-direction: column; + gap: 0.5rem; + + .action-icons { + padding: 0 0.2rem; + display: flex; + width: 100%; + justify-content: space-between; + } + + .success-icon { + margin-right: 10px; + color: var.$green-650; + } + + .error-icon, + .warning-icon { + margin-right: 10px; + color: var.$red-100; + } + + .info-icon { + margin-right: 10px; + color: black; + } + + .toast-message { + flex: 1; + padding: 1rem 2rem 1rem 1rem; + } + + .toast-bottom { + display: flex; + width: 100%; + justify-content: space-between; + + .toast-actions { + display: flex; + width: 100%; + padding: 0 0.2rem; + justify-content: start; + padding-left: 1rem; + gap: 1rem; + + .action-btn { + cursor: pointer; + } + } + + .toast-close { + padding-right: 1rem; + } + } + + .dismiss-btn { + background: none; + border: none; + cursor: pointer; + padding: 0; + } +} diff --git a/projects/step-core/src/lib/components/toast/toast.component.ts b/projects/step-core/src/lib/components/toast/toast.component.ts new file mode 100644 index 000000000..ed7f222f6 --- /dev/null +++ b/projects/step-core/src/lib/components/toast/toast.component.ts @@ -0,0 +1,64 @@ +import { Component, inject, Input, OnInit } from '@angular/core'; +import { ToastService } from '../../services/toast.service'; +import { NotificationAction } from '../../shared/toast-action.interface'; +import { ToastType } from '../../shared/toast-type.enum'; +import { Entity } from '../../modules/entity/types/entity'; + +@Component({ + selector: 'step-toast', + templateUrl: 'toast.component.html', + styleUrls: ['toast.component.scss'], +}) +export class ToastComponent implements OnInit { + @Input() type!: ToastType; + @Input() message: string = ''; + @Input() actions: NotificationAction[] | undefined = []; + @Input() duration: number | undefined = 3000; + @Input() autoClose: boolean = true; + @Input() values: string[] = []; + @Input() entity?: Entity; + @Input() entityName?: string; + private _toastService = inject(ToastService); + private timer: any; + toastType = ToastType; + messageParts: any[] = []; + + ngOnInit(): void { + this.formatMessage(); + if (this.autoClose) { + this.timer = setTimeout(() => this.closeToast(), this.duration || 3000); + } + } + + ngOnDestroy(): void { + if (this.timer) { + clearTimeout(this.timer); + } + } + + closeToast(): void { + this._toastService.removeToast(this.message, this.values); + } + + private formatMessage(): void { + const formattedMessage = this.message.replace(/{(\d+)}/g, (match, index) => { + return `{${index}}`; + }); + + const parts = formattedMessage.split(/(\{\d+\})/g); + this.messageParts = parts.map((part) => { + const match = part.match(/\{(\d+)\}/); + if (match) { + const index = parseInt(match[1], 10); + const originalValue = this.values[index]; + const displayValue = this.truncate(originalValue, 50); + return { isPlaceholder: true, originalValue, displayValue }; + } + return part; + }); + } + + private truncate(value: string, maxLength: number): string { + return value.length > maxLength ? value.substring(0, maxLength) + '...' : value; + } +} diff --git a/projects/step-core/src/lib/services/toast.service.ts b/projects/step-core/src/lib/services/toast.service.ts new file mode 100644 index 000000000..8ece074d4 --- /dev/null +++ b/projects/step-core/src/lib/services/toast.service.ts @@ -0,0 +1,49 @@ +import { Injectable, signal } from '@angular/core'; +import { NotificationAction } from '../shared/toast-action.interface'; +import { ToastType } from '../shared/toast-type.enum'; +import { Entity } from '../modules/entity/types/entity'; + +@Injectable({ + providedIn: 'root', +}) +export class ToastService { + private toastsInternal = signal< + { + type: ToastType; + message: string; + values: string[]; + entity?: Entity; + entityName?: string; + actions?: NotificationAction[]; + autoClose?: boolean; + duration?: number; + }[] + >([]); + readonly toastMessages = this.toastsInternal.asReadonly(); + + showToast( + type: ToastType, + message: string, + values: string[], + entity?: Entity, + entityName?: string, + actions: NotificationAction[] = [], + autoClose: boolean = true, + duration: number = 3000, + ): void { + this.toastsInternal.update((toasts) => + toasts.concat({ type, message, values, entity, entityName, actions, autoClose, duration }), + ); + } + + removeToast(message: string, values: string[]): void { + this.toastsInternal.update((toasts) => + toasts.filter((toast) => toast.message !== message || !this.arraysEqual(toast.values, values)), + ); + } + + private arraysEqual(arr1: string[], arr2: string[]): boolean { + if (arr1.length !== arr2.length) return false; + return arr1.every((value, index) => value === arr2[index]); + } +} diff --git a/projects/step-core/src/lib/shared/toast-action.interface.ts b/projects/step-core/src/lib/shared/toast-action.interface.ts new file mode 100644 index 000000000..5544e92e3 --- /dev/null +++ b/projects/step-core/src/lib/shared/toast-action.interface.ts @@ -0,0 +1,4 @@ +export interface NotificationAction { + label: string; + handler: () => void; +} diff --git a/projects/step-core/src/lib/shared/toast-type.enum.ts b/projects/step-core/src/lib/shared/toast-type.enum.ts new file mode 100644 index 000000000..effbdfedb --- /dev/null +++ b/projects/step-core/src/lib/shared/toast-type.enum.ts @@ -0,0 +1,6 @@ +export enum ToastType { + SUCCESS = 'success', + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', +} diff --git a/projects/step-core/src/lib/step-core.module.ts b/projects/step-core/src/lib/step-core.module.ts index c9213fcfa..f394b6a8b 100644 --- a/projects/step-core/src/lib/step-core.module.ts +++ b/projects/step-core/src/lib/step-core.module.ts @@ -74,6 +74,8 @@ import { TestIdDirective } from './directives/test-id.directive'; import { ExtractUrlPipe } from './pipes/extract-url.pipe'; import { ExtractQueryParamsPipe } from './pipes/extract-query-params.pipe'; import { INFO_BANNER_EXPORTS } from './modules/info-banner'; +import { ToastComponent } from './components/toast/toast.component'; +import { ToastContainerComponent } from './components/toast-container/toast-container.component'; @NgModule({ declarations: [ @@ -113,6 +115,8 @@ import { INFO_BANNER_EXPORTS } from './modules/info-banner'; TestIdDirective, ExtractUrlPipe, ExtractQueryParamsPipe, + ToastComponent, + ToastContainerComponent, ], imports: [ CommonModule, @@ -219,6 +223,8 @@ import { INFO_BANNER_EXPORTS } from './modules/info-banner'; INFO_BANNER_EXPORTS, ExtractUrlPipe, ExtractQueryParamsPipe, + ToastComponent, + ToastContainerComponent, ], providers: [ CORE_INITIALIZER, @@ -350,3 +356,7 @@ export { TestIdDirective } from './directives/test-id.directive'; export * from './modules/info-banner'; export * from './pipes/extract-url.pipe'; export * from './pipes/extract-query-params.pipe'; +export * from './services/toast.service'; +export * from './components/toast/toast.component'; +export * from './components/toast-container/toast-container.component'; +export * from './shared/toast-type.enum'; diff --git a/projects/step-frontend/src/lib/components/root/root.component.html b/projects/step-frontend/src/lib/components/root/root.component.html index 72d3aaac6..ace3431d7 100644 --- a/projects/step-frontend/src/lib/components/root/root.component.html +++ b/projects/step-frontend/src/lib/components/root/root.component.html @@ -1,2 +1,3 @@ + diff --git a/projects/step-frontend/src/lib/modules/plan/components/plan-list/plan-list.component.ts b/projects/step-frontend/src/lib/modules/plan/components/plan-list/plan-list.component.ts index 4c024cffd..6354fed8a 100644 --- a/projects/step-frontend/src/lib/modules/plan/components/plan-list/plan-list.component.ts +++ b/projects/step-frontend/src/lib/modules/plan/components/plan-list/plan-list.component.ts @@ -4,14 +4,17 @@ import { AutoDeselectStrategy, DialogParentService, DialogsService, - Plan, IsUsedByDialogService, + Plan, selectionCollectionProvider, STORE_ALL, - tablePersistenceConfigProvider, tableColumnsConfigProvider, + tablePersistenceConfigProvider, + ToastService, + ToastType, } from '@exense/step-core'; import { map, of, pipe, switchMap, tap } from 'rxjs'; +import { Router } from '@angular/router'; @Component({ selector: 'step-plan-list', @@ -34,7 +37,8 @@ import { map, of, pipe, switchMap, tap } from 'rxjs'; export class PlanListComponent implements DialogParentService { private _isUsedByDialogs = inject(IsUsedByDialogService); private _dialogs = inject(DialogsService); - + private toast = inject(ToastService); + private _router = inject(Router); readonly _plansApiService = inject(AugmentedPlansService); readonly dataSource = this._plansApiService.getPlansTableDataSource(); @@ -52,13 +56,31 @@ export class PlanListComponent implements DialogParentService { } duplicatePlan(id: string): void { + let clonedPlan: Plan; + const values: Array = []; this._plansApiService .clonePlan(id) .pipe( - switchMap((clone) => this._plansApiService.savePlan(clone)), + switchMap((clone) => { + clonedPlan = clone; + if (clonedPlan.root?.attributes!['name']) { + values.push(clonedPlan.root?.attributes!['name']); + } + return this._plansApiService.savePlan(clone); + }), this.updateDataSourceAfterChange, ) - .subscribe(); + .subscribe(() => + this.toast.showToast( + ToastType.SUCCESS, + `{0} successfully duplicated`, + values, + clonedPlan, + 'plans', + [{ label: 'edit', handler: () => this._router.navigateByUrl(`/plans/editor/${clonedPlan.id}`) }], + false, + ), + ); } deletePlan(plan: Plan): void {