Commit Graph

509 Commits

Author SHA1 Message Date
Joseph Doherty
e667ea2b50 test(ui/design): roundtrip tests + normalization notice for IO editors
Editors now set a _normalized flag when ParseFromJson coalesces a
legacy type name (lowercase "string", "Int32", "Double", etc.) to the
canonical set. When flagged, render a small alert-info inline:
"Some parameter types were normalized... Save to persist the
canonical form." The flag clears on any user edit so the notice
doesn't linger after Emit overwrites the JSON.

31 new bUnit tests in tests/.../Shared/:
  - ParameterListEditorTests: null/empty rendering, row count per
    JSON entry, legacy type normalization across .NET names +
    lowercase, the normalized notice trigger, add/remove emission,
    List/non-List item-type column visibility, required-flag round
    trip, invalid JSON + non-array error paths.
  - ReturnTypeEditorTests: null vs simple vs List shape, legacy type
    normalization, change-type / clear-type emission, invalid JSON
    + non-object error paths.

Total CentralUI test count: 82 -> 113.
2026-05-12 04:27:00 -04:00
Joseph Doherty
1b98d37919 refactor(ui/design): replace JSON inputs with structured editors
Two new shared components in Components/Shared:
  - ParameterListEditor: table of rows (name + type + item type + required + remove)
  - ReturnTypeEditor: single type (+ item type when List)

Both round-trip the same JSON shape already stored on the entity:
  parameters: [{"name":"x","type":"String","required":true},...]
  return:     {"type":"List","itemType":"Integer"} | null

Type set follows the Inbound API validator (Boolean, Integer, Float,
String, Object, List). Legacy values normalize on read — Int32 / int64
/ Double / Decimal / lowercase string / etc all coalesce to the new
set so existing rows render correctly. Re-saving persists the
normalized form.

Applied to:
  - SharedScriptForm
  - TemplateEdit Add Script form (also surfaces ParameterDefinitions
    + ReturnDefinition which the entity supported but the form was
    never wiring through)
  - ApiMethodForm

Graceful degradation: invalid JSON is shown with a "Start fresh"
escape hatch instead of crashing the form.
2026-05-12 04:22:58 -04:00
Joseph Doherty
eb1d6872ef refactor(ui/shared): migrate sidebar CSS to Bootstrap variables
Replaces hardcoded sidebar / nav-link hex colors with Bootstrap CSS
custom properties (var(--bs-dark), var(--bs-primary), var(--bs-gray-*),
var(--bs-white)). Visual parity preserved; rebrand/dark-mode work
later can override the variables without touching this file.

Only the reconnect overlay rgba(0,0,0,0.5) is left as a literal —
Bootstrap doesn't ship a backdrop-overlay token.
2026-05-12 03:57:45 -04:00
Joseph Doherty
8038aa7cb5 refactor(ui/shared): introduce IDialogService + DialogHost
Eliminates the per-page <ConfirmDialog @ref="_confirmDialog"
ConfirmButtonClass="btn-danger" /> boilerplate. Pages now inject
IDialogService and call ConfirmAsync(title, message, danger: true)
programmatically.

New scoped service holds a single active dialog (throws on nested
calls), with a global DialogHost mounted once in MainLayout that
renders the modal markup, owns body scroll-lock via Bootstrap's
modal-open class, traps focus on the modal element, and handles
Escape-to-cancel.

Same service also exposes PromptAsync, used to replace the bespoke
NewFolderDialog. Both ConfirmDialog and NewFolderDialog components
are deleted — their callers (~13 pages across Admin/Design/Deployment
/Monitoring) now go through the service.

DiffDialog stays as-is — different use case (before/after content).

bUnit tests in TopologyPageTests, DataConnectionsPageTests, and
TemplatesPageTests register IDialogService in their service
collection.

Also: a top-of-file Razor comment on Sites.razor pointing future
implementers at it as the reference list-page pattern.
2026-05-12 03:57:37 -04:00
Joseph Doherty
e21791adb0 refactor(ui/monitoring): KPI dashboard, message expand, copy, pagination fix
Dashboard: user-info card demoted; 4 KPI cards (Sites, Data
connections, Templates, API keys) sourced from existing repositories;
3 Quick-action link cards (Health, Audit Log, Templates). Inline
max-width style replaced with Bootstrap utilities.

