@_selectedTemplate!.Name
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)
}
@* Validation results *@
@if (_validationResult != null)
{
@if (_validationResult.Errors.Count > 0)
{
Validation Errors (@_validationResult.Errors.Count)
@foreach (var err in _validationResult.Errors)
{
- [@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")
}
}
@if (_validationResult.Warnings.Count > 0)
{
Warnings (@_validationResult.Warnings.Count)
@foreach (var warn in _validationResult.Warnings)
{
- [@warn.Category] @warn.Message
}
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
Validation passed with no errors or warnings.
}
}
@* Template info edit *@
OnDragStart(node)"
@ondragend="OnDragEnd"
@ondragenter="() => OnDragEnter(node)"
@ondragleave="() => OnDragLeave(node)"
@ondragover:preventDefault="@isDropTarget"
@ondrop="() => OnDrop(node)"
@ondrop:stopPropagation="true">
@switch (node.Kind)
{
case TmplNodeKind.Folder:
π
@node.Label
@node.Children.Count
break;
case TmplNodeKind.Template:
@node.Label
if (node.Template?.ParentTemplateId is int pid)
{
inherits @(_templates.FirstOrDefault(t => t.Id == pid)?.Name)
}
@node.Template!.Attributes.Count attr,
@node.Template.Alarms.Count alm,
@node.Template.Scripts.Count scr
if (node.Template.Compositions.Count > 0)
{
@node.Template.Compositions.Count comp
}
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
@node.Label
β @composedName
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 =>
{
switch (node.Kind)
{
case TmplNodeKind.Folder:
level, int depth)
{
foreach (var f in level.OrderBy(f => f.Name, StringComparer.OrdinalIgnoreCase))
{
yield return ((int?)f.Id, new string(' ', depth * 2) + f.Name);
foreach (var sub in WalkFolderHierarchy(_folders.Where(c => c.ParentFolderId == f.Id), depth + 1))
yield return sub;
}
}
// Rename folder dialog state
private bool _showRenameFolderDialog;
private int _renameFolderId;
private string _renameFolderInitialName = string.Empty;
private string? _renameFolderError;
private void OpenRenameFolderDialog(int folderId, string currentName)
{
_renameFolderId = folderId;
_renameFolderInitialName = currentName;
_renameFolderError = null;
_showRenameFolderDialog = true;
}
private async Task SubmitRenameFolder((int FolderId, string NewName) req)
{
_renameFolderError = null;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.RenameFolderAsync(req.FolderId, req.NewName, user);
if (result.IsSuccess)
{
_showRenameFolderDialog = false;
_toast.ShowSuccess("Folder renamed.");
await LoadTemplatesAsync();
}
else
{
_renameFolderError = result.Error;
}
}
private async Task DeleteFolder(int folderId, string label)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete folder '{label}'?", "Delete Folder");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateFolderService.DeleteFolderAsync(folderId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Folder '{label}' deleted.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task DeleteTemplate(Template template)
{
var confirmed = await _confirmDialog.ShowAsync(
$"Delete template '{template.Name}'? This will fail if instances or child templates reference it.",
"Delete Template");
if (!confirmed) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteTemplateAsync(template.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Template '{template.Name}' deleted.");
await LoadTemplatesAsync();
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Delete failed: {ex.Message}");
}
}
private async Task UpdateTemplateProperties()
{
if (_selectedTemplate == null) return;
try
{
var user = await GetCurrentUserAsync();
var result = await TemplateService.UpdateTemplateAsync(
_selectedTemplate.Id, _editName.Trim(), _editDescription?.Trim(),
_editParentId == 0 ? null : _editParentId, user);
if (result.IsSuccess)
{
_toast.ShowSuccess("Template properties updated.");
await LoadTemplatesAsync();
_selectedTemplate = result.Value;
}
else
{
_toast.ShowError(result.Error);
}
}
catch (Exception ex)
{
_toast.ShowError($"Update failed: {ex.Message}");
}
}
private async Task RunValidation()
{
if (_selectedTemplate == null) return;
_validating = true;
_validationResult = null;
try
{
// Use the full validation pipeline via TemplateService
// This performs flattening, collision detection, script compilation,
// trigger reference validation, and connection binding checks
var validationService = new ValidationService();
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{_selectedTemplate.Name}",
TemplateId = _selectedTemplate.Id,
Attributes = _attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference
}).ToList(),
Alarms = _alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
{
CanonicalName = a.Name,
PriorityLevel = a.PriorityLevel,
IsLocked = a.IsLocked,
TriggerType = a.TriggerType.ToString(),
TriggerConfiguration = a.TriggerConfiguration
}).ToList(),
Scripts = _scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList()
};
_validationResult = validationService.Validate(flatConfig);
// Also check for naming collisions across the inheritance/composition graph
var collisions = await TemplateService.DetectCollisionsAsync(_selectedTemplate.Id);
if (collisions.Count > 0)
{
var collisionErrors = collisions.Select(c =>
Commons.Types.Flattening.ValidationEntry.Error(
Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
_validationResult = Commons.Types.Flattening.ValidationResult.Merge(_validationResult, collisionResult);
}
}
catch (Exception ex)
{
_toast.ShowError($"Validation error: {ex.Message}");
}
_validating = false;
}
// ---- Attributes Tab ----
private RenderFragment RenderAttributesTab() => __builder =>
{
Attributes
@if (_showAttrForm)
{
@if (_attrFormError != null) {
@_attrFormError
}
}
| Name |
Type |
Value |
Data Source |
Lock |
Actions |
@foreach (var attr in _attributes)
{
| @attr.Name |
@attr.DataType |
@(attr.Value ?? "β") |
@(attr.DataSourceReference ?? "β") |
@if (attr.IsLocked)
{
L
}
else
{
U
}
|
|
}
};
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
Alarms
@if (_showAlarmForm)
{
@if (_alarmFormError != null) {
@_alarmFormError
}
}
| Name |
Trigger |
Priority |
Config |
Lock |
Actions |
@foreach (var alarm in _alarms)
{
| @alarm.Name |
@alarm.TriggerType |
@alarm.PriorityLevel |
@(alarm.TriggerConfiguration ?? "β") |
@if (alarm.IsLocked) { L }
else { U }
|
|
}
};
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
Scripts
@if (_showScriptForm)
{
@if (_scriptFormError != null) {
@_scriptFormError
}
}
| Name |
Trigger |
Code (preview) |
Lock |
Actions |
@foreach (var script in _scripts)
{
| @script.Name |
@(script.TriggerType ?? "β") |
@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "") |
@if (script.IsLocked) { L }
else { U }
|
|
}
};
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{
Compositions
@if (_showCompForm)
{
@if (_compFormError != null) {
@_compFormError
}
}
| Instance Name |
Composed Template |
Actions |
@foreach (var comp in _compositions)
{
@comp.InstanceName |
@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}") |
|
}
};
// ---- CRUD handlers ----
private async Task AddAttribute()
{
if (_selectedTemplate == null) return;
_attrFormError = null;
if (string.IsNullOrWhiteSpace(_attrName)) { _attrFormError = "Name is required."; return; }
var attr = new TemplateAttribute(_attrName.Trim())
{
DataType = _attrDataType,
Value = _attrValue?.Trim(),
IsLocked = _attrIsLocked,
DataSourceReference = _attrDataSourceRef?.Trim()
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAttributeAsync(_selectedTemplate.Id, attr, user);
if (result.IsSuccess)
{
_showAttrForm = false;
_toast.ShowSuccess($"Attribute '{_attrName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_attrFormError = result.Error;
}
}
private async Task DeleteAttribute(TemplateAttribute attr)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete attribute '{attr.Name}'?", "Delete Attribute");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAttributeAsync(attr.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Attribute '{attr.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else
{
_toast.ShowError(result.Error);
}
}
private async Task AddAlarm()
{
if (_selectedTemplate == null) return;
_alarmFormError = null;
if (string.IsNullOrWhiteSpace(_alarmName)) { _alarmFormError = "Name is required."; return; }
var alarm = new TemplateAlarm(_alarmName.Trim())
{
TriggerType = _alarmTriggerType,
PriorityLevel = _alarmPriority,
TriggerConfiguration = _alarmTriggerConfig?.Trim(),
IsLocked = _alarmIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddAlarmAsync(_selectedTemplate.Id, alarm, user);
if (result.IsSuccess)
{
_showAlarmForm = false;
_toast.ShowSuccess($"Alarm '{_alarmName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_alarmFormError = result.Error;
}
}
private async Task DeleteAlarm(TemplateAlarm alarm)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete alarm '{alarm.Name}'?", "Delete Alarm");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteAlarmAsync(alarm.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Alarm '{alarm.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
private async Task AddScript()
{
if (_selectedTemplate == null) return;
_scriptFormError = null;
if (string.IsNullOrWhiteSpace(_scriptName)) { _scriptFormError = "Name is required."; return; }
if (string.IsNullOrWhiteSpace(_scriptCode)) { _scriptFormError = "Code is required."; return; }
var script = new TemplateScript(_scriptName.Trim(), _scriptCode)
{
TriggerType = _scriptTriggerType?.Trim(),
TriggerConfiguration = _scriptTriggerConfig?.Trim(),
IsLocked = _scriptIsLocked
};
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddScriptAsync(_selectedTemplate.Id, script, user);
if (result.IsSuccess)
{
_showScriptForm = false;
_toast.ShowSuccess($"Script '{_scriptName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_scriptFormError = result.Error;
}
}
private async Task DeleteScript(TemplateScript script)
{
var confirmed = await _confirmDialog.ShowAsync($"Delete script '{script.Name}'?", "Delete Script");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteScriptAsync(script.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Script '{script.Name}' deleted.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
private async Task AddComposition()
{
if (_selectedTemplate == null) return;
_compFormError = null;
if (string.IsNullOrWhiteSpace(_compInstanceName)) { _compFormError = "Instance name is required."; return; }
if (_compComposedTemplateId == 0) { _compFormError = "Select a template."; return; }
var user = await GetCurrentUserAsync();
var result = await TemplateService.AddCompositionAsync(
_selectedTemplate.Id, _compComposedTemplateId, _compInstanceName.Trim(), user);
if (result.IsSuccess)
{
_showCompForm = false;
_toast.ShowSuccess($"Composition '{_compInstanceName}' added.");
await SelectTemplate(_selectedTemplate.Id);
}
else
{
_compFormError = result.Error;
}
}
private async Task DeleteComposition(TemplateComposition comp)
{
var confirmed = await _confirmDialog.ShowAsync($"Remove composition '{comp.InstanceName}'?", "Delete Composition");
if (!confirmed) return;
var user = await GetCurrentUserAsync();
var result = await TemplateService.DeleteCompositionAsync(comp.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Composition '{comp.InstanceName}' removed.");
if (_selectedTemplate != null) await SelectTemplate(_selectedTemplate.Id);
}
else { _toast.ShowError(result.Error); }
}
}