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 }}
+
+
+
+
+}
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 {