Health: KPI row condensed to Online / Offline / Sites with active
errors (Total Sites and Total Script Errors dropped). Per-site cards
re-laid out 2-column with each subsection (Data Connections,
Instances & Queues, Errors & Parked Messages) inside Bootstrap
collapse panels collapsed by default. Online / Offline / Primary /
Standby badges paired with shape glyphs (o / * / triangle) plus
aria-label.

EventLogs: filter row wrapped in a Bootstrap collapse toggled by
"Filter options (n active)"; per-row View toggle reveals the full
message in a collapse row; "Keyword" relabeled "Message contains";
all filter inputs gain id+label-for+aria-label; severity badges paired
with a leading glyph; explicit "End of results" terminator on
Load more.

ParkedMessages: Message ID rendered as <code>{first 12}...</code>
plus a clipboard button; per-row View toggle reveals full error;
action buttons get aria-label="{Retry|Discard} message {id}";
in-flight spinner inside the active button.

AuditLog: pagination Next-disabled now uses
_page * _pageSize >= _totalCount via HasMore helper (fixes the
exactly-page-size edge case). Clear filters button added. Entity ID
rendered as code + clipboard button. View/Hide buttons gain
aria-label referencing the entry id. State JSON larger than 1 KB
renders a "View in modal" button instead of the inline overflow.
2026-05-12 03:33:06 -04:00
Joseph Doherty
321ca0bbbf refactor(ui/deployment): live-updates toggle, DebugView guardrails
New shared DiffDialog mirroring ConfirmDialog's API
(ShowAsync(title, before, after)) so live-data pages stop
hand-rolling Bootstrap modal markup.

Topology: <h4> in flex header, aria-labels on Expand/Collapse/Refresh
and the inline rename input, Live-updates toggle (suppresses the 15s
timer when off), instance/area counts moved into a summary alert
above the tree, Stale badge paired with bi-exclamation-triangle icon
+ aria-label, hand-rolled Diff modal replaced with <DiffDialog @ref>.

Deployments: pause/resume auto-refresh button replaces the static
"Auto-refresh: 10s" text; summary cards switch to
col-lg-3 col-md-6 col-12; InProgress spinner gets role="status" +
aria-label; failed rows pick up a bi-x-circle icon next to the
Status badge; Deployment ID + Revision folded into one
{id}@{revision[..8]} cell; inline Error column collapses behind a
per-row "View error" toggle; bare empty-state text upgraded to the
centered muted block.

DebugView: status-strip card at the top showing instance / connection
state / last snapshot timestamp plus a "Start fresh" button when the
page auto-reconnected from localStorage. Per-table filter input,
scroll-lock toggle, Clear button, and a 200-row queue-style cap.
<tbody> elements gain aria-live="polite" aria-atomic="false" for
screen-reader announcements. Quality and Alarm-State badges get
aria-labels; timestamps display HH:mm:ss with full ms in a hover
tooltip. Auto-reconnect surfaces a toast with autoDismissMs: 8000.
2026-05-12 03:32:53 -04:00
Joseph Doherty
b6e2ec8a50 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.
2026-05-12 03:32:39 -04:00
Joseph Doherty
da2c0d714e refactor(ui/admin): card grid, search, kebab; LDAP scope-rule chips
LdapMappings: flex header, search filter, per-row Edit + kebab Delete,
@key, dropped Site-Scope-Rules cell in favor of a {n rule(s)} badge.

LdapMappingForm: two stacked cards (Mapping then Site Scope Rules);
scope rules render as removable chips with an inline "Add scope rule"
form; create-mode disables the scope card with an explainer; role
select gets form-text help.

DataConnections: <h4> in flex header, Bulk actions dropdown holding
Expand/Collapse, hover-visible kebab on tree nodes mirroring the
right-click context menu, aria-labels, "No connections match the
filter." inline empty state.

DataConnectionForm: Site rendered as readonly plaintext + lock-after-
creation note in edit mode; parallel Primary endpoint / Backup endpoint
headings; "Optional" badge on Backup when null; form-text on
FailoverRetryCount.

