Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div class="toast-container">
@for (toast of _toasts(); track toast.message) {
<step-toast
[type]="toast.type"
[message]="toast.message"
[values]="toast.values"
[entity]="toast.entity"
[entityName]="toast.entityName"
[actions]="toast.actions"
[autoClose]="!!toast.autoClose"
[duration]="toast.duration"
/>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.toast-container {
position: fixed;
top: 2rem;
right: 2rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 1rem;
}
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions projects/step-core/src/lib/components/toast/toast.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
@if (message) {
<div class="toast">
<div class="toast-message">
@if (entity) {
<entity-icon [entity]="entity" [entityName]="entityName" />&nbsp;
}
<ng-container *ngFor="let part of messageParts">
<span *ngIf="part.isPlaceholder" [matTooltip]="part.originalValue">{{ part.displayValue }}</span>
<span *ngIf="!part.isPlaceholder">{{ part }}</span>
</ng-container>
</div>
<div class="toast-bottom">
<div class="toast-actions">
@for (action of actions; track action.label) {
<a (click)="action.handler()" class="action-btn" type="button" color="primary">
{{ action.label }}
</a>
}
</div>
<div class="toast-close">
<button class="dismiss-btn" (click)="closeToast()">close</button>
</div>
</div>
</div>
}
72 changes: 72 additions & 0 deletions projects/step-core/src/lib/components/toast/toast.component.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
64 changes: 64 additions & 0 deletions projects/step-core/src/lib/components/toast/toast.component.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
49 changes: 49 additions & 0 deletions projects/step-core/src/lib/services/toast.service.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
}
4 changes: 4 additions & 0 deletions projects/step-core/src/lib/shared/toast-action.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface NotificationAction {
label: string;
handler: () => void;
}
6 changes: 6 additions & 0 deletions projects/step-core/src/lib/shared/toast-type.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum ToastType {
SUCCESS = 'success',
ERROR = 'error',
WARNING = 'warning',
INFO = 'info',
}
10 changes: 10 additions & 0 deletions projects/step-core/src/lib/step-core.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -113,6 +115,8 @@ import { INFO_BANNER_EXPORTS } from './modules/info-banner';
TestIdDirective,
ExtractUrlPipe,
ExtractQueryParamsPipe,
ToastComponent,
ToastContainerComponent,
],
imports: [
CommonModule,
Expand Down Expand Up @@ -219,6 +223,8 @@ import { INFO_BANNER_EXPORTS } from './modules/info-banner';
INFO_BANNER_EXPORTS,
ExtractUrlPipe,
ExtractQueryParamsPipe,
ToastComponent,
ToastContainerComponent,
],
providers: [
CORE_INITIALIZER,
Expand Down Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
<step-theme></step-theme>
<step-toast-container></step-toast-container>
<router-outlet></router-outlet>
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
Expand All @@ -52,13 +56,31 @@ export class PlanListComponent implements DialogParentService {
}

duplicatePlan(id: string): void {
let clonedPlan: Plan;
const values: Array<string> = [];
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 {
Expand Down