From f05b03f1cc879b65e1edba421153adeddae3d8b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 08:55:20 -0400 Subject: [PATCH] =?UTF-8?q?feat(templates/ui):=20phase=206-8=20=E2=80=94?= =?UTF-8?q?=20derived=20template=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../Pages/Design/TemplateEdit.razor | 304 +++++++++++++++++- .../Components/Pages/Design/Templates.razor | 20 +- 2 files changed, 310 insertions(+), 14 deletions(-) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 8ae5ede..dc54627 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -50,6 +50,13 @@ private List _scripts = new(); private List _compositions = new(); + // Populated only when _selectedTemplate.IsDerived โ€” keyed by member name. + private Dictionary _baseAttributesByName = new(StringComparer.Ordinal); + private Dictionary _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) + { +
+
+ + Derived from + @_baseTemplate.Name + @if (_ownerTemplate != null && _ownerComposition != null) + { + โ€” composed inside @_ownerTemplate.Name as @_ownerComposition.InstanceName. + } +
+
+ }
-

@_selectedTemplate!.Name

- @if (_selectedTemplate.ParentTemplateId.HasValue) +

@_selectedTemplate.Name

+ @if (_selectedTemplate.ParentTemplateId.HasValue && !_selectedTemplate.IsDerived) { inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name) } @@ -490,6 +543,7 @@
} + var derived = _selectedTemplate!.IsDerived; @@ -498,17 +552,29 @@ + @if (derived) + { + + } + else + { + + } @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; - - + + + @if (derived) + { + + } + else + { + + } @@ -539,6 +654,72 @@
Value Data Source LockSourceLock in derivedActions
@attr.Name @attr.DataType@(attr.Value ?? "โ€”")@(attr.DataSourceReference ?? "โ€”")@(effectiveValue ?? "โ€”")@(effectiveDataSource ?? "โ€”") @if (attr.IsLocked) { @@ -519,6 +585,37 @@ Unlocked } + @if (lockedByBase) + { + ๐Ÿ”’ Base-locked + } + else if (attr.IsInherited) + { + Inherited + } + else if (baseAttr != null) + { + Override + } + else + { + Local + } + +
+ +
+
}; + 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 @@
} + var derivedScripts = _selectedTemplate!.IsDerived; @@ -730,18 +912,29 @@ + @if (derivedScripts) + { + + } + else + { + + } @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; + title="@effectiveCode">@effectiveCode[..Math.Min(80, effectiveCode.Length)]@(effectiveCode.Length > 80 ? "..." : "") + @if (derivedScripts) + { + + } + else + { + + } @@ -772,6 +1005,51 @@
Trigger Code (preview) LockSourceLock in derivedActions
@script.Name @(script.TriggerType ?? "โ€”") @script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "") @if (script.IsLocked) { @@ -752,6 +945,37 @@ Unlocked } + @if (lockedByBase) + { + ๐Ÿ”’ Base-locked + } + else if (script.IsInherited) + { + Inherited + } + else if (baseScript != null) + { + Override + } + else + { + Local + } + +
+ +
+
}; + 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 => { diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor index 98127b8..6288311 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor @@ -62,6 +62,14 @@ +
+ + +
@@ -102,6 +110,7 @@ private List