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:
@@ -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">
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteAttribute(attr)">Delete</button>
|
||||
</li>
|
||||
@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">
|
||||
<li>
|
||||
<button class="dropdown-item text-danger"
|
||||
@onclick="() => DeleteScript(script)">Delete</button>
|
||||
</li>
|
||||
@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 =>
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user