feat(ui/templates): split-pane layout with folder + composition tree

This commit is contained in:
Joseph Doherty
2026-05-11 11:12:40 -04:00
parent 78165b3d99
commit 4977f99a74

View File

@@ -27,181 +27,56 @@
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_selectedTemplate == null)
{
@* Template list view *@
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Templates</h4>
<button class="btn btn-primary btn-sm" @onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>New Template</button>
</div>
<TreeView TItem="TmplTreeNode" Items="_templateTreeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Template.Id"
StorageKey="templates-tree"
Selectable="true"
SelectedKeyChanged="key => { if (key is int id) _ = SelectTemplate(id); }">
<NodeContent Context="node">
<strong>@node.Template.Name</strong>
@if (node.Template.ParentTemplateId.HasValue)
{
<span class="text-muted small ms-1">inherits @(_templates.FirstOrDefault(t => t.Id == node.Template.ParentTemplateId)?.Name)</span>
}
@if (!string.IsNullOrEmpty(node.Template.Description))
{
<span class="text-muted small ms-2">@node.Template.Description</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>
}
</NodeContent>
<ContextMenu Context="node">
<button class="dropdown-item" @onclick="() => SelectTemplate(node.Template.Id)">
Edit
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteTemplate(node.Template)">
Delete
</button>
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No templates. Create one to get started.</span>
</EmptyContent>
</TreeView>
}
else
{
@* Template detail/edit view *@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<button class="btn btn-outline-secondary btn-sm me-2" @onclick="BackToList">Back to List</button>
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li>[@warn.Category] @warn.Message</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" />
</div>
<div class="col-md-4">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_editParentId">
<option value="0">(None)</option>
@foreach (var t in _templates.Where(t => t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
<div class="row g-2">
<div class="col-md-4 col-lg-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Templates</h6>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" title="New folder at root"
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
<button class="btn btn-outline-secondary" title="New template at root"
@onclick="() => OpenNewTemplateDialog(null)">+ Template</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree.ExpandAll()">Expand</button>
<button class="btn btn-outline-secondary" @onclick="() => _tree.CollapseAll()">Collapse</button>
</div>
</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>
</div>
<div class="col-md-8 col-lg-9">
@if (_selectedTemplate == null)
{
<div class="text-muted fst-italic mt-3">
Select a template on the left to view or edit.
</div>
}
else
{
@RenderTemplateDetail()
}
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
@RenderAttributesTab()
}
else if (_activeTab == "alarms")
{
@RenderAlarmsTab()
}
else if (_activeTab == "scripts")
{
@RenderScriptsTab()
}
else if (_activeTab == "compositions")
{
@RenderCompositionsTab()
}
}
</div>
@@ -412,12 +287,193 @@
}
}
private void BackToList()
private TreeView<TmplNode> _tree = default!;
private RenderFragment RenderTemplateDetail() => __builder =>
{
_selectedTemplate = null;
_validationResult = null;
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="d-inline mb-0">@_selectedTemplate!.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li>[@warn.Category] @warn.Message</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" />
</div>
<div class="col-md-4">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_editParentId">
<option value="0">(None)</option>
@foreach (var t in _templates.Where(t => t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
</div>
</div>
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
@RenderAttributesTab()
}
else if (_activeTab == "alarms")
{
@RenderAlarmsTab()
}
else if (_activeTab == "scripts")
{
@RenderScriptsTab()
}
else if (_activeTab == "compositions")
{
@RenderCompositionsTab()
}
};
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;
}
};
private async Task OnTreeNodeSelected(object? key)
{
if (key is not string s) return;
if (s.StartsWith("t:") && int.TryParse(s[2..], out var tid))
{
await SelectTemplate(tid);
}
else if (s.StartsWith("c:") && int.TryParse(s[2..], out var cid))
{
var comp = _templates.SelectMany(t => t.Compositions).FirstOrDefault(c => c.Id == cid);
if (comp != null)
{
// Reveal + select the composed template.
await _tree.RevealNode($"t:{comp.ComposedTemplateId}", select: true);
await SelectTemplate(comp.ComposedTemplateId);
}
}
// Folder selection: no-op (Section 4 design — folder click does not load detail).
}
private RenderFragment RenderNodeContextMenu(TmplNode node) => __builder => { };
private void OpenNewFolderDialog(int? parentFolderId) { /* Task 17 */ }
private void OpenNewTemplateDialog(int? parentFolderId) { /* Task 17 */ }
private async Task DeleteTemplate(Template template)
{
var confirmed = await _confirmDialog.ShowAsync(