This is an automated email from the ASF dual-hosted git repository.
SvenO3 pushed a commit to branch
4568-allow-reordering-and-restructuring-of-assets-in-asset-view-edit-mode
in repository https://gitbox.apache.org/repos/asf/streampipes.git
The following commit(s) were added to
refs/heads/4568-allow-reordering-and-restructuring-of-assets-in-asset-view-edit-mode
by this push:
new bd8e248c13 Add asset reordering via drag and drop
bd8e248c13 is described below
commit bd8e248c1357fc1463336fb85e6fecac8aa44df9
Author: Sven Oehler <[email protected]>
AuthorDate: Fri Jun 19 17:50:29 2026 +0200
Add asset reordering via drag and drop
---
.../asset-selection-panel.component.html | 146 +++++++++++++++--
.../asset-selection-panel.component.scss | 42 ++++-
.../asset-selection-panel.component.ts | 181 ++++++++++++++++++++-
3 files changed, 346 insertions(+), 23 deletions(-)
diff --git
a/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.html
b/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.html
index 04e6ce96d4..accf15e8ec 100644
---
a/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.html
+++
b/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.html
@@ -46,7 +46,22 @@
<mat-tree-node
*matTreeNodeDef="let node"
matTreeNodeToggle
- class="tree-leaf"
+ class="tree-leaf tree-row"
+ cdkDropList
+ [id]="getDropTargetId(node)"
+ [cdkDropListData]="getChildAssets(node)"
+ [cdkDropListConnectedTo]="dropTargetIds"
+ [cdkDropListDisabled]="!editMode"
+ [cdkDropListEnterPredicate]="canEnterDropList"
+ [cdkDropListSortingDisabled]="true"
+ [class.drop-target-active]="
+ activeDropTargetAssetId === node.assetId
+ "
+ (cdkDropListEntered)="markDropTarget(node)"
+ (cdkDropListExited)="clearDropTarget(node)"
+ (cdkDropListDropped)="
+ dropAssetIntoParent($event, node)
+ "
>
<div
class="asset-node"
@@ -57,8 +72,34 @@
'select-asset-' + node.assetName
"
(click)="selectNode(node, false)"
+ cdkDrag
+ [cdkDragData]="node"
+ [cdkDragDisabled]="!editMode"
+ cdkDragLockAxis="y"
+ cdkDragBoundary=".tree-wrapper"
>
<div class="node-main">
+ @if (editMode) {
+ <button
+ mat-icon-button
+ type="button"
+ class="node-drag-handle"
+ cdkDragHandle
+ [matTooltip]="
+ 'Reorder asset' | translate
+ "
+ [attr.aria-label]="
+ 'Reorder asset' | translate
+ "
+ (click)="
+ $event.stopPropagation()
+ "
+ >
+ <mat-icon
+ >drag_indicator</mat-icon
+ >
+ </button>
+ }
<mat-icon
class="node-icon"
[class.node-icon-selected]="
@@ -92,14 +133,20 @@
'add-asset-' +
node.assetName
"
- (click)="addAsset(node)"
+ (click)="
+ addAsset(node);
+ $event.stopPropagation()
+ "
color="accent"
>
<mat-icon>add</mat-icon>
</button>
<button
mat-icon-button
- (click)="deleteAsset(node)"
+ (click)="
+ deleteAsset(node);
+ $event.stopPropagation()
+ "
color="accent"
>
<mat-icon>delete</mat-icon>
@@ -112,7 +159,26 @@
<mat-nested-tree-node
*matTreeNodeDef="let node; when: hasChild"
>
- <div class="mat-tree-node">
+ <div
+ class="mat-tree-node tree-row"
+ cdkDropList
+ [id]="getDropTargetId(node)"
+ [cdkDropListData]="getChildAssets(node)"
+ [cdkDropListConnectedTo]="dropTargetIds"
+ [cdkDropListDisabled]="!editMode"
+ [cdkDropListEnterPredicate]="
+ canEnterDropList
+ "
+ [cdkDropListSortingDisabled]="true"
+ [class.drop-target-active]="
+ activeDropTargetAssetId ===
node.assetId
+ "
+ (cdkDropListEntered)="markDropTarget(node)"
+ (cdkDropListExited)="clearDropTarget(node)"
+ (cdkDropListDropped)="
+ dropAssetIntoParent($event, node)
+ "
+ >
<button
mat-icon-button
matTreeNodeToggle
@@ -134,12 +200,44 @@
</button>
<div
class="asset-node"
- [class.root-node]="
- node.assetId === assetModel.assetId
+ [class.root-node]="isRootNode(node)"
+ (click)="
+ selectNode(node, isRootNode(node))
"
- (click)="selectNode(node, true)"
+ cdkDrag
+ [cdkDragData]="node"
+ [cdkDragDisabled]="
+ !editMode || isRootNode(node)
+ "
+ cdkDragLockAxis="y"
+ cdkDragBoundary=".tree-wrapper"
>
<div class="node-main">
+ @if (
+ editMode && !isRootNode(node)
+ ) {
+ <button
+ mat-icon-button
+ type="button"
+ class="node-drag-handle"
+ cdkDragHandle
+ [matTooltip]="
+ 'Reorder asset'
+ | translate
+ "
+ [attr.aria-label]="
+ 'Reorder asset'
+ | translate
+ "
+ (click)="
+
$event.stopPropagation()
+ "
+ >
+ <mat-icon
+
>drag_indicator</mat-icon
+ >
+ </button>
+ }
<mat-icon
class="node-icon"
[class.node-icon-selected]="
@@ -147,7 +245,12 @@
selectedAsset.assetId
"
>
- account_tree
+ {{
+ isRootNode(node) ||
+ node.assets?.length
+ ? 'account_tree'
+ : 'device_hub'
+ }}
</mat-icon>
<div class="node-text">
<div
@@ -159,11 +262,11 @@
>
{{ node.assetName }}
</div>
+ <div class="node-meta">
+ {{ node.assetDescription }}
+ </div>
</div>
- @if (
- node.assetId ===
- assetModel.assetId
- ) {
+ @if (isRootNode(node)) {
<span class="badge">Root</span>
}
</div>
@@ -171,7 +274,10 @@
<div class="node-actions">
<button
mat-icon-button
- (click)="addAsset(node)"
+ (click)="
+ addAsset(node);
+
$event.stopPropagation()
+ "
[attr.data-cy]="
'add-asset-' +
node.assetName
@@ -180,6 +286,20 @@
>
<mat-icon>add</mat-icon>
</button>
+ @if (!node.assets?.length) {
+ <button
+ mat-icon-button
+ (click)="
+ deleteAsset(node);
+
$event.stopPropagation()
+ "
+ color="accent"
+ >
+ <mat-icon
+ >delete</mat-icon
+ >
+ </button>
+ }
</div>
}
</div>
diff --git
a/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.scss
b/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.scss
index 7a8e01c302..0d70087255 100644
---
a/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.scss
+++
b/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.scss
@@ -53,12 +53,18 @@
padding-left: 6px;
}
-.mat-tree-node {
+.mat-tree-node,
+.tree-row {
min-height: 48px;
font-family: inherit;
}
-.mat-tree-node:hover .asset-node {
+.tree-row {
+ width: 100%;
+ border-radius: 10px;
+}
+
+.tree-row:hover .asset-node {
background: var(--color-bg-1);
}
@@ -77,10 +83,33 @@
background: var(--color-bg-0);
}
-.asset-node:hover {
+.cdk-drag-preview .asset-node,
+.cdk-drag-preview.asset-node {
+ box-shadow: 0 4px 12px rgb(0 0 0 / 20%);
+}
+
+.cdk-drag-placeholder {
+ opacity: 0.35;
+}
+
+.cdk-drag-animating,
+.cdk-drop-list-dragging .mat-tree-node:not(.cdk-drag-placeholder) {
+ transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}
+
+.tree-row:hover .asset-node {
border-color: var(--color-primary);
}
+.tree-row.drop-target-active .asset-node {
+ background: color-mix(in srgb, var(--color-primary) 12%, white);
+ box-shadow: inset 0 0 0 2px var(--color-primary);
+}
+
+.tree-row.drop-target-active .node-toggle {
+ color: var(--color-primary);
+}
+
.asset-node-disabled {
color: var(--color-bg-3);
cursor: default;
@@ -102,6 +131,7 @@
display: flex;
align-items: center;
gap: 10px;
+ min-width: 0;
}
.node-icon {
@@ -119,6 +149,7 @@
.node-text {
display: flex;
flex-direction: column;
+ min-width: 0;
}
.node-title {
@@ -135,6 +166,11 @@
gap: 6px;
}
+.node-drag-handle {
+ cursor: grab;
+ flex: 0 0 auto;
+}
+
.node-toggle {
margin-right: 6px;
}
diff --git
a/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.ts
b/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.ts
index e4efb77e9e..e018a4fbfa 100644
---
a/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.ts
+++
b/ui/src/app/assets/components/asset-details/edit-asset/asset-selection-panel/asset-selection-panel.component.ts
@@ -20,12 +20,19 @@ import {
Component,
EventEmitter,
Input,
+ OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { SpAsset, SpAssetModel } from '@streampipes/platform-services';
import { NestedTreeControl } from '@angular/cdk/tree';
+import {
+ CdkDrag,
+ CdkDragDrop,
+ CdkDragHandle,
+ CdkDropList,
+} from '@angular/cdk/drag-drop';
import {
MatNestedTreeNode,
MatTree,
@@ -45,6 +52,9 @@ import { SpBasicViewComponent } from '@streampipes/shared-ui';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
+import { MatTooltip } from '@angular/material/tooltip';
+
+const HOVER_EXPAND_DELAY_MS = 500;
@Component({
selector: 'sp-asset-selection-panel',
@@ -65,9 +75,13 @@ import { TranslatePipe } from '@ngx-translate/core';
MatNestedTreeNode,
MatTreeNodeOutlet,
TranslatePipe,
+ CdkDropList,
+ CdkDrag,
+ CdkDragHandle,
+ MatTooltip,
],
})
-export class SpAssetSelectionPanelComponent implements OnInit {
+export class SpAssetSelectionPanelComponent implements OnInit, OnDestroy {
@Input()
assetModel: SpAssetModel;
@@ -87,7 +101,11 @@ export class SpAssetSelectionPanelComponent implements
OnInit {
@ViewChild('tree') tree;
hasChild = (_: number, node: SpAsset) =>
- !!node.assets && node.assets.length > 0;
+ this.editMode || (!!node.assets && node.assets.length > 0);
+
+ dropTargetIds: string[] = [];
+ activeDropTargetAssetId: string | undefined;
+ private hoverExpandTimer: ReturnType<typeof setTimeout> | undefined;
ngOnInit(): void {
this.treeControl = new NestedTreeControl<SpAsset>(node => node.assets);
@@ -95,16 +113,17 @@ export class SpAssetSelectionPanelComponent implements
OnInit {
this.resetTree();
}
+ ngOnDestroy(): void {
+ this.cancelHoverExpand();
+ }
+
selectNode(asset: SpAsset, rootNode: boolean) {
this.selectedAssetEmitter.emit({ asset, rootNode });
this.expandToAsset(asset.assetId);
}
addAsset(node: SpAsset) {
- if (!node.assets) {
- node.assets = [];
- }
- node.assets.push(this.makeNewAsset());
+ this.getChildAssets(node).push(this.makeNewAsset());
this.dataSource.data = [this.assetModel];
this.treeControl.dataNodes = [this.assetModel];
this.rerenderTree();
@@ -113,7 +132,7 @@ export class SpAssetSelectionPanelComponent implements
OnInit {
rerenderTree(): void {
this.dataSource.data = [];
this.dataSource.data = [this.assetModel];
- this.treeControl.expandAll();
+ this.refreshDropListIds();
}
deleteAsset(node: SpAsset) {
@@ -121,6 +140,85 @@ export class SpAssetSelectionPanelComponent implements
OnInit {
this.rerenderTree();
}
+ dropAssetIntoParent(
+ event: CdkDragDrop<SpAsset[]>,
+ targetParent: SpAsset,
+ ): void {
+ this.cancelHoverExpand();
+ this.activeDropTargetAssetId = undefined;
+ const draggedAsset = event.item.data as SpAsset;
+ let assetWasMoved = false;
+ if (this.canDropAsset(draggedAsset, targetParent)) {
+ const sourceAssets = this.findParentAssets(draggedAsset.assetId);
+ const targetAssets = this.getChildAssets(targetParent);
+ const sourceIndex = sourceAssets?.findIndex(
+ asset => asset.assetId === draggedAsset.assetId,
+ );
+
+ if (sourceAssets && sourceIndex !== undefined && sourceIndex >= 0)
{
+ sourceAssets.splice(sourceIndex, 1);
+ targetAssets.push(draggedAsset);
+ assetWasMoved = true;
+ }
+ }
+
+ this.rerenderTree();
+ if (assetWasMoved) {
+ this.expandToAsset(draggedAsset.assetId);
+ if (this.selectedAsset?.assetId === draggedAsset.assetId) {
+ this.selectNode(draggedAsset, this.isRootNode(draggedAsset));
+ }
+ }
+ }
+
+ canEnterDropList = (drag: CdkDrag, drop: CdkDropList): boolean => {
+ const targetParent = this.findAssetByDropListId(drop.id);
+ return this.canDropAsset(drag.data as SpAsset, targetParent);
+ };
+
+ markDropTarget(node: SpAsset): void {
+ this.activeDropTargetAssetId = node.assetId;
+ this.scheduleHoverExpand(node);
+ }
+
+ clearDropTarget(node: SpAsset): void {
+ if (this.activeDropTargetAssetId === node.assetId) {
+ this.activeDropTargetAssetId = undefined;
+ this.cancelHoverExpand();
+ }
+ }
+
+ canDropAsset(
+ draggedAsset: SpAsset | undefined,
+ targetParent: SpAsset | undefined,
+ ): boolean {
+ if (!draggedAsset || !targetParent) {
+ return false;
+ }
+ const sourceAssets = this.findParentAssets(draggedAsset.assetId);
+ return (
+ !this.isRootNode(draggedAsset) &&
+ draggedAsset.assetId !== targetParent.assetId &&
+ sourceAssets !== (targetParent.assets || []) &&
+ !this.isDescendantOf(draggedAsset, targetParent.assetId)
+ );
+ }
+
+ getDropTargetId(node: SpAsset): string {
+ return `asset-drop-target-${node.assetId}`;
+ }
+
+ getChildAssets(node: SpAsset): SpAsset[] {
+ if (!node.assets) {
+ node.assets = [];
+ }
+ return node.assets;
+ }
+
+ isRootNode(node: SpAsset): boolean {
+ return node.assetId === this.assetModel.assetId;
+ }
+
removeAssetWithId(assets: SpAsset[], id: string) {
for (let i = 0; i < assets.length; i++) {
if (assets[i].assetId === id) {
@@ -154,9 +252,78 @@ export class SpAssetSelectionPanelComponent implements
OnInit {
private resetTree() {
this.dataSource.data = [this.assetModel];
this.treeControl.dataNodes = [this.assetModel];
+ this.refreshDropListIds();
this.treeControl.expandAll();
}
+ private refreshDropListIds(): void {
+ this.dropTargetIds = this.getAllAssets(this.assetModel).map(node =>
+ this.getDropTargetId(node),
+ );
+ }
+
+ private getAllAssets(node: SpAsset): SpAsset[] {
+ return [
+ node,
+ ...(node.assets?.flatMap(child => this.getAllAssets(child)) || []),
+ ];
+ }
+
+ private isDescendantOf(asset: SpAsset, targetAssetId: string): boolean {
+ return (
+ asset.assets?.some(
+ child =>
+ child.assetId === targetAssetId ||
+ this.isDescendantOf(child, targetAssetId),
+ ) || false
+ );
+ }
+
+ private findParentAssets(assetId: string): SpAsset[] | undefined {
+ return this.findParentAssetsRecursive(this.assetModel, assetId);
+ }
+
+ private findParentAssetsRecursive(
+ parent: SpAsset,
+ assetId: string,
+ ): SpAsset[] | undefined {
+ const children = parent.assets || [];
+ if (children.some(child => child.assetId === assetId)) {
+ return children;
+ }
+ for (const child of children) {
+ const parentAssets = this.findParentAssetsRecursive(child,
assetId);
+ if (parentAssets) {
+ return parentAssets;
+ }
+ }
+ return undefined;
+ }
+
+ private findAssetByDropListId(dropListId: string): SpAsset | undefined {
+ const dropTarget = this.getAllAssets(this.assetModel).find(
+ node => this.getDropTargetId(node) === dropListId,
+ );
+ return dropTarget;
+ }
+
+ private scheduleHoverExpand(node: SpAsset): void {
+ this.cancelHoverExpand();
+ this.hoverExpandTimer = setTimeout(() => {
+ if (this.activeDropTargetAssetId === node.assetId) {
+ this.treeControl.expand(node);
+ }
+ this.hoverExpandTimer = undefined;
+ }, HOVER_EXPAND_DELAY_MS);
+ }
+
+ private cancelHoverExpand(): void {
+ if (this.hoverExpandTimer) {
+ clearTimeout(this.hoverExpandTimer);
+ this.hoverExpandTimer = undefined;
+ }
+ }
+
private expandToAsset(assetId: string) {
const path = this.findPath(this.assetModel, assetId);
if (path) {