ApiKeys: search filter, Status column dropped (state now lives in the
kebab menu label "Disable"/"Enable"), Edit + kebab actions, @key,
aria-labels.

ApiKeyForm: nested card removed; fixed-text Back header; real
clipboard copy via IJSRuntime + toast confirmation.

Test selector fix in DataConnectionFormTests for the new Site
readonly-plaintext rendering.
2026-05-12 03:32:17 -04:00
Joseph Doherty
f7b10f2ff7 refactor(ui/shared): scroll-lock, escape, aria-live, responsive sidebar
ConfirmDialog locks body scroll via IJSRuntime + Bootstrap's
modal-open class on show, restores on hide. Escape key now closes
the dialog; default ConfirmButtonClass flipped from btn-danger to
btn-primary so non-destructive confirms aren't red. Destructive
callsites (Delete, Discard) get explicit ConfirmButtonClass="btn-danger".

ToastNotification adds aria-live="polite" + aria-atomic="true" on the
container and an optional autoDismissMs parameter on every Show* method.

LoadingSpinner text-muted -> text-secondary for contrast.

DataTable gains a clear (x) button on the search input and applies
disabled / aria-disabled directly to the pagination buttons.

NewFolderDialog splits backdrop and modal markup to match ConfirmDialog.

NavMenu wraps the nav list in an overflow-y scroll container so the
username/sign-out footer stays anchored, and section headers convert
from <li> to <div role="presentation">.

MainLayout adds a hamburger toggle for <lg viewports; sidebar collapses
via Bootstrap collapse data attributes.

App.razor extracts inline <style> block to a shared site.css; adds a
left-border accent on the active nav link; switches the reconnect
modal to modal-dialog-centered.

Login uses d-flex / min-vh-100 centering. NotAuthorizedView gets the
same centered layout plus the ScadaLink brand heading.

Sites.razor: only the new ConfirmButtonClass="btn-danger" follow-up.
2026-05-12 03:32:07 -04:00
Joseph Doherty
ff5f5a10ef docs(ui): UI audit findings (2026-05-12)
Audit of every page in CentralUI against the Sites.razor card-grid
pattern, the no-third-party-UI-libs constraint, and accessibility
basics. Findings + per-page severity + suggested implementation
order live in docs/plans/. Implementation follows in subsequent commits.
2026-05-12 03:31:54 -04:00
Joseph Doherty
0805e18e9c refactor(ui/sites): replace 10-col table with card grid + collapsible cluster panel
The dense table buried high-signal fields (name, identifier, connections)
under four 80-character Akka/gRPC URLs truncated mid-string. Replace with
a 2-column responsive card grid; cluster-node addresses now live in a
collapsed disclosure with copy-to-clipboard. Adds client-side filter,
empty/no-match states, kebab menu for less-frequent actions, and
@key=site.Id to keep Bootstrap collapse state from leaking across cards
when the filter changes.
2026-05-12 02:55:37 -04:00
Joseph Doherty
22d91c858a feat(ui): Layer E2 OpcUaEndpointEditor gains Authentication / Advanced / Deadband sections
Three new sections inserted into <OpcUaEndpointEditor>:

1. Authentication (between the existing Connection row and Timing)
   - 'Enable Authentication' button when Config.UserIdentity is null
   - TokenType select (Anonymous / UsernamePassword / X509Certificate)
   - Conditional Username + Password inputs for UsernamePassword
   - Conditional Certificate path + Certificate password for X509Certificate
   - 'Remove Authentication' button

2. Advanced subscription (after the existing Subscription row)
   - Subscription display name (text)
   - Subscription priority (number 0-255)
   - Timestamps to return (Source / Server / Both select)
   - Discard oldest (checkbox)

3. Deadband filter (after Advanced subscription)
   - 'Enable Deadband' button when Config.Deadband is null
   - Type select (Absolute / Percent), Value number input
   - 'Remove Deadband' button

EnableAuthentication and EnableDeadband helpers complement EnableHeartbeat.
All new fields use the existing RenderFieldError helper for validator errors.

