This is an automated email from the ASF dual-hosted git repository.

riemer pushed a commit to branch 3810-support-dashboard-clones
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to refs/heads/3810-support-dashboard-clones 
by this push:
     new 1fbbba8583 feat(#3810): Support cloning of dashboards
1fbbba8583 is described below

commit 1fbbba85833cd0a0336cd204551835cc67e36dff
Author: Dominik Riemer <[email protected]>
AuthorDate: Thu Oct 2 09:45:40 2025 +0200

    feat(#3810): Support cloning of dashboards
---
 ...omponent.scss => sp-table-actions.directive.ts} |  17 +-
 .../components/sp-table/sp-table.component.html    |  55 +++++-
 .../components/sp-table/sp-table.component.scss    |   4 +
 .../lib/components/sp-table/sp-table.component.ts  |  31 ++++
 .../shared-ui/src/lib/shared-ui.module.ts          |   3 +
 .../streampipes/shared-ui/src/public-api.ts        |   1 +
 .../id-generator/id-generator.service.ts           |   4 +
 .../template/PipelineInvocationBuilder.ts          |  96 ----------
 .../slide-view/dashboard-slide-view.component.scss |   5 +-
 .../dashboard-overview-table.component.html        | 195 +++++++++++++--------
 .../dashboard-overview-table.component.ts          |  18 ++
 .../panel/dashboard-panel.component.scss           |   1 -
 ui/src/app/dashboard/dashboard.module.ts           |   4 +
 .../clone-dashboard-dialog.component.html          | 160 +++++++++++++++++
 .../clone-dashboard-dialog.component.scss}         |  13 +-
 .../clone-dashboard-dialog.component.ts            | 130 ++++++++++++++
 .../edit-dashboard-dialog.component.ts             |   8 +-
 ui/src/scss/sp/_variables.scss                     |   1 +
 ui/src/scss/sp/layout.scss                         |   8 +
 ui/src/scss/sp/sp-theme.scss                       |   3 +-
 20 files changed, 557 insertions(+), 200 deletions(-)

diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts
similarity index 76%
copy from 
ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
copy to 
ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts
index d390c087f5..0b7e740d94 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table-actions.directive.ts
@@ -1,4 +1,4 @@
-/*!
+/*
  * Licensed to the Apache Software Foundation (ASF) under one or more
  * contributor license agreements.  See the NOTICE file distributed with
  * this work for additional information regarding copyright ownership.
@@ -16,15 +16,6 @@
  *
  */
 
-.paginator-container {
-    border-top: 1px solid rgba(0, 0, 0, 0.12);
-}
-
-.mat-mdc-row:hover {
-    background-color: var(--color-bg-1);
-}
-
-.mat-mdc-no-data-row {
-    height: var(--mat-table-row-item-container-height, 52px);
-    text-align: center;
-}
+import { Directive } from '@angular/core';
+@Directive({ selector: 'ng-template[spTableActions]', standalone: false })
+export class SpTableActionsDirective {}
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
index c9134d4a7f..5f7ad2cabd 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.html
@@ -20,8 +20,61 @@
     <table mat-table class="sp-table" [dataSource]="dataSource">
         <ng-content></ng-content>
 
+        @if (showActionsMenu) {
+            <ng-container matColumnDef="actions">
+                <th
+                    fxFlex
+                    fxLayoutAlign="center center"
+                    mat-header-cell
+                    *matHeaderCellDef
+                ></th>
+                <td
+                    fxFlex
+                    fxLayoutAlign="end center"
+                    mat-cell
+                    *matCellDef="let element"
+                >
+                    <div
+                        [matMenuTriggerFor]="menu"
+                        #menuTrigger="matMenuTrigger"
+                        (mouseenter)="mouseEnter(menuTrigger)"
+                        (mouseleave)="mouseLeave(menuTrigger)"
+                    >
+                        <button
+                            mat-icon-button
+                            [matMenuTriggerFor]="menu"
+                            #menuTrigger="matMenuTrigger"
+                            (click)="$event.stopPropagation()"
+                            [attr.data-cy]="'more-options'"
+                        >
+                            <mat-icon>more_vert</mat-icon>
+                        </button>
+                    </div>
+                    <mat-menu #menu="matMenu" [hasBackdrop]="false">
+                        <div
+                            (mouseenter)="mouseEnter(menuTrigger)"
+                            (mouseleave)="mouseLeave(menuTrigger)"
+                        >
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    actionsTemplate;
+                                    context: { $implicit: element }
+                                "
+                            >
+                            </ng-container>
+                        </div>
+                    </mat-menu>
+                </td>
+            </ng-container>
+        }
+
         <tr mat-header-row *matHeaderRowDef="columns"></tr>
