feat(ui/templates): native HTML5 drag-drop reorganization

This commit is contained in:
Joseph Doherty
2026-05-11 11:20:42 -04:00
parent fc105acd7c
commit c60aad9df4

View File

@@ -144,23 +144,26 @@
</div>
<div style="max-height: calc(100vh - 160px); overflow-y: auto;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="templates-tree"
Selectable="true"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent>
</TreeView>
<div @ondragover:preventDefault="true" @ondrop="OnDropOnRoot"
style="min-height: 100%; padding: 4px;">
<TreeView @ref="_tree" TItem="TmplNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Kind != TmplNodeKind.Composition && n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="templates-tree"
Selectable="true"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@RenderNodeLabel(node)
</NodeContent>
<ContextMenu Context="node">
@RenderNodeContextMenu(node)
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No templates yet. Use the buttons above to create a folder or template.</span>
</EmptyContent>
</TreeView>
</div>
</div>
</div>
@@ -389,6 +392,75 @@
private TreeView<TmplNode> _tree = default!;
// ---- Drag-and-drop state ----
private (TmplNodeKind kind, int id)? _dragPayload;
private string? _dragOverKey;
private void OnDragStart(TmplNode node)
{
if (node.Kind == TmplNodeKind.Composition) return;
_dragPayload = (node.Kind, node.EntityId);
}
private void OnDragEnd()
{
_dragPayload = null;
_dragOverKey = null;
}
private void OnDragEnter(TmplNode targetFolder)
{
if (_dragPayload == null) return;
if (targetFolder.Kind != TmplNodeKind.Folder) return;
_dragOverKey = targetFolder.Key;
}
private void OnDragLeave(TmplNode targetFolder)
{
if (_dragOverKey == targetFolder.Key) _dragOverKey = null;
}
private async Task OnDrop(TmplNode targetFolder)
{
if (_dragPayload is not { } payload) return;
if (targetFolder.Kind != TmplNodeKind.Folder) return;
var user = await GetCurrentUserAsync();
if (payload.kind == TmplNodeKind.Folder)
{
var result = await TemplateFolderService.MoveFolderAsync(payload.id, targetFolder.EntityId, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
else if (payload.kind == TmplNodeKind.Template)
{
var result = await TemplateService.MoveTemplateAsync(payload.id, targetFolder.EntityId, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
OnDragEnd();
await LoadTemplatesAsync();
}
private async Task OnDropOnRoot()
{
if (_dragPayload is not { } payload) return;
var user = await GetCurrentUserAsync();
if (payload.kind == TmplNodeKind.Folder)
{
var result = await TemplateFolderService.MoveFolderAsync(payload.id, null, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
else if (payload.kind == TmplNodeKind.Template)
{
var result = await TemplateService.MoveTemplateAsync(payload.id, null, user);
if (result.IsFailure) _toast.ShowError(result.Error);
}
OnDragEnd();
await LoadTemplatesAsync();
}
private RenderFragment RenderTemplateDetail() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -519,34 +591,51 @@
private RenderFragment RenderNodeLabel(TmplNode node) => __builder =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
<span class="me-1">📁</span>
<span>@node.Label</span>
<span class="badge bg-light text-dark ms-2">@node.Children.Count</span>
break;
case TmplNodeKind.Template:
<strong>@node.Label</strong>
if (node.Template?.ParentTemplateId is int pid)
{
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)</span>
}
<span class="badge bg-light text-dark ms-2">
@node.Template!.Attributes.Count attr,
@node.Template.Alarms.Count alm,
@node.Template.Scripts.Count scr
</span>
if (node.Template.Compositions.Count > 0)
{
<span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span>
}
break;
case TmplNodeKind.Composition:
<span>@node.Label</span>
<span class="text-muted small ms-1"> @(_templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name ?? $"#{node.Composition!.ComposedTemplateId}")</span>
break;
}
var draggable = node.Kind != TmplNodeKind.Composition;
var isDropTarget = node.Kind == TmplNodeKind.Folder;
var classes = "d-inline-block " + (_dragOverKey == node.Key ? "bg-info bg-opacity-25" : "");
var style = node.Kind == TmplNodeKind.Composition && _dragPayload != null
? "opacity: 0.5;" : "";
<div class="@classes" style="@style"
draggable="@(draggable ? "true" : "false")"
@ondragstart="() => OnDragStart(node)"
@ondragend="OnDragEnd"
@ondragenter="() => OnDragEnter(node)"
@ondragleave="() => OnDragLeave(node)"
@ondragover:preventDefault="@isDropTarget"
@ondrop="() => OnDrop(node)">
@switch (node.Kind)
{
case TmplNodeKind.Folder:
<span class="me-1">📁</span>
<span>@node.Label</span>
<span class="badge bg-light text-dark ms-2">@node.Children.Count</span>
break;
case TmplNodeKind.Template:
<strong>@node.Label</strong>
if (node.Template?.ParentTemplateId is int pid)
{
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)</span>
}
<span class="badge bg-light text-dark ms-2">
@node.Template!.Attributes.Count attr,
@node.Template.Alarms.Count alm,
@node.Template.Scripts.Count scr
</span>
if (node.Template.Compositions.Count > 0)
{
<span class="badge bg-info text-dark ms-1">@node.Template.Compositions.Count comp</span>
}
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
<span>@node.Label</span>
<span class="text-muted small ms-1">→ @composedName</span>
break;
}
</div>
};
private async Task OnTreeNodeSelected(object? key)