82/82 CentralUI tests pass (the 10 new editor tests drove the design).
2026-05-12 02:30:06 -04:00
Joseph Doherty
f89f234558 test(ui): failing bUnit tests for OpcUaEndpointEditor new sections
Adds 10 new tests covering:
- Authentication section label + Enable/Remove toggle (creates/nulls UserIdentity)
- TokenType conditional rendering: UsernamePassword shows Username/Password,
  X509Certificate shows Certificate path/password, Anonymous shows no extras
- Deadband Enable/Remove toggle
- Advanced Subscription section labels (Discard oldest, Subscription display
  name, Subscription priority, Timestamps to return)
- UserIdentity per-field error rendering under Username

9 new tests fail because the editor component hasn't been extended yet
(TDD red phase). Layer E2 implements the sections.
2026-05-12 02:28:47 -04:00
Joseph Doherty
8faaa8fe2b feat(dcl): Layer D OpcUaGlobalOptions for app-wide identity + cert paths
New deployment-wide options bound from the "OpcUa" section of appsettings.json:
- ApplicationName (default "ScadaLink-DCL")
- TrustedIssuerStorePath / TrustedPeerStorePath / RejectedCertificateStorePath

Empty paths fall back to Path.GetTempPath()/ScadaLink/pki/* so dev runs work
without explicit config — same defaults the hardcoded values previously used.

Wiring:
- ServiceCollectionExtensions binds OpcUaGlobalOptions to the OpcUa section.
- DataConnectionFactory takes IOptions<OpcUaGlobalOptions> and constructs
  RealOpcUaClientFactory with the snapshot.
- RealOpcUaClient(globalOptions) replaces the hardcoded ApplicationName and
  the three CertificateTrustList store paths in ApplicationConfiguration.
- Parameterless ctors on factory and client preserved for the existing test
  suite (32/32 DCL tests still green).
2026-05-12 02:27:58 -04:00
Joseph Doherty
e6a5b558f3 feat(dcl): Layer C runtime wires new OPC UA settings through to OPC SDK
OpcUaConnectionOptions record gains DiscardOldest, SubscriptionPriority,
SubscriptionDisplayName, TimestampsToReturn, plus OpcUaDeadbandOptions and
OpcUaUserIdentityOptions nullable sub-records.

OpcUaDataConnection.ConnectAsync copies all new fields from the typed
OpcUaEndpointConfig (including the Deadband and UserIdentity sub-objects)
into the OpcUaConnectionOptions record.

RealOpcUaClient:
- BuildUserIdentity translates TokenType into Opc.Ua.UserIdentity:
  Anonymous → null, UsernamePassword → new UserIdentity(name, utf8(pass)),
  X509Certificate → new UserIdentity(X509CertificateLoader.LoadPkcs12FromFile(...)).
- Subscription uses opts.SubscriptionDisplayName and opts.SubscriptionPriority.
- MonitoredItem.DiscardOldest is opts.DiscardOldest (was hardcoded true).
- BuildDataChangeFilter materializes a DataChangeFilter when Deadband is set.
- ReadAsync uses MapTimestampsToReturn for opts.TimestampsToReturn (was hardcoded Source).

X509CertificateLoader replaces obsolete X509Certificate2(string,string) ctor
(SYSLIB0057 on .NET 10). UserIdentity(string,byte[]) ctor used because the
(string,string) overload was removed in OPC Foundation 1.5.378.106.
2026-05-12 02:26:15 -04:00
Joseph Doherty
b60a8ef409 feat(commons): Layer B serializer + validator handle new OPC UA settings
OpcUaEndpointConfigSerializer:
- ToFlatDict emits new scalar keys (DiscardOldest, SubscriptionPriority,
  SubscriptionDisplayName, TimestampsToReturn).
- ToFlatDict emits dotted sub-object keys (UserIdentity.TokenType / Username /
  Password / CertificatePath / CertificatePassword, Deadband.Type / Value)
  when those sub-objects are non-null.
- FromFlatDict reads the same keys back; missing keys preserve POCO defaults.
- Deadband.Value uses InvariantCulture for double parsing/formatting.

OpcUaEndpointConfigValidator:
- SubscriptionDisplayName required (non-empty).
- UserIdentity.UsernamePassword requires Username.
- UserIdentity.X509Certificate requires CertificatePath.
- Deadband.Value must be > 0 when Deadband is set.
- fieldPrefix propagates through sub-object error EntityNames.

Drives the 11 previously-failing tests green; 51/51 in the suite now pass.
2026-05-12 02:22:51 -04:00
Joseph Doherty
91450ec390 test(commons): failing tests for Layer B serializer + validator extensions
Adds 11 new tests covering:
- Roundtrip of DiscardOldest/SubscriptionPriority/SubscriptionDisplayName/TimestampsToReturn
- Roundtrip of UserIdentity sub-object across all three TokenTypes
- Roundtrip of Deadband sub-object
- ToFlatDict/FromFlatDict for UserIdentity.* and Deadband.* dotted keys
- Validator rules: empty SubscriptionDisplayName, UsernamePassword w/o Username,
  X509 w/o CertificatePath, Deadband Value <= 0, prefix propagation

Build passes; tests fail because serializer/validator have not been extended yet
(TDD red phase). Task B2 will implement the changes to drive them green.
2026-05-12 02:21:33 -04:00
Joseph Doherty
16f7ab0d0a feat(commons): extend OpcUaEndpointConfig with auth, subscription tuning, read/filter knobs
Adds POCOs and enums for upcoming OPC UA editor expansion:
- OpcUaUserTokenType (Anonymous | UsernamePassword | X509Certificate)
- OpcUaUserIdentityConfig (TokenType + Username/Password + CertificatePath/Password)
- OpcUaDeadbandType (Absolute | Percent) + OpcUaDeadbandConfig
- OpcUaTimestampsToReturn (Source | Server | Both)

OpcUaEndpointConfig grows three new scalars (DiscardOldest, SubscriptionPriority,
SubscriptionDisplayName) plus optional UserIdentity and Deadband sub-objects.
Defaults preserve current runtime behavior (anonymous, no deadband, DiscardOldest=true).
2026-05-12 02:20:12 -04:00
Joseph Doherty
084da55ad6 fix(commons): LoadLegacy handles mixed-type JSON values (number/bool/string) 2026-05-12 02:08:32 -04:00
Joseph Doherty
cfb90d2078 fix(ui/admin): always clear _loading in DataConnectionForm.OnInitializedAsync 2026-05-12 01:14:18 -04:00
Joseph Doherty
9916aeaa47 refactor(ui/admin): DataConnectionForm uses OpcUaEndpointEditor and typed model 2026-05-12 01:11:49 -04:00
Joseph Doherty
505731fcef test(ui): drive DataConnectionForm tests via NavigationManager for SupplyParameterFromQuery 2026-05-12 01:09:31 -04:00
Joseph Doherty
46260f30ee test(ui): failing tests for DataConnectionForm refactor 2026-05-12 01:07:55 -04:00
Joseph Doherty
1c71d3342a feat(ui): OpcUaEndpointEditor Blazor component 2026-05-12 01:05:32 -04:00
Joseph Doherty
304ebec121 test(ui): failing bUnit tests for OpcUaEndpointEditor 2026-05-12 01:02:41 -04:00
Joseph Doherty
496d2a68e3 refactor(site-runtime): route OPC UA connection JSON through serializer 2026-05-12 00:59:25 -04:00
Joseph Doherty
f98d29fc36 refactor(dcl): OpcUaDataConnection uses OpcUaEndpointConfig via FromFlatDict 2026-05-12 00:57:09 -04:00
Joseph Doherty
80d4d3e252 feat(commons): OpcUaEndpointConfigValidator 2026-05-12 00:52:55 -04:00
Joseph Doherty
b53221e44a test(commons): failing tests for OpcUaEndpointConfigValidator 2026-05-12 00:50:28 -04:00
Joseph Doherty
4608adcd53 refactor(commons): defensive legacy-parse + FromFlatDict starts from POCO defaults 2026-05-12 00:48:17 -04:00
Joseph Doherty
8fbf167389 feat(commons): OpcUaEndpointConfigSerializer with legacy fallback + flat-dict interop 2026-05-12 00:44:21 -04:00
Joseph Doherty
90b252047e test(commons): decouple serializer tests from JSON whitespace and verify defaults symmetrically 2026-05-12 00:41:55 -04:00
Joseph Doherty
2220bfcf58 test(commons): failing tests for OpcUaEndpointConfigSerializer 2026-05-12 00:38:56 -04:00
Joseph Doherty
b16606d97e feat(commons): OpcUaEndpointConfig POCOs + ConnectionConfig ValidationCategory 2026-05-12 00:35:27 -04:00
Joseph Doherty
a9c4c2c655 docs(plans): implementation plan for OPC UA config model refactor
14 bite-sized tasks (TDD pattern) covering:
- Commons foundation: POCOs, serializer, validator
- Runtime adoption: OpcUaDataConnection + DeploymentManagerActor swap
- UI build: <OpcUaEndpointEditor> + DataConnectionForm rewrite
- Verification: build/test green + Docker browser smoke + push

Tasks #45-#58 created with blocking dependencies; companion
.tasks.json sidecar persists the plan for executing-plans skill.
2026-05-12 00:33:51 -04:00
Joseph Doherty
c906e73441 docs(plans): OPC UA endpoint config model & form refactor design
Captures the design decisions from the brainstorming session:
- OpcUaEndpointConfig POCO + validator + serializer in Commons
- Single source of truth: both UI and site runtime consume the model
- Typed nested JSON storage (camelCase), legacy flat-dict fallback
- Shared <OpcUaEndpointEditor> Blazor component used twice
- Custom protocol removed from dropdown; Protocol field hidden
- Validation timing on Save only; per-field red text via ValidationEntry
2026-05-12 00:27:35 -04:00
Joseph Doherty
da5fdf0e63 feat(ui/admin): Topology-style refresh of Data Connections page
Brings the Data Connections admin page up to the same UX standard as the
Topology page:
- Search box with dim non-matches (opacity 0.4, shape preserved)
- Toolbar: + Connection (disabled until a site is selected), Refresh,
  Expand, Collapse
- Site context menu gains "Add Connection here" that navigates with
  ?siteId= so the form preselects + locks the Site field
- Form gains "Primary Endpoint" / "Backup Endpoint" h6 subsection
  headers matching the SiteForm convention; Failover Retry Count moved
  inside the Backup subsection
- URL renamed: /admin/connections (primary) + /admin/data-connections
  (legacy secondary @page). Same dual-route treatment on the form
- Nav label: "Data Connections" -> "Connections"
- Adds DataConnectionsPageTests bUnit suite (6 tests)
2026-05-11 22:42:48 -04:00
Joseph Doherty
f3386d0278 feat(ui/deployment): consolidate sites/areas/instances into Topology page
Single /deployment/topology page replaces /deployment/instances (legacy URL
preserved as a secondary @page directive) and the /admin/areas* CRUD pages.
TreeView with Site → Area → Instance, V1–V7 visual guide (bi-building /
bi-diagram-3 / bi-box), always-visible empty containers, search dim, F2
inline area rename, and right-click context menus per node kind (Add Area,
Move to Area…, lifecycle actions, etc.).

Adds AreaService.MoveAreaAsync with cycle prevention, same-site enforcement,
and name-collision check at the new parent. Instance rename intentionally
out of scope — UniqueName is the site-side actor identity, requires its own
design pass.
2026-05-11 22:03:55 -04:00
Joseph Doherty
b2eddd9713 feat(ui/templates): derived-template action and slimmer composition row
Right-click a template now offers "New Derived Template" — opens
TemplateCreate with the parent pre-selected via a new ?parentId query
parameter. Composition rows in the tree drop the trailing
"→ TargetName" muted text; the kind glyph plus the instance name carry
enough meaning, and the composed template is one click away from the
row's right-click menu.
2026-05-11 21:29:32 -04:00
Joseph Doherty
b4cb7e6f5f feat(templates): lock ParentTemplateId after creation
Template inheritance is set once at create time and immutable on update.
UpdateTemplateAsync now returns "Parent template cannot be changed after
creation." when the caller sends a parent that differs from the stored
value — server-side enforcement covers UI, ManagementService, and CLI.
TemplateEdit renders the parent as static plaintext rather than an
editable dropdown; TemplateCreate's parent picker is unchanged.
2026-05-11 21:29:21 -04:00
Joseph Doherty
8e388a89c5 feat(ui/templates): adopt TreeView design guide; split editor to /design/templates/{id}
Templates page is now a tree-only browser; editing happens on a dedicated
TemplateEdit page. Drag-drop is replaced by context-menu Move-to-Folder.
TreeView gains Bootstrap Icons (chevron + per-kind glyphs), ancestor guide
lines, defined hover/selected/focus tokens, and Escape-dismisses-menu per
the new Visual Design Guide (V1-V7) in Component-TreeView.md.
2026-05-11 20:52:34 -04:00
Joseph Doherty
f3b33e7e1d fix(ui/treeview): union sessionStorage keys instead of overwriting
The previous fix tried to defer page-side RevealNode to the second
render so TreeView's async sessionStorage load could finish first. In
practice Blazor Server didn't always fire a second OnAfterRenderAsync
on the page after the deep-link load, so the reveal never ran.

Real fix: change TreeView's storage-load to UNION the restored keys
with whatever's already in _expandedKeys, instead of REPLACING. That
way the page can call RevealNode whenever it wants and the storage
restore can't clobber the reveal regardless of completion order. The
page-side guard simplifies back to a one-shot reveal on first render.

Semantic note: if a deep-link reveal expands an ancestor that the user
had previously collapsed, the deep link wins. Intentional — the URL
expresses the navigation intent.
2026-05-11 12:42:38 -04:00
Joseph Doherty
d8e6f44616 fix(ui/templates): defer deep-link reveal until TreeView restores sessionStorage
Both page.OnAfterRenderAsync(firstRender=true) and
TreeView.OnAfterRenderAsync(firstRender=true) ran concurrently:
- Page called RevealNode → added ancestor keys to _expandedKeys
- TreeView awaited treeviewStorage.load → replaced _expandedKeys with
  the persisted set (often empty if user collapsed before navigating)

Whichever JS interop completed second won. When TreeView won, the deep-link
reveal silently lost. Gate the reveal on firstRender==false so it runs
strictly after TreeView's restore is done.
2026-05-11 12:39:21 -04:00
Joseph Doherty
ca164dca03 fix(ui/templates): stop drop propagation on folder nodes
Without stopPropagation, dropping a template onto a folder fires both
OnDrop(folder) and OnDropOnRoot via event bubbling. The two async handlers
race on the same scoped DbContext, which is not thread-safe — the second
throws ObjectDisposedException and tears down the Blazor circuit. Surfaced
during browser smoke testing via JS-dispatched DragEvent sequence.
2026-05-11 12:28:05 -04:00
Joseph Doherty
acead212b2 fix(ui/templates): dereference string params with @ and stack toolbar below title
Smoke testing revealed two issues introduced by the modal extraction commit:

1. ErrorMessage / InitialName / TemplateName parameters on the dialog
   components were passed as bare strings (e.g. ErrorMessage="_newFolderError")
   instead of dereferenced C# expressions (ErrorMessage="@_newFolderError").
   Razor treats unquoted-but-not-@-prefixed values to string parameters as
   string literals — so the error block rendered the literal field name in
   red whenever the modal opened. Non-string parameters (int/IEnumerable)
   were fine since Razor treats those as C# expressions by default.

2. The Templates header + 4-button toolbar shared one flex row, but at
   col-md-4 / col-lg-3 width the buttons overflowed into the right-column
   empty-state area. Stack title above a full-width btn-group instead.
2026-05-11 12:20:40 -04:00
Joseph Doherty
3587ab4fcb refactor(ui/templates): extract dialog modals into shared components 2026-05-11 12:03:35 -04:00
Joseph Doherty
17e690f6ef test(ui/templates): cover drag-template-to-root via bUnit DragEventArgs 2026-05-11 12:00:07 -04:00
Joseph Doherty
8155dbc411 docs(templates): describe folder hierarchy and management commands 2026-05-11 11:28:09 -04:00
Joseph Doherty
d54013cb88 test(ui/templates): bUnit rendering tests for folder tree 2026-05-11 11:25:15 -04:00
Joseph Doherty
ca3b34223d feat(ui/templates): reveal deep-linked template on initial render 2026-05-11 11:21:53 -04:00