-        <tr mat-row *matRowDef="let row; columns: columns"></tr>
+        <tr
+            mat-row
+            *matRowDef="let row; columns: columns"
+            (click)="rowClicked.emit(row)"
+            [ngClass]="rowsClickable ? 'cursor-pointer' : ''"
+        ></tr>
 
         <tr class="mat-row" *matNoDataRow>
             <td
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
index d390c087f5..bd8eea251f 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
@@ -28,3 +28,7 @@
     height: var(--mat-table-row-item-container-height, 52px);
     text-align: center;
 }
+
+.cursor-pointer {
+    cursor: pointer;
+}
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
index 2b31397b83..42fa5d2d4f 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
+++ 
b/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.ts
@@ -22,8 +22,11 @@ import {
     Component,
     ContentChild,
     ContentChildren,
+    EventEmitter,
     Input,
+    Output,
     QueryList,
+    TemplateRef,
     ViewChild,
 } from '@angular/core';
 import {
@@ -35,6 +38,8 @@ import {
     MatTableDataSource,
 } from '@angular/material/table';
 import { MatPaginator } from '@angular/material/paginator';
+import { SpTableActionsDirective } from './sp-table-actions.directive';
+import { MatMenuTrigger } from '@angular/material/menu';
 
 @Component({
     selector: 'sp-table',
@@ -51,12 +56,20 @@ export class SpTableComponent<T> implements AfterViewInit, 
AfterContentInit {
     @ViewChild(MatTable, { static: true }) table: MatTable<T>;
 
     @Input() columns: string[];
+    @Input() rowsClickable = false;
+    @Input() showActionsMenu = false;
 
     @Input() dataSource: MatTableDataSource<T>;
 
+    @Output() rowClicked = new EventEmitter<T>();
+
     @ViewChild('paginator') paginator: MatPaginator;
+    @ContentChild(SpTableActionsDirective, { read: TemplateRef })
+    actionsTemplate?: TemplateRef<any>;
 
     pageSize = 1;
+    timedOutCloser: any;
+    trigger: MatMenuTrigger | undefined = undefined;
 
     ngAfterViewInit() {
         this.dataSource.paginator = this.paginator;
@@ -72,4 +85,22 @@ export class SpTableComponent<T> implements AfterViewInit, 
AfterContentInit {
         );
         this.table.setNoDataRow(this.noDataRow);
     }
+
+    mouseEnter(trigger) {
+        if (this.timedOutCloser) {
+            clearTimeout(this.timedOutCloser);
+        }
+        if (this.trigger !== undefined) {
+            this.trigger.closeMenu();
+        }
+        trigger.openMenu();
+        this.trigger = trigger;
+    }
+
+    mouseLeave(trigger) {
+        this.timedOutCloser = setTimeout(() => {
+            trigger.closeMenu();
+            this.trigger = undefined;
+        }, 50);
+    }
 }
diff --git a/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts 
b/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts
index 0ead4013e3..0bbe262e61 100644
--- a/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts
+++ b/ui/projects/streampipes/shared-ui/src/lib/shared-ui.module.ts
@@ -98,6 +98,7 @@ import { InputSchemaPropertyComponent } from 
'./components/input-schema-panel/in
 import { MatExpansionModule } from '@angular/material/expansion';
 import { SortByRuntimeNamePipe } from './pipes/sort-by-runtime-name.pipe';
 import { DragDropModule } from '@angular/cdk/drag-drop';
+import { SpTableActionsDirective } from 
'./components/sp-table/sp-table-actions.directive';
 
 @NgModule({
     declarations: [
@@ -149,6 +150,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
         InputSchemaPanelComponent,
         InputSchemaPropertyComponent,
         SortByRuntimeNamePipe,
+        SpTableActionsDirective,
     ],
     imports: [
         CommonModule,
@@ -216,6 +218,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
         PipelineElementComponent,
         InputSchemaPanelComponent,
         SidebarResizeComponent,
+        SpTableActionsDirective,
     ],
 })
 export class SharedUiModule {}
diff --git a/ui/projects/streampipes/shared-ui/src/public-api.ts 
b/ui/projects/streampipes/shared-ui/src/public-api.ts
index ae6cdd29c1..f416efe889 100644
--- a/ui/projects/streampipes/shared-ui/src/public-api.ts
+++ b/ui/projects/streampipes/shared-ui/src/public-api.ts
@@ -42,6 +42,7 @@ export * from 
'./lib/components/sp-exception-message/exception-details-dialog/ex
 export * from 
'./lib/components/sp-exception-message/exception-details/exception-details.component';
 export * from './lib/components/sp-label/sp-label.component';
 export * from './lib/components/sp-table/sp-table.component';
+export * from './lib/components/sp-table/sp-table-actions.directive';
 export * from './lib/components/warning-box/warning-box.component';
 export * from './lib/components/time-selector/time-selector.model';
 export * from './lib/components/time-selector/time-range-selector.component';
diff --git a/ui/src/app/core-services/id-generator/id-generator.service.ts 
b/ui/src/app/core-services/id-generator/id-generator.service.ts
index d3d4a0b069..b75a5b7b83 100644
--- a/ui/src/app/core-services/id-generator/id-generator.service.ts
+++ b/ui/src/app/core-services/id-generator/id-generator.service.ts
@@ -46,4 +46,8 @@ export class IdGeneratorService {
     public generatePrefixedId(): string {
         return this.idPrefix + this.generate(6);
     }
+
+    public generateWithPrefix(prefix: string, length: number): string {
+        return prefix + this.generate(length);
+    }
 }
diff --git a/ui/src/app/core-services/template/PipelineInvocationBuilder.ts 
b/ui/src/app/core-services/template/PipelineInvocationBuilder.ts
deleted file mode 100644
index 9192a735f3..0000000000
--- a/ui/src/app/core-services/template/PipelineInvocationBuilder.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- *
- */
-import {
-    FreeTextStaticProperty,
-    MappingPropertyUnary,
-    PipelineTemplateInvocation,
-} from '@streampipes/platform-services';
-
-export class PipelineInvocationBuilder {
-    private pipelineTemplateInvocation: PipelineTemplateInvocation;
-
-    constructor(pipelineTemplateInvocation: PipelineTemplateInvocation) {
-        this.pipelineTemplateInvocation = pipelineTemplateInvocation;
-    }
-
-    public static create(
-        pipelineTemplateInvocation: PipelineTemplateInvocation,
-    ) {
-        return new PipelineInvocationBuilder(pipelineTemplateInvocation);
-    }
-
-    public setTemplateId(id: string) {
-        this.pipelineTemplateInvocation.pipelineTemplateId = id;
-        return this;
-    }
-
-    public setName(name: string) {
-        this.pipelineTemplateInvocation.kviName = name;
-        return this;
-    }
-
-    public setFreeTextStaticProperty(name: string, value: string) {
-        this.pipelineTemplateInvocation.staticProperties.forEach(property => {
-            if (
-                property instanceof FreeTextStaticProperty &&
-                'jsplumb_domId2' + name === property.internalName
-            ) {
-                property.value = value;
-            }
-        });
-
-        return this;
-    }
-
-    public setOneOfStaticProperty(name: string, value: string) {
-        this.pipelineTemplateInvocation.staticProperties.forEach(property => {
-            if (
-                // property instanceof OneOfStaticProperty &&
-                property['@class'] ===
-                    
'org.apache.streampipes.model.staticproperty.OneOfStaticProperty' &&
-                'jsplumb_domId2' + name === property.internalName
-            ) {
-                // set selected for selected option
-                property.options.forEach(option => {
-                    if (option.name === value) {
-                        option.selected = true;
-                    }
-                });
-            }
-        });
-
-        return this;
-    }
-
-    public setMappingPropertyUnary(name: string, value: string) {
-        this.pipelineTemplateInvocation.staticProperties.forEach(property => {
-            if (
-                property instanceof MappingPropertyUnary &&
-                'jsplumb_domId2' + name === property.internalName
-            ) {
-                property.selectedProperty = value;
-            }
-        });
-
-        return this;
-    }
-
-    public build() {
-        return this.pipelineTemplateInvocation;
-    }
-}
diff --git 
a/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
 
b/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
index 96db439b27..dd87689641 100644
--- 
a/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
+++ 
b/ui/src/app/dashboard-shared/components/chart-view/slide-view/dashboard-slide-view.component.scss
@@ -23,6 +23,7 @@
     cursor: pointer;
     margin: 5px 10px;
     padding: 5px;
+    background: var(--color-bg-0);
 }
 
 .viz-preview-selected {
@@ -32,8 +33,8 @@
 .selection-box {
     overflow-y: auto;
     overflow-x: hidden;
-    margin-bottom: 5px;
-    height: calc(100vh - 147px);
+    height: calc(100vh - 137px);
+    border-right: 1px solid var(--color-bg-3);
     max-width: 100%;
 }
 
diff --git 
a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
 
b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
index f3117836fe..235edc259f 100644
--- 
a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
+++ 
b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.html
@@ -24,7 +24,10 @@
         <sp-table
             fxFlex="100"
             [columns]="displayedColumns"
+            [showActionsMenu]="true"
+            [rowsClickable]="true"
             [dataSource]="dataSource"
+            (rowClicked)="onRowClicked($event)"
         >
             <ng-container matColumnDef="name">
                 <th
@@ -98,77 +101,129 @@
                 </td>
             </ng-container>
 
-            <ng-container matColumnDef="actions">
-                <th
-                    fxFlex
-                    fxLayoutAlign="center center"
-                    mat-header-cell
-                    *matHeaderCellDef
-                ></th>
-                <td
-                    fxFlex
-                    fxLayoutAlign="start center"
-                    mat-cell
-                    *matCellDef="let element"
+            <ng-template spTableActions let-element>
+                <button
+                    mat-menu-item
+                    (click)="showDashboard(element); $event.stopPropagation()"
                 >
-                    <div fxLayout="row" fxFlex="100" fxLayoutAlign="end 
center">
-                        <button
-                            mat-icon-button
-                            color="accent"
-                            [matTooltip]="'Show dashboard' | translate"
-                            (click)="showDashboard(element)"
-                        >
-                            <i class="material-icons">visibility</i>
-                        </button>
-                        <button
-                            mat-icon-button
-                            color="accent"
-                            [matTooltip]="'Edit dashboard' | translate"
-                            *ngIf="hasDataExplorerWritePrivileges"
-                            [attr.data-cy]="'edit-dashboard-' + element.name"
-                            (click)="editDashboard(element)"
-                        >
-                            <i class="material-icons">edit</i>
-                        </button>
-                        <button
-                            mat-icon-button
-                            color="accent"
-                            [matTooltip]="'Kiosk mode' | translate"
-                            (click)="openDashboardInKioskMode(element)"
-                        >
-                            <i class="material-icons">open_in_new</i>
-                        </button>
-                        <button
-                            mat-icon-button
-                            color="accent"
-                            [matTooltip]="'Dashboard settings' | translate"
-                            *ngIf="hasDataExplorerWritePrivileges"
-                            (click)="openEditDashboardDialog(element)"
-                        >
-                            <i class="material-icons">settings</i>
-                        </button>
-                        <button
-                            mat-icon-button
-                            color="accent"
-                            [matTooltip]="'Manage permissions' | translate"
-                            *ngIf="isAdmin"
-                            (click)="showPermissionsDialog(element)"
-                        >
-                            <i class="material-icons">share</i>
-                        </button>
-                        <button
-                            mat-icon-button
-                            color="accent"
-                            [matTooltip]="'Delete chart' | translate"
-                            *ngIf="hasDataExplorerWritePrivileges"
-                            [attr.data-cy]="'delete-dashboard-' + element.name"
-                            (click)="openDeleteDashboardDialog(element)"
-                        >
-                            <i class="material-icons">delete</i>
-                        </button>
-                    </div>
-                </td>
-            </ng-container>
+                    <mat-icon>visibility</mat-icon>
+                    <span>{{ 'Show' | translate }}</span>
+                </button>
+                @if (hasDataExplorerWritePrivileges) {
+                    <button
+                        mat-menu-item
+                        [attr.data-cy]="'edit-dashboard-' + element.name"
+                        (click)="editDashboard(element)"
+                    >
+                        <mat-icon>edit</mat-icon>
+                        <span>{{ 'Edit' | translate }}</span>
+                    </button>
+                    <button
+                        mat-menu-item
+                        [attr.data-cy]="'clone-dashboard-' + element.name"
+                        (click)="openCloneDialog(element)"
+                    >
+                        <mat-icon>flip_to_front</mat-icon>
+                        <span>{{ 'Clone' | translate }}</span>
+                    </button>
+                }
+                <button
+                    mat-menu-item
+                    [attr.data-cy]="'kiosk-mode-dashboard-' + element.name"
+                    (click)="openDashboardInKioskMode(element)"
+                >
+                    <mat-icon>open_in_new</mat-icon>
+                    <span>{{ 'Kiosk mode' | translate }}</span>
+                </button>
+                @if (hasDataExplorerWritePrivileges) {
+                    <button
+                        mat-menu-item
+                        [attr.data-cy]="'edit-dashboard-' + element.name"
+                        (click)="openEditDashboardDialog(element)"
+                    >
+                        <mat-icon>settings</mat-icon>
+                        <span>{{ 'Settings' | translate }}</span>
+                    </button>
+                }
+                <button
+                    mat-menu-item
+                    [attr.data-cy]="
+                        'manage-dashboard-permissions-' + element.name
+                    "
+                    (click)="showPermissionsDialog(element)"
+                >
+                    <mat-icon>share</mat-icon>
+                    <span>{{ 'Manage permissions' | translate }}</span>
+                </button>
+                @if (hasDataExplorerWritePrivileges) {
+                    <button
+                        mat-menu-item
+                        [attr.data-cy]="'delete-dashboard-' + element.name"
+                        (click)="openDeleteDashboardDialog(element)"
+                    >
+                        <mat-icon>delete</mat-icon>
+                        <span>{{ 'Delete' | translate }}</span>
+                    </button>
+                }
+                <!--                  </mat-menu>-->
+                <!--                    <div fxLayout="row" fxFlex="100" 
fxLayoutAlign="end center">-->
+                <!--                        <button-->
+                <!--                            mat-icon-button-->
+                <!--                            color="accent"-->
+                <!--                            [matTooltip]="'Manage 
permissions' | translate"-->
+                <!--                            
(click)="showDashboard(element)"-->
+                <!--                        >-->
+                <!--                            <i 
class="material-icons">visibility</i>-->
+                <!--                        </button>-->
+                <!--                        <button-->
+                <!--                            mat-icon-button-->
+                <!--                            color="accent"-->
+                <!--                            [matTooltip]="'Edit dashboard' 
| translate"-->
+                <!--                            
*ngIf="hasDataExplorerWritePrivileges"-->
+                <!--                            
[attr.data-cy]="'edit-dashboard-' + element.name"-->
+                <!--                            
(click)="editDashboard(element)"-->
+                <!--                        >-->
+                <!--                            <i 
class="material-icons">edit</i>-->
+                <!--                        </button>-->
+                <!--                        <button-->
+                <!--                            mat-icon-button-->
+                <!--                            color="accent"-->
+                <!--                            [matTooltip]="'Kiosk mode' | 
translate"-->
+                <!--                            
(click)="openDashboardInKioskMode(element)"-->
+                <!--                        >-->
+                <!--                            <i 
class="material-icons">open_in_new</i>-->
+                <!--                        </button>-->
+                <!--                        <button-->
+                <!--                            mat-icon-button-->
+                <!--                            color="accent"-->
+                <!--                            [matTooltip]="'Dashboard 
settings' | translate"-->
+                <!--                            
*ngIf="hasDataExplorerWritePrivileges"-->
+                <!--                            
(click)="openEditDashboardDialog(element)"-->
+                <!--                        >-->
+                <!--                            <i 
class="material-icons">settings</i>-->
+                <!--                        </button>-->
+                <!--                        <button-->
+                <!--                            mat-icon-button-->
+                <!--                            color="accent"-->
+                <!--                            [matTooltip]="'Manage 
permissions' | translate"-->
+                <!--                            *ngIf="isAdmin"-->
+                <!--                            
(click)="showPermissionsDialog(element)"-->
+                <!--                        >-->
+                <!--                            <i 
class="material-icons">share</i>-->
+                <!--                        </button>-->
+                <!--                        <button-->
+                <!--                            mat-icon-button-->
+                <!--                            color="accent"-->
+                <!--                            [matTooltip]="'Delete chart' | 
translate"-->
+                <!--                            
*ngIf="hasDataExplorerWritePrivileges"-->
+                <!--                            
[attr.data-cy]="'delete-dashboard-' + element.name"-->
+                <!--                            
(click)="openDeleteDashboardDialog(element)"-->
+                <!--                        >-->
+                <!--                            <i 
class="material-icons">delete</i>-->
+                <!--                        </button>-->
+                <!--                    </div>-->
+                <!--                </td>-->
+            </ng-template>
         </sp-table>
     </div>
 </div>
diff --git 
a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
 
b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
index 43053f6068..e776395959 100644
--- 
a/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
+++ 
b/ui/src/app/dashboard/components/overview/dashboard-overview-table/dashboard-overview-table.component.ts
@@ -22,6 +22,7 @@ import { Dashboard, DashboardService } from 
'@streampipes/platform-services';
 import {
     ConfirmDialogComponent,
     DateFormatService,
+    PanelType,
 } from '@streampipes/shared-ui';
 import { SpDataExplorerOverviewDirective } from 
'../../../../data-explorer/components/overview/data-explorer-overview.directive';
 import { MatDialog } from '@angular/material/dialog';
@@ -29,6 +30,8 @@ import { DataExplorerDashboardService } from 
'../../../../dashboard-shared/servi
 import { DataExplorerSharedService } from 
'../../../../data-explorer-shared/services/data-explorer-shared.service';
 import { TranslateService } from '@ngx-translate/core';
 import { Router } from '@angular/router';
+import { CloneDashboardDialogComponent } from 
'../../../dialogs/clone-dashboard/clone-dashboard-dialog.component';
+import { EditDashboardDialogComponent } from 
'../../../dialogs/edit-dashboard/edit-dashboard-dialog.component';
 
 @Component({
     selector: 'sp-dashboard-overview-table',
@@ -158,4 +161,19 @@ export class DashboardOverviewTableComponent extends 
SpDataExplorerOverviewDirec
     makeDashboardKioskUrl(dashboardId: string): string {
         return 
`${window.location.protocol}//${window.location.host}/#/dashboard-kiosk/${dashboardId}`;
     }
+
+    openCloneDialog(dashboard: Dashboard): void {
+        this.dialogService.open(CloneDashboardDialogComponent, {
+            panelType: PanelType.SLIDE_IN_PANEL,
+            title: this.translateService.instant('Clone dashboard'),
+            width: '50vw',
+            data: {
+                dashboard: dashboard,
+            },
+        });
+    }
+
+    onRowClicked(dashboard: Dashboard) {
+        this.showDashboard(dashboard);
+    }
 }
diff --git 
a/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss 
b/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss
index 285b384708..ad4770c30a 100644
--- a/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss
+++ b/ui/src/app/dashboard/components/panel/dashboard-panel.component.scss
@@ -41,7 +41,6 @@
 
 .designer-panel {
     width: 450px;
-    border: 1px solid var(--color-tab-border);
 }
 
 .edit-menu-btn {
diff --git a/ui/src/app/dashboard/dashboard.module.ts 
b/ui/src/app/dashboard/dashboard.module.ts
index e5a299417e..7e480fdb69 100644
--- a/ui/src/app/dashboard/dashboard.module.ts
+++ b/ui/src/app/dashboard/dashboard.module.ts
@@ -69,6 +69,8 @@ import { EditDashboardDialogComponent } from 
'./dialogs/edit-dashboard/edit-dash
 import { DashboardOverviewTableComponent } from 
'./components/overview/dashboard-overview-table/dashboard-overview-table.component';
 import { TranslateModule } from '@ngx-translate/core';
 import { DashboardSharedModule } from 
'../dashboard-shared/dashboard-shared.module';
+import { CloneDashboardDialogComponent } from 
'./dialogs/clone-dashboard/clone-dashboard-dialog.component';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
 
 @NgModule({
     imports: [
@@ -114,6 +116,7 @@ import { DashboardSharedModule } from 
'../dashboard-shared/dashboard-shared.modu
         DataExplorerSharedModule,
         DashboardSharedModule,
         TranslateModule.forChild(),
+        MatProgressSpinnerModule,
         RouterModule.forChild([
             {
                 path: '',
@@ -145,6 +148,7 @@ import { DashboardSharedModule } from 
'../dashboard-shared/dashboard-shared.modu
         ChartSelectionComponent,
         EditDashboardDialogComponent,
         DashboardOverviewTableComponent,
+        CloneDashboardDialogComponent,
     ],
     providers: [],
     exports: [],
diff --git 
a/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.html
 
b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.html
new file mode 100644
index 0000000000..36be42ddf3
--- /dev/null
+++ 
b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.html
@@ -0,0 +1,160 @@
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~    http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  ~
+  -->
+
+<div class="sp-dialog-container" fxLayout="column">
+    @if (compositeDashboard) {
+        <div class="sp-dialog-content p-15">
+            <div fxFlex="100">
+                <div
+                    fxFlex="100"
+                    fxLayout="column"
+                    style="margin: 5px; width: 100%"
+                >
+                    <!-- Name -->
+                    <mat-form-field
+                        class="w-100"
+                        floatLabel="auto"
+                        color="accent"
+                    >
+                        <mat-label>{{
+                            'New dashboard title' | translate
+                        }}</mat-label>
+                        <input
+                            id="dvname"
+                            #dvname="ngModel"
+                            required
+                            matInput
+                            data-cy="clone-data-view-name"
+                            [(ngModel)]="form.name"
+                        />
+                        <mat-error>{{
+                            'Title must not be empty' | translate
+                        }}</mat-error>
+                    </mat-form-field>
+
+                    <!-- Description (optional) -->
+                    <mat-form-field class="w-100" color="accent">
+                        <mat-label>{{ 'Description' | translate }}</mat-label>
+                        <input
+                            matInput
+                            [(ngModel)]="form.description"
+                            data-cy="clone-data-view-description"
+                        />
+                    </mat-form-field>
+
+                    <!-- Deep clone -->
+                    <div class="mt-10" fxLayout="column">
+                        <label>{{ 'Clone options' | translate }}</label>
+                        <mat-checkbox
+                            class="mt-5"
+                            [(ngModel)]="form.deepClone"
+                            data-cy="clone-deep"
+                            >{{ 'Deep clone (also clone widgets)' | translate 
}}
+                        </mat-checkbox>
+                    </div>
+
+                    <!-- Allow widget edits (only if deepClone) -->
+                    <div
+                        class="mt-10 mb-10"
+                        fxLayout="column"
+                        *ngIf="form.deepClone"
+                    >
+                        <mat-checkbox
+                            [(ngModel)]="form.allowWidgetEdits"
+                            data-cy="clone-allow-widget-edits"
+                            >{{ 'Modify chart configurations' | translate }}
+                        </mat-checkbox>
+                    </div>
+
+                    <!-- Widget edit grid -->
+                    <div *ngIf="form.deepClone && form.allowWidgetEdits">
+                        <mat-accordion class="mt-10">
+                            @for (
+                                w of widgetConfigs;
+                                track w.current.elementId
+                            ) {
+                                <mat-expansion-panel
+                                    class="mat-elevation-z0 border-1"
+                                >
+                                    <mat-expansion-panel-header>
+                                        <mat-panel-title>
+                                            {{
+                                                w.current.baseAppearanceConfig
+                                                    .widgetTitle
+                                            }}
+                                        </mat-panel-title>
+                                    </mat-expansion-panel-header>
+
+                                    <div
+                                        fxLayout="column"
+                                        class="widget-edit p-10"
+                                    >
+                                        <mat-form-field
+                                            class="w-100"
+                                            color="accent"
+                                        >
+                                            <mat-label>{{
+                                                'Chart Name' | translate
+                                            }}</mat-label>
+                                            <input
+                                                matInput
+                                                [(ngModel)]="
+                                                    w.cloned
+                                                        .baseAppearanceConfig
+                                                        .widgetTitle
+                                                "
+                                            />
+                                        </mat-form-field>
+                                    </div>
+                                </mat-expansion-panel>
+                            }
+                        </mat-accordion>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <mat-divider></mat-divider>
+
+        <div class="sp-dialog-actions actions-align-left" fxLayoutGap="10px">
+            <button
+                [disabled]="dvname.invalid"
+                mat-button
+                mat-flat-button
+                color="accent"
+                data-cy="clone-save"
+                (click)="onSave()"
+            >
+                {{ 'Clone' | translate }}
+            </button>
+            <button
+                mat-button
+                mat-flat-button
+                class="mat-basic mr-10"
+                (click)="onCancel()"
+            >
+                {{ 'Close' | translate }}
+            </button>
+        </div>
+    } @else {
+        <div fxFlex="100" fxLayoutAlign="center center">
+            <mat-spinner [diameter]="20"></mat-spinner>
+            <h5 class="mt-10">Loading</h5>
+        </div>
+    }
+</div>
diff --git 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
 
b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.scss
similarity index 76%
copy from 
ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
copy to 
ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.scss
index d390c087f5..0c040d8f26 100644
--- 
a/ui/projects/streampipes/shared-ui/src/lib/components/sp-table/sp-table.component.scss
+++ 
b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.scss
@@ -16,15 +16,6 @@
  *
  */
 
-.paginator-container {
-    border-top: 1px solid rgba(0, 0, 0, 0.12);
-}
-
-.mat-mdc-row:hover {
-    background-color: var(--color-bg-1);
-}
-
-.mat-mdc-no-data-row {
-    height: var(--mat-table-row-item-container-height, 52px);
-    text-align: center;
+.border-1 {
+    border: 1px solid var(--color-bg-2);
 }
diff --git 
a/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.ts
 
b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.ts
new file mode 100644
index 0000000000..c9fb21bc80
--- /dev/null
+++ 
b/ui/src/app/dashboard/dialogs/clone-dashboard/clone-dashboard-dialog.component.ts
@@ -0,0 +1,130 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { Component, inject, Input, OnInit } from '@angular/core';
+import {
+    ChartService,
+    CompositeDashboard,
+    Dashboard,
+    DashboardService,
+    DataExplorerWidgetModel,
+} from '@streampipes/platform-services';
+import { DialogRef } from '@streampipes/shared-ui';
+import { IdGeneratorService } from 
'../../../core-services/id-generator/id-generator.service';
+import { Observable, zip } from 'rxjs';
+import { TranslateService } from '@ngx-translate/core';
+
+export interface WidgetClone {
+    current: DataExplorerWidgetModel;
+    cloned: DataExplorerWidgetModel;
+}
+
+@Component({
+    selector: 'sp-clone-dashboard-dialog-component',
+    templateUrl: './clone-dashboard-dialog.component.html',
+    styleUrls: ['./clone-dashboard-dialog.component.scss'],
+    standalone: false,
+})
+export class CloneDashboardDialogComponent implements OnInit {
+    private dialogRef = inject(DialogRef<CloneDashboardDialogComponent>);
+    private dashboardService = inject(DashboardService);
+    private chartService = inject(ChartService);
+    private idGeneratorService = inject(IdGeneratorService);
+    private translate = inject(TranslateService);
+
+    static readonly DashboardPrefix = 'sp:dashboardmodel:';
+    static readonly ChartPrefix = 'sp:dataexplorerwidgetmodel:';
+
+    @Input()
+    dashboard: Dashboard;
+
+    compositeDashboard: CompositeDashboard;
+    widgetConfigs: WidgetClone[] = [];
+
+    form;
+
+    ngOnInit() {
+        this.dashboardService
+            .getCompositeDashboard(this.dashboard.elementId)
+            .subscribe(res => {
+                this.compositeDashboard = res.body as CompositeDashboard;
+                this.widgetConfigs = this.compositeDashboard.widgets.map(w => {
+                    return {
+                        current: w,
+                        cloned: JSON.parse(JSON.stringify(w)),
+                    };
+                });
+            });
+        this.form = {
+            name: `${this.dashboard.name} (${this.translate.instant('Copy')})`,
+            description: this.dashboard.description,
+            deepClone: false,
+            allowWidgetEdits: false,
+        };
+    }
+
+    onCancel(): void {
+        this.dialogRef.close();
+    }
+
+    onSave(): void {
+        let widget$: Observable<any>[] = [];
+        const clonedDashboard: Dashboard = JSON.parse(
+            JSON.stringify(this.dashboard),
+        );
+        const clonedWidgets = this.widgetConfigs.map(wc =>
+            this.form.allowWidgetEdits ? wc.cloned : wc.current,
+        );
+        clonedDashboard.elementId = this.idGeneratorService.generateWithPrefix(
+            CloneDashboardDialogComponent.DashboardPrefix,
+            6,
+        );
+        clonedDashboard.rev = undefined;
+        clonedDashboard.metadata.createdAtEpochMs = Date.now();
+        clonedDashboard.metadata.lastModifiedEpochMs = Date.now();
+        clonedDashboard.name = this.form.name;
+        clonedDashboard.description = this.form.description;
+        if (this.form.deepClone) {
+            clonedDashboard.widgets.forEach((widget, index) => {
+                const widgetElementId =
+                    this.idGeneratorService.generateWithPrefix(
+                        CloneDashboardDialogComponent.ChartPrefix,
+                        6,
+                    );
+                const clonedWidget = clonedWidgets.find(
+                    w => w.elementId === widget.id,
+                );
+                if (clonedWidget !== undefined) {
+                    clonedWidgets[index].elementId = widgetElementId;
+                    clonedWidgets[index].metadata.createdAtEpochMs = 
Date.now();
+                    clonedWidgets[index].metadata.lastModifiedEpochMs =
+                        Date.now();
+                    clonedWidgets[index].rev = undefined;
+                }
+                widget.id = widgetElementId;
+            });
+            widget$ = clonedWidgets.map(w => this.chartService.saveChart(w));
+        }
+        zip([
+            ...widget$,
+            this.dashboardService.saveDashboard(clonedDashboard),
+        ]).subscribe(() => {
+            this.dialogRef.close(true);
+        });
+    }
+}
diff --git 
a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
 
b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
index 01d4221d92..112e70cc41 100644
--- 
a/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
+++ 
b/ui/src/app/dashboard/dialogs/edit-dashboard/edit-dashboard-dialog.component.ts
@@ -16,7 +16,7 @@
  *
  */
 
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, inject, Input, OnInit } from '@angular/core';
 import { Dashboard, DashboardService } from '@streampipes/platform-services';
 import { DialogRef } from '@streampipes/shared-ui';
 
@@ -30,10 +30,8 @@ export class EditDashboardDialogComponent implements OnInit {
     @Input() createMode: boolean;
     @Input() dashboard: Dashboard;
 
-    constructor(
-        private dialogRef: DialogRef<EditDashboardDialogComponent>,
-        private dashboardService: DashboardService,
-    ) {}
+    private dialogRef = inject(DialogRef<EditDashboardDialogComponent>);
+    private dashboardService = inject(DashboardService);
 
     ngOnInit() {
         if (!this.dashboard.dashboardGeneralSettings.defaultViewMode) {
diff --git a/ui/src/scss/sp/_variables.scss b/ui/src/scss/sp/_variables.scss
index 663001ef74..9f77125c08 100644
--- a/ui/src/scss/sp/_variables.scss
+++ b/ui/src/scss/sp/_variables.scss
@@ -80,6 +80,7 @@ $sp-color-error: #b71c1c;
     --mat-menu-container-color: var(--color-bg-0);
     --mat-select-panel-background-color: var(--color-bg-0);
     --mat-sidenav-container-shape: 0;
+    --mat-sidenav-container-divider-color: var(--color-bg-3);
 }
 
 .dark-mode {
diff --git a/ui/src/scss/sp/layout.scss b/ui/src/scss/sp/layout.scss
index b4654d7760..6b736888c5 100644
--- a/ui/src/scss/sp/layout.scss
+++ b/ui/src/scss/sp/layout.scss
@@ -60,6 +60,10 @@
     margin-top: 0;
 }
 
+.mt-5 {
+    margin-top: 5px;
+}
+
 .mt-10 {
     margin-top: 10px;
 }
@@ -72,6 +76,10 @@
     margin-top: 30px;
 }
 
+.mb-5 {
+    margin-bottom: 5px;
+}
+
 .mb-10 {
     margin-bottom: 10px;
 }
diff --git a/ui/src/scss/sp/sp-theme.scss b/ui/src/scss/sp/sp-theme.scss
index 63cc186e97..3a824e35e9 100644
--- a/ui/src/scss/sp/sp-theme.scss
+++ b/ui/src/scss/sp/sp-theme.scss
@@ -56,7 +56,8 @@ html {
             surface-container-high: light-dark(#eeeeee, #1c1c1c),
             on-surface: light-dark(#1a1a1a, #e6e6e6),
             on-surface-variant: light-dark(#5e5e5e, #b5b5b5),
-            surface-tint: transparent
+            surface-tint: transparent,
+            background: light-dark(#fafafa, #121212)
         )
     );
 


Reply via email to