From b6e2ec8a50a377fb3ade8c54595cd55e273be00d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 12 May 2026 03:32:39 -0400 Subject: [PATCH] refactor(ui/design): card grid, SMTP split, TemplateEdit vertical-stack Templates:

in flex header, Expand/Collapse moved into a Bulk actions dropdown, hover-visible kebab on tree nodes with aria-labels. TreeView CSS gets a .tv-kebab opacity-on-hover utility. TemplateCreate: form-control (not -sm) for primary inputs; accessible Back button. TemplateEdit: Properties card vertical-stacked with Save at the bottom-right and Parent rendered as readonly plaintext. Add-member forms (Attributes, Alarms, Scripts, Compositions) reflowed from horizontal row g-2 align-items-end into cards with stacked col-12 inputs (Scripts gets rows=10). Lock/Unlock badges show full words. Per-row Delete moved into a kebab dropdown. Tab nav gains role="tablist" / role="tab" / aria-selected / aria-controls and panels get role="tabpanel". Validation entries get consistent strong-and- muted styling. SharedScripts: migrated from table to card grid (col-lg-6) matching Sites; cards show code preview + param/return badges + Edit + kebab. Search filter, empty state CTA, @key. SharedScriptForm: small ?-icon tooltips next to Parameters and Return Definition labels. ExternalSystems: SMTP split out to its own page; remaining tabs ( External Systems, DB Connections, Notification Lists, API Methods, API Keys) unified as card grids with per-tab search + empty-state CTA. Tab nav gets full ARIA instrumentation. Header gains a link to the new SMTP page. New page SmtpConfiguration.razor at /design/smtp: vertical-stacked form using the existing Credentials field on the entity. ExternalSystemForm: AuthConfig placeholder updates based on the selected AuthType (None / ApiKey / BasicAuth). DbConnectionForm: form-text below Connection String noting that the value is stored in plain text and is admin-only. ApiMethodForm: Script textarea rows=10; JSON example placeholders for Params and Returns. NotificationListForm: form-control sizing on Name/email inputs; thead.table-dark -> table-light on the recipients table. --- .../Pages/Design/ApiMethodForm.razor | 8 +- .../Pages/Design/DbConnectionForm.razor | 1 + .../Pages/Design/ExternalSystemForm.razor | 12 +- .../Pages/Design/ExternalSystems.razor | 564 +++++++++++------- .../Pages/Design/NotificationListForm.razor | 12 +- .../Pages/Design/SharedScriptForm.razor | 14 +- .../Pages/Design/SharedScripts.razor | 138 +++-- .../Pages/Design/SmtpConfiguration.razor | 222 +++++++ .../Pages/Design/TemplateCreate.razor | 26 +- .../Pages/Design/TemplateEdit.razor | 359 +++++++---- .../Components/Pages/Design/Templates.razor | 74 ++- .../Components/Shared/TreeView.razor.css | 17 + 12 files changed, 1039 insertions(+), 408 deletions(-) create mode 100644 src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor index 5e50f84..67b3cfe 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ApiMethodForm.razor @@ -30,15 +30,17 @@
- +
- +
- +
@if (_formError != null) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor index 98e7099..83257b8 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/DbConnectionForm.razor @@ -27,6 +27,7 @@
+
Treat as sensitive — visible to admins only.
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor index 6136f52..8920202 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystemForm.razor @@ -33,11 +33,13 @@
- +
@@ -74,6 +76,14 @@ private ExternalSystemDefinition? _existing; + // Per-AuthType placeholder that hints at the expected JSON shape for AuthConfig. + private string _authConfigPlaceholder => _authType switch + { + "ApiKey" => "{\"key\":\"xyz\"}", + "BasicAuth" => "{\"username\":\"u\",\"password\":\"p\"}", + _ => "{}" + }; + protected override async Task OnInitializedAsync() { if (Id.HasValue) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor index 3250b14..a84053e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/ExternalSystems.razor @@ -11,10 +11,14 @@ @inject NavigationManager NavigationManager
-

