feat(ui/templates): split-pane layout with folder + composition tree
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user