refactor(ui/design): card grid, SMTP split, TemplateEdit vertical-stack

Templates: <h4> 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.
This commit is contained in:
Joseph Doherty
2026-05-12 03:32:39 -04:00
parent da2c0d714e
commit b6e2ec8a50
12 changed files with 1039 additions and 408 deletions

View File

@@ -16,7 +16,7 @@
</div>
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
<ConfirmDialog @ref="_confirmDialog" ConfirmButtonClass="btn-danger" />
@if (_loading)
{
@@ -26,46 +26,81 @@
{
<div class="alert alert-danger">@_errorMessage</div>
}
else if (_scripts.Count == 0)
{
<div class="text-center py-5 text-muted">
<p class="mb-3">No shared scripts configured.</p>
<button class="btn btn-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo("/design/shared-scripts/create")'>
Create your first script
</button>
</div>
}
else
{
<table class="table table-sm table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Name</th>
<th>Code (preview)</th>
<th>Parameters</th>
<th>Returns</th>
<th style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@if (_scripts.Count == 0)
{
<tr>
<td colspan="6" class="text-muted text-center">No shared scripts configured.</td>
</tr>
}
@foreach (var script in _scripts)
{
<tr>
<td>@script.Id</td>
<td><strong>@script.Name</strong></td>
<td class="small text-muted font-monospace text-truncate" style="max-width: 300px;">
@script.Code[..Math.Min(60, script.Code.Length)]@(script.Code.Length > 60 ? "..." : "")
</td>
<td class="small text-muted">@(script.ParameterDefinitions ?? "—")</td>
<td class="small text-muted">@(script.ReturnDefinition ?? "—")</td>
<td>
<button class="btn btn-outline-primary btn-sm py-0 px-1 me-1"
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{script.Id}/edit")'>Edit</button>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<div class="mb-3" style="max-width: 320px;">
<input class="form-control form-control-sm"
placeholder="Filter by name or code…"
@bind="_search" @bind:event="oninput" />
</div>
@if (!FilteredScripts.Any())
{
<p class="text-muted small">No shared scripts match the filter.</p>
}
<div class="row g-3">
@foreach (var s in FilteredScripts)
{
var preview = s.Code.Length > 80
? s.Code[..80] + "…"
: s.Code;
var paramCount = CountJsonArrayEntries(s.ParameterDefinitions);
<div class="col-lg-6 col-12" @key="s.Id">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">@s.Name</h5>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm"
@onclick='() => NavigationManager.NavigateTo($"/design/shared-scripts/{s.Id}/edit")'>
Edit
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="@($"More actions for {s.Name}")">⋮</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item text-danger"
@onclick="() => DeleteScript(s)">Delete</button>
</li>
</ul>
</div>
</div>
</div>
<pre class="small text-muted font-monospace mb-2"
style="white-space: pre-wrap; word-break: break-all;"
title="@s.Code">@preview</pre>
<div>
<span class="badge bg-light text-dark me-1">@paramCount params</span>
@if (!string.IsNullOrWhiteSpace(s.ReturnDefinition))
{
<span class="badge bg-light text-dark">returns</span>
}
else
{
<span class="badge bg-light text-dark">void</span>
}
</div>
</div>
</div>
</div>
}
</div>
}
</div>
@@ -79,10 +114,18 @@
private List<SharedScript> _scripts = new();
private bool _loading = true;
private string? _errorMessage;
private string _search = "";
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
private IEnumerable<SharedScript> 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}");
}
}
/// <summary>
/// Best-effort count of JSON array entries by tallying top-level objects.
/// Returns 0 if the parameter definition is null/empty/malformed.
/// </summary>
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;
}
}