diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index d661774..02abcd8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -113,11 +113,6 @@ private ScadaLink.CentralUI.ScriptAnalysis.CompositionContext? ActiveEditorParent => _editorParents.FirstOrDefault(); - private bool _showCompForm; - private int _compComposedTemplateId; - private string _compInstanceName = string.Empty; - private string? _compFormError; - private ToastNotification _toast = default!; protected override async Task OnParametersSetAsync() @@ -341,15 +336,6 @@ Scripts @_scripts.Count - @if (_activeTab == "attributes") @@ -364,10 +350,6 @@ {
@RenderScriptsTab()
} - else if (_activeTab == "compositions") - { -
@RenderCompositionsTab()
- } }; private async Task DeleteTemplate() @@ -1029,81 +1011,6 @@ else _toast.ShowError(result.Error); } - // ---- Compositions Tab ---- - private RenderFragment RenderCompositionsTab() => __builder => - { -
-
Compositions
- -
- - @if (_showCompForm) - { -
-
Add Composition
-
-
-
- - -
-
- - -
- @if (_compFormError != null) - { -
@_compFormError
- } -
- - -
-
-
-
- } - - - - - - - - - - - @foreach (var comp in _compositions) - { - - - - - - } - -
Instance NameComposed TemplateActions
@comp.InstanceName@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}") - -
- }; - // ---- CRUD handlers ---- private async Task AddAttribute() @@ -1237,42 +1144,6 @@ 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 LoadAsync(); - } - else - { - _compFormError = result.Error; - } - } - - private async Task DeleteComposition(TemplateComposition comp) - { - var confirmed = await Dialog.ConfirmAsync("Delete Composition", $"Remove composition '{comp.InstanceName}'?", danger: true); - if (!confirmed) return; - var user = await GetCurrentUserAsync(); - var result = await TemplateService.DeleteCompositionAsync(comp.Id, user); - if (result.IsSuccess) - { - _toast.ShowSuccess($"Composition '{comp.InstanceName}' removed."); - await LoadAsync(); - } - else { _toast.ShowError(result.Error); } - } - // ---- Editor metadata builders ---- private async Task> BuildChildContextsAsync( diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor index 6288311..4f2511a 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor @@ -35,6 +35,13 @@ ErrorMessage="@_moveFolderError" OnSubmit="SubmitMoveFolder" /> + + @if (_loading) { @@ -299,7 +306,8 @@ break; case TmplNodeKind.Template: -
  • +
  • +
  • @@ -307,6 +315,9 @@ case TmplNodeKind.Composition:
  • +
  • +
  • +
  • break; } @@ -345,7 +356,8 @@ break; case TmplNodeKind.Template: - + + @@ -353,6 +365,9 @@ case TmplNodeKind.Composition: + + + break; } }; @@ -544,4 +559,89 @@ _toast.ShowError($"Delete failed: {ex.Message}"); } } + + // ---- Compose-into dialog ---- + private bool _showComposeDialog; + private int _composeSourceId; + private string _composeSourceName = string.Empty; + private string? _composeError; + + private void OpenComposeDialog(Template source) + { + _composeSourceId = source.Id; + _composeSourceName = source.Name; + _composeError = null; + _showComposeDialog = true; + } + + // Possible parents for a compose: every non-derived template except the source itself. + // Server still validates cycles + collisions; the picker just trims obvious bad choices. + private IEnumerable<(int Id, string Label)> EnumerateComposableParents(int sourceId) + { + return _templates + .Where(t => !t.IsDerived && t.Id != sourceId) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .Select(t => (t.Id, t.Name)); + } + + private async Task SubmitCompose((int SourceTemplateId, int ParentTemplateId, string SlotName) req) + { + _composeError = null; + var user = await GetCurrentUserAsync(); + var result = await TemplateService.AddCompositionAsync(req.ParentTemplateId, req.SourceTemplateId, req.SlotName, user); + if (result.IsSuccess) + { + _showComposeDialog = false; + _toast.ShowSuccess($"Composed '{_composeSourceName}' as '{req.SlotName}'."); + await LoadTemplatesAsync(); + } + else + { + _composeError = result.Error; + } + } + + // ---- Composition leaf: rename + delete ---- + private async Task RenameComposition(TemplateComposition composition) + { + var newName = await Dialog.PromptAsync( + "Rename slot", + $"New name for slot '{composition.InstanceName}':", + initialValue: composition.InstanceName, + placeholder: "Slot name"); + if (string.IsNullOrWhiteSpace(newName) || newName.Trim() == composition.InstanceName) return; + + var user = await GetCurrentUserAsync(); + var result = await TemplateService.RenameCompositionAsync(composition.Id, newName.Trim(), user); + if (result.IsSuccess) + { + _toast.ShowSuccess($"Slot renamed to '{newName.Trim()}'."); + await LoadTemplatesAsync(); + } + else + { + _toast.ShowError(result.Error); + } + } + + private async Task DeleteComposition(TemplateComposition composition) + { + var confirmed = await Dialog.ConfirmAsync( + "Delete composition", + $"Delete slot '{composition.InstanceName}'? This removes the derived template and any overrides on it.", + danger: true); + if (!confirmed) return; + + var user = await GetCurrentUserAsync(); + var result = await TemplateService.DeleteCompositionAsync(composition.Id, user); + if (result.IsSuccess) + { + _toast.ShowSuccess($"Composition '{composition.InstanceName}' removed."); + await LoadTemplatesAsync(); + } + else + { + _toast.ShowError(result.Error); + } + } } diff --git a/src/ScadaLink.CentralUI/Components/Shared/ComposeIntoDialog.razor b/src/ScadaLink.CentralUI/Components/Shared/ComposeIntoDialog.razor new file mode 100644 index 0000000..3e6bb1f --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Shared/ComposeIntoDialog.razor @@ -0,0 +1,68 @@ +@if (IsVisible) +{ + +} + +@code { + [Parameter] public bool IsVisible { get; set; } + [Parameter] public EventCallback IsVisibleChanged { get; set; } + [Parameter] public int SourceTemplateId { get; set; } + [Parameter] public string SourceName { get; set; } = string.Empty; + [Parameter] public IEnumerable<(int Id, string Label)> ParentOptions { get; set; } = Array.Empty<(int, string)>(); + [Parameter] public string? ErrorMessage { get; set; } + [Parameter] public EventCallback<(int SourceTemplateId, int ParentTemplateId, string SlotName)> OnSubmit { get; set; } + + private bool _wasVisible; + private int _parentTemplateId; + private string _slotName = string.Empty; + + protected override void OnParametersSet() + { + if (IsVisible && !_wasVisible) + { + _parentTemplateId = 0; + _slotName = SourceName; + } + _wasVisible = IsVisible; + } + + private async Task Close() => await IsVisibleChanged.InvokeAsync(false); + + private async Task Submit() + { + if (_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName)) return; + await OnSubmit.InvokeAsync((SourceTemplateId, _parentTemplateId, _slotName.Trim())); + } +} diff --git a/src/ScadaLink.TemplateEngine/TemplateService.cs b/src/ScadaLink.TemplateEngine/TemplateService.cs index a1dec64..7009d8c 100644 --- a/src/ScadaLink.TemplateEngine/TemplateService.cs +++ b/src/ScadaLink.TemplateEngine/TemplateService.cs @@ -654,6 +654,50 @@ public class TemplateService return Result.Success(composition); } + public async Task> RenameCompositionAsync( + int compositionId, + string newInstanceName, + string user, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(newInstanceName)) + return Result.Failure("Slot name is required."); + + var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken); + if (composition == null) + return Result.Failure($"Composition with ID {compositionId} not found."); + + if (composition.InstanceName == newInstanceName) return Result.Success(composition); + + var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken); + if (owner == null) + return Result.Failure($"Owning template with ID {composition.TemplateId} not found."); + + if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName)) + return Result.Failure( + $"Slot name '{newInstanceName}' already exists on '{owner.Name}'."); + + var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken); + if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId) + { + var newDerivedName = $"{owner.Name}.{newInstanceName}"; + var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken); + if (allTemplates.Any(t => t.Id != derived.Id && t.Name == newDerivedName)) + return Result.Failure( + $"Cannot rename derived template to '{newDerivedName}': a template with that name already exists."); + + derived.Name = newDerivedName; + await _repository.UpdateTemplateAsync(derived, cancellationToken); + } + + composition.InstanceName = newInstanceName; + await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken); + await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + + return Result.Success(composition); + } + public async Task> DeleteCompositionAsync( int compositionId, string user, diff --git a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs index 6cf9a1a..2bbf276 100644 --- a/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs +++ b/tests/ScadaLink.TemplateEngine.Tests/TemplateServiceTests.cs @@ -354,6 +354,45 @@ public class TemplateServiceTests Assert.Contains("already exists", result.Error); } + [Fact] + public async Task RenameComposition_RenamesSlotAndDerivedTemplate() + { + var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 }; + var owner = new Template("Pump") { Id = 1 }; + owner.Compositions.Add(composition); + var derived = new Template("Pump.OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 }; + + _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny())).ReturnsAsync(composition); + _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny())).ReturnsAsync(owner); + _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny())).ReturnsAsync(derived); + _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny())) + .ReturnsAsync(new List