Integration Definitions

+
+

Integration Definitions

+ Email configuration → +
- + @if (_loading) { @@ -26,33 +30,74 @@ } else { - - @if (_tab == "extsys") { @RenderExternalSystems() } - else if (_tab == "dbconn") { @RenderDbConnections() } - else if (_tab == "notif") { @RenderNotificationLists() @RenderSmtpConfig() } - else if (_tab == "inbound") { @RenderInboundApiMethods() @RenderApiKeyMethodAssignments() } + @if (_tab == "extsys") + { +
@RenderExternalSystems()
+ } + else if (_tab == "dbconn") + { +
@RenderDbConnections()
+ } + else if (_tab == "notif") + { +
@RenderNotificationLists()
+ } + else if (_tab == "inbound") + { +
@RenderInboundApiMethods()
+ } + else if (_tab == "apikeys") + { +
@RenderApiKeys()
+ } }
@@ -63,27 +108,44 @@ // External Systems private List _externalSystems = new(); + private string _extsysSearch = ""; + private IEnumerable FilteredExternalSystems => + string.IsNullOrWhiteSpace(_extsysSearch) + ? _externalSystems + : _externalSystems.Where(es => es.Name?.Contains(_extsysSearch, StringComparison.OrdinalIgnoreCase) ?? false); // Database Connections private List _dbConnections = new(); + private string _dbConnSearch = ""; + private IEnumerable FilteredDbConnections => + string.IsNullOrWhiteSpace(_dbConnSearch) + ? _dbConnections + : _dbConnections.Where(dc => dc.Name?.Contains(_dbConnSearch, StringComparison.OrdinalIgnoreCase) ?? false); - // SMTP Configuration - private List _smtpConfigs = new(); - private bool _showSmtpForm; - private SmtpConfiguration? _editingSmtp; - private string _smtpHost = "", _smtpFromAddress = "", _smtpAuthType = "OAuth2"; - private int _smtpPort = 587; - private string? _smtpFormError; - - // API Key list + // API Keys private List _apiKeys = new(); + private string _apiKeySearch = ""; + private IEnumerable FilteredApiKeys => + string.IsNullOrWhiteSpace(_apiKeySearch) + ? _apiKeys + : _apiKeys.Where(k => k.Name?.Contains(_apiKeySearch, StringComparison.OrdinalIgnoreCase) ?? false); // Notification Lists private List _notificationLists = new(); private Dictionary> _recipients = new(); + private string _notifSearch = ""; + private IEnumerable FilteredNotificationLists => + string.IsNullOrWhiteSpace(_notifSearch) + ? _notificationLists + : _notificationLists.Where(n => n.Name?.Contains(_notifSearch, StringComparison.OrdinalIgnoreCase) ?? false); // Inbound API Methods private List _apiMethods = new(); + private string _apiMethodSearch = ""; + private IEnumerable FilteredApiMethods => + string.IsNullOrWhiteSpace(_apiMethodSearch) + ? _apiMethods + : _apiMethods.Where(m => m.Name?.Contains(_apiMethodSearch, StringComparison.OrdinalIgnoreCase) ?? false); private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; @@ -110,7 +172,6 @@ } _apiMethods = (await InboundApiRepository.GetAllApiMethodsAsync()).ToList(); - _smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList(); _apiKeys = (await InboundApiRepository.GetAllApiKeysAsync()).ToList(); } catch (Exception ex) { _errorMessage = ex.Message; } @@ -120,27 +181,67 @@ // ==== External Systems ==== private RenderFragment RenderExternalSystems() => __builder => { -
-
External Systems
- +
+
External Systems
+
- - - - @foreach (var es in _externalSystems) + @if (_externalSystems.Count == 0) + { +
+

No external systems configured.

+ +
+ } + else + { +
+ +
+ + @if (!FilteredExternalSystems.Any()) + { +

No external systems match the filter.

+ } + +
+ @foreach (var es in FilteredExternalSystems) { -
- - - - +
+
+
+
+
@es.Name
+
+ + +
+
+

@es.EndpointUrl

+
+ @es.AuthType + Max @es.MaxRetries retries + Delay @es.RetryDelay.TotalSeconds s +
+
+
+
} - -
NameURLAuthRetriesDelayActions
@es.Name@es.EndpointUrl@es.AuthType@es.MaxRetries@es.RetryDelay.TotalSeconds s - - -
+
+ } }; private async Task DeleteExtSys(ExternalSystemDefinition es) @@ -153,27 +254,66 @@ // ==== Database Connections ==== private RenderFragment RenderDbConnections() => __builder => { -
-
Database Connections
- +
+
Database Connections
+
- - - - @foreach (var dc in _dbConnections) + @if (_dbConnections.Count == 0) + { +
+

No database connections configured.

+ +
+ } + else + { +
+ +
+ + @if (!FilteredDbConnections.Any()) + { +

No database connections match the filter.

+ } + +
+ @foreach (var dc in FilteredDbConnections) { -
- - - - +
+
+
+
+
@dc.Name
+
+ + +
+
+

@dc.ConnectionString

+
+ Max @dc.MaxRetries retries + Delay @dc.RetryDelay.TotalSeconds s +
+
+
+
} - -
NameConnection StringRetriesDelayActions
@dc.Name@dc.ConnectionString@dc.MaxRetries@dc.RetryDelay.TotalSeconds s - - -
+
+ } }; private async Task DeleteDbConn(DatabaseConnectionDefinition dc) @@ -186,39 +326,73 @@ // ==== Notification Lists ==== private RenderFragment RenderNotificationLists() => __builder => { -
-
Notification Lists
- +
+
Notification Lists
+
- @foreach (var list in _notificationLists) + @if (_notificationLists.Count == 0) { -
-
- @list.Name -
- - +
+

No notification lists configured.

+ +
+ } + else + { +
+ +
+ + @if (!FilteredNotificationLists.Any()) + { +

No notification lists match the filter.

+ } + +
+ @foreach (var list in FilteredNotificationLists) + { + var recips = _recipients.GetValueOrDefault(list.Id); +
+
+
+
+
@list.Name
+
+ + +
+
+ @if (recips == null || recips.Count == 0) + { +

No recipients.

+ } + else + { +
+ @foreach (var r in recips) + { + @r.Name <@r.EmailAddress> + } +
+ } +
+
-
-
- @{ - var recips = _recipients.GetValueOrDefault(list.Id); - } - @if (recips == null || recips.Count == 0) - { - No recipients. - } - else - { - @foreach (var r in recips) - { - - @r.Name <@r.EmailAddress> - - } - } -
+ }
} }; @@ -233,28 +407,69 @@ // ==== Inbound API Methods ==== private RenderFragment RenderInboundApiMethods() => __builder => { -
-
Inbound API Methods
- +
+
Inbound API Methods
+
- - - - @foreach (var m in _apiMethods) + @if (_apiMethods.Count == 0) + { +
+

No API methods configured.

+ +
+ } + else + { +
+ +
+ + @if (!FilteredApiMethods.Any()) + { +

No API methods match the filter.

+ } + +
+ @foreach (var m in FilteredApiMethods) { -
- - - - - + var preview = m.Script.Length > 80 ? m.Script[..80] + "…" : m.Script; +
+
+
+
+
+
@m.Name
+ POST /api/@m.Name +
+
+ + +
+
+
@preview
+ Timeout @m.TimeoutSeconds s +
+
+
} - -
NameTimeoutScript (preview)Actions
POST /api/@m.Name@m.TimeoutSeconds s@m.Script[..Math.Min(60, m.Script.Length)] - - -
+
+ } }; private async Task DeleteApiMethod(ApiMethod m) @@ -264,115 +479,56 @@ catch (Exception ex) { _toast.ShowError(ex.Message); } } - // ==== SMTP Configuration ==== - private RenderFragment RenderSmtpConfig() => __builder => + // ==== API Keys ==== + private RenderFragment RenderApiKeys() => __builder => { -
-
-
SMTP Configuration
- @if (_smtpConfigs.Count == 0) - { - - } +
+
API Keys
- @if (_showSmtpForm) + @if (_apiKeys.Count == 0) { -
-
-
-
-
-
-
-
- -
-
- @if (_smtpFormError != null) {
@_smtpFormError
} -
- } - - @foreach (var smtp in _smtpConfigs) - { -
-
-
- - @smtp.Host:@smtp.Port | - Auth: @smtp.AuthType | - From: @smtp.FromAddress - - -
-
+
+

No API keys configured. Add your first API key from the Admin section.

} - }; - - private void ShowSmtpAddForm() - { - _showSmtpForm = true; - _editingSmtp = null; - _smtpHost = string.Empty; - _smtpPort = 587; - _smtpAuthType = "OAuth2"; - _smtpFromAddress = string.Empty; - _smtpFormError = null; - } - - private async Task SaveSmtpConfig() - { - _smtpFormError = null; - if (string.IsNullOrWhiteSpace(_smtpHost) || string.IsNullOrWhiteSpace(_smtpFromAddress)) { _smtpFormError = "Host and From Address required."; return; } - try + else { - if (_editingSmtp != null) - { - _editingSmtp.Host = _smtpHost.Trim(); - _editingSmtp.Port = _smtpPort; - _editingSmtp.AuthType = _smtpAuthType; - _editingSmtp.FromAddress = _smtpFromAddress.Trim(); - await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp); - } - else - { - var smtp = new SmtpConfiguration(_smtpHost.Trim(), _smtpAuthType, _smtpFromAddress.Trim()) { Port = _smtpPort }; - await NotificationRepository.AddSmtpConfigurationAsync(smtp); - } - await NotificationRepository.SaveChangesAsync(); - _showSmtpForm = false; - _toast.ShowSuccess("SMTP configuration saved."); - await LoadAllAsync(); - } - catch (Exception ex) { _smtpFormError = ex.Message; } - } +
+ +
- // ==== API Key → Method Assignments ==== - private RenderFragment RenderApiKeyMethodAssignments() => __builder => - { -
-
-
API Keys
-
+ @if (!FilteredApiKeys.Any()) + { +

No API keys match the filter.

+ } - - - - @foreach (var key in _apiKeys) +
+ @foreach (var key in FilteredApiKeys) { -
- - - - +
+
+
+
+
@key.Name
+ + @(key.IsEnabled ? "Enabled" : "Disabled") + +
+
+ +
+
+
+
} - -
Key NameEnabledActions
@key.Name@(key.IsEnabled ? "Enabled" : "Disabled") - -
+
+ } }; private async Task ToggleApiKeyEnabled(ApiKey key) diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor index 56d83c2..c1c9903 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/NotificationListForm.razor @@ -44,25 +44,25 @@
- - + +
- - + +
@if (_recipientFormError != null) {
@_recipientFormError
}
- +
- + diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor index f13b4a6..83b04e6 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScriptForm.razor @@ -30,12 +30,22 @@ disabled="@(Id.HasValue)" />
- +
- +
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor index 7ad85e2..2557fe7 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SharedScripts.razor @@ -16,7 +16,7 @@ - + @if (_loading) { @@ -26,46 +26,81 @@ {
@_errorMessage
} + else if (_scripts.Count == 0) + { +
+

No shared scripts configured.

+ +
+ } else { -
Name Email
- - - - - - - - - - - - @if (_scripts.Count == 0) - { - - - - } - @foreach (var script in _scripts) - { - - - - - - - - - } - -
IDNameCode (preview)ParametersReturnsActions
No shared scripts configured.
@script.Id@script.Name - @script.Code[..Math.Min(60, script.Code.Length)]@(script.Code.Length > 60 ? "..." : "") - @(script.ParameterDefinitions ?? "—")@(script.ReturnDefinition ?? "—") - - -
+
+ +
+ + @if (!FilteredScripts.Any()) + { +

No shared scripts match the filter.

+ } + +
+ @foreach (var s in FilteredScripts) + { + var preview = s.Code.Length > 80 + ? s.Code[..80] + "…" + : s.Code; + var paramCount = CountJsonArrayEntries(s.ParameterDefinitions); +
+
+
+
+
@s.Name
+
+ + +
+
+ +
@preview
+ +
+ @paramCount params + @if (!string.IsNullOrWhiteSpace(s.ReturnDefinition)) + { + returns + } + else + { + void + } +
+
+
+
+ } +
}
@@ -79,10 +114,18 @@ private List _scripts = new(); private bool _loading = true; private string? _errorMessage; + private string _search = ""; private ToastNotification _toast = default!; private ConfirmDialog _confirmDialog = default!; + private IEnumerable FilteredScripts => + string.IsNullOrWhiteSpace(_search) + ? _scripts + : _scripts.Where(s => + (s.Name?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false) || + (s.Code?.Contains(_search, StringComparison.OrdinalIgnoreCase) ?? false)); + protected override async Task OnInitializedAsync() { await LoadDataAsync(); @@ -128,4 +171,21 @@ _toast.ShowError($"Delete failed: {ex.Message}"); } } + + /// + /// Best-effort count of JSON array entries by tallying top-level objects. + /// Returns 0 if the parameter definition is null/empty/malformed. + /// + private static int CountJsonArrayEntries(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return 0; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array) + return doc.RootElement.GetArrayLength(); + } + catch { /* fall through */ } + return 0; + } } diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor new file mode 100644 index 0000000..ee57e44 --- /dev/null +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/SmtpConfiguration.razor @@ -0,0 +1,222 @@ +@page "/design/smtp" +@using ScadaLink.Security +@using ScadaLink.Commons.Interfaces.Repositories +@using SmtpConfigurationEntity = ScadaLink.Commons.Entities.Notifications.SmtpConfiguration +@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] +@inject INotificationRepository NotificationRepository +@inject NavigationManager NavigationManager + +
+
+

