feat(templates/ui): phase 6-8 — derived template UX

Templates tree hides IsDerived templates by default. A "Show derived"
form-switch in the page header toggles them into the listing so users
can reach orphaned derived templates when they need to.

TemplateEdit:
- Banner on derived templates: links to the base + the composing owner /
  slot name pulled from OwnerCompositionId.
- Attributes/Scripts tables grew a context-aware column:
  * On derived templates: a Source badge (Inherited / Override / Local)
    plus a 🔒 Base-locked badge when the base marks LockedInDerived.
  * On base templates: a switch that flips LockedInDerived through
    UpdateAttribute/UpdateScript.
- Effective Value / Code now resolves from the base when an inherited row
  carries a stale snapshot — matches the flatten-time behavior so the UI
  doesn't lie.
- Override / Revert-to-base actions added to the row kebab; delete is
  hidden on inherited rows (the base owns those).
This commit is contained in:
Joseph Doherty
2026-05-12 08:55:20 -04:00
parent f599809486
commit f05b03f1cc
2 changed files with 310 additions and 14 deletions

View File

@@ -50,6 +50,13 @@
private List<TemplateScript> _scripts = new();
private List<TemplateComposition> _compositions = new();
// Populated only when _selectedTemplate.IsDerived — keyed by member name.
private Dictionary<string, TemplateAttribute> _baseAttributesByName = new(StringComparer.Ordinal);
private Dictionary<string, TemplateScript> _baseScriptsByName = new(StringComparer.Ordinal);
private Template? _baseTemplate;
private Template? _ownerTemplate;
private TemplateComposition? _ownerComposition;
private bool _loading = true;
private string? _loadError;
private string _activeTab = "attributes";
@@ -144,6 +151,38 @@
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
// Derived-template context: base + slot owner power the override
// banner, inherited badges, and LockedInDerived rendering.
_baseAttributesByName = new(StringComparer.Ordinal);
_baseScriptsByName = new(StringComparer.Ordinal);
_baseTemplate = null;
_ownerTemplate = null;
_ownerComposition = null;
if (_selectedTemplate.IsDerived && _selectedTemplate.ParentTemplateId.HasValue)
{
_baseTemplate = await TemplateEngineRepository.GetTemplateByIdAsync(_selectedTemplate.ParentTemplateId.Value);
if (_baseTemplate != null)
{
foreach (var a in _baseTemplate.Attributes)
_baseAttributesByName[a.Name] = a;
foreach (var s in _baseTemplate.Scripts)
_baseScriptsByName[s.Name] = s;
}
if (_selectedTemplate.OwnerCompositionId.HasValue)
{
foreach (var t in _templates)
{
var c = t.Compositions.FirstOrDefault(x => x.Id == _selectedTemplate.OwnerCompositionId.Value);
if (c != null)
{
_ownerTemplate = t;
_ownerComposition = c;
break;
}
}
}
}
// Editor metadata: child compositions + every parent that
// composes this template. Powers Attributes["X"] /
// Children["Y"].Attributes["Z"] / Parent.Attributes["W"]
@@ -175,10 +214,24 @@
private RenderFragment RenderTemplateDetail() => __builder =>
{
@if (_selectedTemplate!.IsDerived && _baseTemplate != null)
{
<div class="alert alert-info py-2 mb-3 d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-diagram-3 me-1"></i>
<strong>Derived</strong> from
<a href="/design/templates/@_baseTemplate.Id"><code>@_baseTemplate.Name</code></a>
@if (_ownerTemplate != null && _ownerComposition != null)
{
<span class="ms-1">— composed inside <a href="/design/templates/@_ownerTemplate.Id"><code>@_ownerTemplate.Name</code></a> as <code>@_ownerComposition.InstanceName</code>.</span>
}
</div>
</div>
}
<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)
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue && !_selectedTemplate.IsDerived)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
@@ -490,6 +543,7 @@
</div>
}
var derived = _selectedTemplate!.IsDerived;
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
@@ -498,17 +552,29 @@
<th>Value</th>
<th>Data Source</th>
<th>Lock</th>
@if (derived)
{
<th>Source</th>
}
else
{
<th title="When true, derived templates may not override this row.">Lock in derived</th>
}
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _attributes)
{
_baseAttributesByName.TryGetValue(attr.Name, out var baseAttr);
var lockedByBase = derived && baseAttr != null && baseAttr.LockedInDerived;
var effectiveValue = (derived && attr.IsInherited && baseAttr != null) ? baseAttr.Value : attr.Value;
var effectiveDataSource = (derived && attr.IsInherited && baseAttr != null) ? baseAttr.DataSourceReference : attr.DataSourceReference;
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(attr.Value ?? "—")</td>
<td class="small text-muted">@(attr.DataSourceReference ?? "—")</td>
<td class="small">@(effectiveValue ?? "—")</td>
<td class="small text-muted">@(effectiveDataSource ?? "—")</td>
<td>
@if (attr.IsLocked)
{
@@ -519,6 +585,37 @@
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
@if (derived)
{
<td>
@if (lockedByBase)
{
<span class="badge bg-warning text-dark" title="Locked by base template — cannot override.">🔒 Base-locked</span>
}
else if (attr.IsInherited)
{
<span class="badge bg-secondary">Inherited</span>
}
else if (baseAttr != null)
{
<span class="badge bg-primary">Override</span>
}
else
{
<span class="badge bg-light text-dark">Local</span>
}
</td>
}
else
{
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
checked="@attr.LockedInDerived"
@onchange="(e) => ToggleAttrLockedInDerived(attr, e)" />
</div>
</td>
}
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
@@ -526,10 +623,28 @@
aria-expanded="false"
aria-label="@($"More actions for {attr.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (derived && baseAttr != null && !lockedByBase)
{
@if (attr.IsInherited)
{
<li>
<button class="dropdown-item" @onclick="() => OverrideAttribute(attr)">Override…</button>
</li>
}
else
{
<li>
<button class="dropdown-item" @onclick="() => RevertAttributeToBase(attr)">Revert to base</button>
</li>
}
}
@if (!(derived && baseAttr != null))
{
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteAttribute(attr)">Delete</button>
</li>
}
</ul>
</div>
</td>
@@ -539,6 +654,72 @@
</table>
};
private async Task ToggleAttrLockedInDerived(TemplateAttribute attr, ChangeEventArgs e)
{
var newValue = e.Value is bool b && b;
if (attr.LockedInDerived == newValue) return;
var user = await GetCurrentUserAsync();
var proposed = new TemplateAttribute(attr.Name)
{
Value = attr.Value,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = attr.Description,
DataSourceReference = attr.DataSourceReference,
IsInherited = attr.IsInherited,
LockedInDerived = newValue,
};
var result = await TemplateService.UpdateAttributeAsync(attr.Id, proposed, user);
if (result.IsSuccess) { attr.LockedInDerived = newValue; _toast.ShowSuccess("Lock-in-derived updated."); }
else _toast.ShowError(result.Error);
}
private async Task OverrideAttribute(TemplateAttribute attr)
{
var newVal = await Dialog.PromptAsync(
$"Override '{attr.Name}'",
"New value (overrides the base):",
placeholder: attr.Value ?? "");
if (newVal == null) return;
var user = await GetCurrentUserAsync();
var proposed = new TemplateAttribute(attr.Name)
{
Value = newVal,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = attr.Description,
DataSourceReference = attr.DataSourceReference,
IsInherited = false,
LockedInDerived = false,
};
var result = await TemplateService.UpdateAttributeAsync(attr.Id, proposed, user);
if (result.IsSuccess) { _toast.ShowSuccess($"Override saved for '{attr.Name}'."); await LoadAsync(); }
else _toast.ShowError(result.Error);
}
private async Task RevertAttributeToBase(TemplateAttribute attr)
{
var confirmed = await Dialog.ConfirmAsync(
"Revert to base",
$"Discard the override for '{attr.Name}' and follow the base value again?");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
_baseAttributesByName.TryGetValue(attr.Name, out var baseAttr);
var proposed = new TemplateAttribute(attr.Name)
{
Value = baseAttr?.Value,
DataType = attr.DataType,
IsLocked = attr.IsLocked,
Description = baseAttr?.Description,
DataSourceReference = baseAttr?.DataSourceReference,
IsInherited = true,
LockedInDerived = false,
};
var result = await TemplateService.UpdateAttributeAsync(attr.Id, proposed, user);
if (result.IsSuccess) { _toast.ShowSuccess($"'{attr.Name}' reverted to base."); await LoadAsync(); }
else _toast.ShowError(result.Error);
}
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
@@ -723,6 +904,7 @@
</div>
}
var derivedScripts = _selectedTemplate!.IsDerived;
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
@@ -730,18 +912,29 @@
<th>Trigger</th>
<th>Code (preview)</th>
<th>Lock</th>
@if (derivedScripts)
{
<th>Source</th>
}
else
{
<th title="When true, derived templates may not override this script.">Lock in derived</th>
}
<th style="width: 60px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var script in _scripts)
{
_baseScriptsByName.TryGetValue(script.Name, out var baseScript);
var lockedByBase = derivedScripts && baseScript != null && baseScript.LockedInDerived;
var effectiveCode = (derivedScripts && script.IsInherited && baseScript != null) ? baseScript.Code : script.Code;
<tr>
<td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace"
style="max-width: 300px;"
title="@script.Code">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td>
title="@effectiveCode">@effectiveCode[..Math.Min(80, effectiveCode.Length)]@(effectiveCode.Length > 80 ? "..." : "")</td>
<td>
@if (script.IsLocked)
{
@@ -752,6 +945,37 @@
<span class="badge bg-light text-dark" aria-label="Unlocked">Unlocked</span>
}
</td>
@if (derivedScripts)
{
<td>
@if (lockedByBase)
{
<span class="badge bg-warning text-dark" title="Locked by base template — cannot override.">🔒 Base-locked</span>
}
else if (script.IsInherited)
{
<span class="badge bg-secondary">Inherited</span>
}
else if (baseScript != null)
{
<span class="badge bg-primary">Override</span>
}
else
{
<span class="badge bg-light text-dark">Local</span>
}
</td>
}
else
{
<td>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
checked="@script.LockedInDerived"
@onchange="(e) => ToggleScriptLockedInDerived(script, e)" />
</div>
</td>
}
<td>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
@@ -759,10 +983,19 @@
aria-expanded="false"
aria-label="@($"More actions for {script.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (derivedScripts && baseScript != null && !lockedByBase && !script.IsInherited)
{
<li>
<button class="dropdown-item" @onclick="() => RevertScriptToBase(script)">Revert to base</button>
</li>
}
@if (!(derivedScripts && baseScript != null))
{
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(script)">Delete</button>
</li>
}
</ul>
</div>
</td>
@@ -772,6 +1005,51 @@
</table>
};
private async Task ToggleScriptLockedInDerived(TemplateScript script, ChangeEventArgs e)
{
var newValue = e.Value is bool b && b;
if (script.LockedInDerived == newValue) return;
var user = await GetCurrentUserAsync();
var proposed = new TemplateScript(script.Name, script.Code)
{
IsLocked = script.IsLocked,
TriggerType = script.TriggerType,
TriggerConfiguration = script.TriggerConfiguration,
ParameterDefinitions = script.ParameterDefinitions,
ReturnDefinition = script.ReturnDefinition,
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
IsInherited = script.IsInherited,
LockedInDerived = newValue,
};
var result = await TemplateService.UpdateScriptAsync(script.Id, proposed, user);
if (result.IsSuccess) { script.LockedInDerived = newValue; _toast.ShowSuccess("Lock-in-derived updated."); }
else _toast.ShowError(result.Error);
}
private async Task RevertScriptToBase(TemplateScript script)
{
var confirmed = await Dialog.ConfirmAsync(
"Revert to base",
$"Discard the override for script '{script.Name}' and follow the base body again?");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
_baseScriptsByName.TryGetValue(script.Name, out var baseScript);
var proposed = new TemplateScript(script.Name, baseScript?.Code ?? script.Code)
{
IsLocked = script.IsLocked,
TriggerType = baseScript?.TriggerType ?? script.TriggerType,
TriggerConfiguration = baseScript?.TriggerConfiguration ?? script.TriggerConfiguration,
ParameterDefinitions = baseScript?.ParameterDefinitions ?? script.ParameterDefinitions,
ReturnDefinition = baseScript?.ReturnDefinition ?? script.ReturnDefinition,
MinTimeBetweenRuns = baseScript?.MinTimeBetweenRuns ?? script.MinTimeBetweenRuns,
IsInherited = true,
LockedInDerived = false,
};
var result = await TemplateService.UpdateScriptAsync(script.Id, proposed, user);
if (result.IsSuccess) { _toast.ShowSuccess($"Script '{script.Name}' reverted to base."); await LoadAsync(); }
else _toast.ShowError(result.Error);
}
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{

View File

@@ -62,6 +62,14 @@
</li>
</ul>
</div>
<div class="form-check form-switch d-flex align-items-center gap-2 mb-0"
title="Derived templates back individual composition slots and are normally hidden.">
<input class="form-check-input" type="checkbox" role="switch"
id="show-derived-toggle"
checked="@_showDerived"
@onchange="OnToggleShowDerived" />
<label class="form-check-label small text-muted" for="show-derived-toggle">Show derived</label>
</div>
<button class="btn btn-outline-secondary btn-sm"
title="New folder at root"
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
@@ -102,6 +110,7 @@
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
private bool _showDerived;
private bool _loading = true;
private string? _errorMessage;
@@ -113,6 +122,12 @@
await LoadTemplatesAsync();
}
private void OnToggleShowDerived(ChangeEventArgs e)
{
_showDerived = e.Value is bool b && b;
BuildTemplateTree();
}
private async Task LoadTemplatesAsync()
{
_loading = true;
@@ -172,7 +187,10 @@
}
// 3. Template nodes with composition leaves
foreach (var t in _templates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
var visibleTemplates = _showDerived
? _templates
: _templates.Where(t => !t.IsDerived);
foreach (var t in visibleTemplates.OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase))
{
var compChildren = t.Compositions
.OrderBy(c => c.InstanceName, StringComparer.OrdinalIgnoreCase)