SMTP Configuration

+
+ + + + + @if (_loading) + { + + } + else if (_errorMessage != null) + { +
@_errorMessage
+ } + else + { + @if (_smtpConfigs.Count == 0 && !_showForm) + { +
+

No SMTP configuration set.

+ +
+ } + else + { + @foreach (var smtp in _smtpConfigs) + { +
+
+ @smtp.Host + @if (_editingSmtp?.Id != smtp.Id || !_showForm) + { + + } +
+
+
+
Host
+
@smtp.Host:@smtp.Port
+
Auth Type
+
@smtp.AuthType
+
From Address
+
@smtp.FromAddress
+
Credentials
+
@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")
+
+
+
+ } + + @if (_showForm) + { +
+
@(_editingSmtp != null ? "Edit SMTP Configuration" : "Add SMTP Configuration")
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Treat as sensitive — visible to admins only.
+
+
+ + +
+ @if (_formError != null) + { +
@_formError
+ } +
+ + +
+
+
+
+ } + else if (_smtpConfigs.Count == 0) + { + + } + } + } +
+ +@code { + private bool _loading = true; + private string? _errorMessage; + + private List _smtpConfigs = new(); + private bool _showForm; + private SmtpConfigurationEntity? _editingSmtp; + + private string _host = string.Empty; + private int _port = 587; + private string _authType = "OAuth2"; + private string? _credentials; + private string _fromAddress = string.Empty; + private string? _formError; + + private ToastNotification _toast = default!; + private ConfirmDialog _confirmDialog = default!; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + _loading = true; + _errorMessage = null; + try + { + _smtpConfigs = (await NotificationRepository.GetAllSmtpConfigurationsAsync()).ToList(); + } + catch (Exception ex) + { + _errorMessage = ex.Message; + } + _loading = false; + } + + private void ShowAddForm() + { + _editingSmtp = null; + _host = string.Empty; + _port = 587; + _authType = "OAuth2"; + _credentials = null; + _fromAddress = string.Empty; + _formError = null; + _showForm = true; + } + + private void StartEdit(SmtpConfigurationEntity smtp) + { + _editingSmtp = smtp; + _host = smtp.Host; + _port = smtp.Port; + _authType = smtp.AuthType; + _credentials = smtp.Credentials; + _fromAddress = smtp.FromAddress; + _formError = null; + _showForm = true; + } + + private void CancelForm() + { + _showForm = false; + _formError = null; + } + + private async Task Save() + { + _formError = null; + if (string.IsNullOrWhiteSpace(_host) || string.IsNullOrWhiteSpace(_fromAddress)) + { + _formError = "Host and From Address are required."; + return; + } + + try + { + if (_editingSmtp != null) + { + _editingSmtp.Host = _host.Trim(); + _editingSmtp.Port = _port; + _editingSmtp.AuthType = _authType; + _editingSmtp.Credentials = _credentials?.Trim(); + _editingSmtp.FromAddress = _fromAddress.Trim(); + await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp); + } + else + { + var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim()) + { + Port = _port, + Credentials = _credentials?.Trim() + }; + await NotificationRepository.AddSmtpConfigurationAsync(smtp); + } + await NotificationRepository.SaveChangesAsync(); + _showForm = false; + _toast.ShowSuccess("SMTP configuration saved."); + await LoadAsync(); + } + catch (Exception ex) + { + _formError = ex.Message; + } + } +} diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor index 7af0213..5b9391c 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateCreate.razor @@ -11,7 +11,9 @@
- +

Create Template

@@ -24,13 +26,13 @@ {
-
- - +
+ +
-
- - @foreach (var t in _templates) { @@ -38,17 +40,17 @@ }
-
- - +
+ +
@if (_formError != null) {
@_formError
}
- - + +
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor index 6938ea3..1754e1d 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor +++ b/src/ScadaLink.CentralUI/Components/Pages/Design/TemplateEdit.razor @@ -14,10 +14,12 @@
- +
- +
@if (_loading) @@ -173,7 +175,13 @@
    @foreach (var err in _validationResult.Errors) { -
  • [@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")
  • +
  • + @err.Category @err.Message + @if (err.EntityName != null) + { + (@err.EntityName) + } +
  • }
@@ -185,7 +193,9 @@
    @foreach (var warn in _validationResult.Warnings) { -
  • [@warn.Category] @warn.Message
  • +
  • + @warn.Category @warn.Message +
  • }
@@ -201,49 +211,64 @@
Template Properties
-
-
- - +
+
+ +
-
- - +
+ +
-
- -
- @(_selectedTemplate.ParentTemplateId is int pid - ? _templates.FirstOrDefault(t => t.Id == pid)?.Name ?? $"#{pid}" - : "(none)") -
+
+ +
-
- +
+
@* Tabs: Attributes, Alarms, Scripts, Compositions *@ -

-
- - - - +
+

Templates

+
+ + + +
@@ -230,21 +246,61 @@ @node.Children.Count } + @RenderNodeKebab(node) break; case TmplNodeKind.Template: @node.Label + @RenderNodeKebab(node) break; case TmplNodeKind.Composition: @node.Label + @RenderNodeKebab(node) break; } }; + private RenderFragment RenderNodeKebab(TmplNode node) => __builder => + { + + + + + }; + private void OnTreeNodeSelected(object? key) { if (key is not string s) return; diff --git a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css index 9c62ee0..e2579b1 100644 --- a/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css +++ b/src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css @@ -133,3 +133,20 @@ [role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right { transform: rotate(90deg); } + +/* Per-row kebab (More actions): hidden by default, revealed on row hover or when + the dropdown is open. Consumers render `` inside + NodeContent to opt in. */ +.tv-row .tv-kebab { + flex: 0 0 auto; + opacity: 0; + transition: opacity 0.1s linear; + margin-left: 0.25rem; +} + +.tv-row:hover .tv-kebab, +.tv-row:focus-within .tv-kebab, +.tv-row .tv-kebab.show, +.tv-row .tv-kebab .show { + opacity: 1; +}