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.
This commit is contained in:
Joseph Doherty
2026-05-11 20:52:34 -04:00
parent f3b33e7e1d
commit 8e388a89c5
14 changed files with 3515 additions and 1127 deletions

View File

@@ -6,7 +6,7 @@
## Goal ## Goal
Replace the current single-list view at `/design/templates` with a tree-organized authoring surface modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and edit templates in a persistent split-pane layout. Replace the current single-list view at `/design/templates` with a tree-organized browser modeled on the Wonderware ArchestrA Template Toolbox. Users organize templates into nested folders, see composition children inline under their owning template, and navigate to a dedicated edit page (`/design/templates/{id}`) when authoring a specific template. The tree page itself does not host the editor.
## Reference ## Reference
@@ -22,13 +22,14 @@ Inheritance is **not** rendered as tree nesting in the image, and it is not rend
| Decision | Choice | | Decision | Choice |
|---|---| |---|---|
| Inheritance in tree | Not shown as nesting; shown as text on the template node label. | | Inheritance in tree | Not shown as nesting; **not shown on the node label either** (label is name only). Inheritance is visible in the TemplateEdit page when a template is selected. |
| Folder model | New `TemplateFolder` entity with self-referencing `ParentFolderId`. `Template.FolderId` nullable. | | Folder model | New `TemplateFolder` entity with self-referencing `ParentFolderId`. `Template.FolderId` nullable. |
| Reorganization UX | Context menus + native HTML5 drag-drop. | | Reorganization UX | **Right-click context menus only** (no drag-drop). Modal dialog pickers for move targets. |
| Composition rendering | Read-only leaves with navigation; right-click → Open composed template / Remove composition. | | Composition rendering | Read-only leaves with navigation; right-click → Open composed template / Remove composition. |
| Root-level templates | Allowed (`FolderId` nullable). Existing templates migrate with `FolderId = null`. | | Root-level templates | Allowed (`FolderId` nullable). Existing templates migrate with `FolderId = null`. |
| Folder delete with contents | Blocked; structured error lists child counts. | | Folder delete with contents | Blocked; structured error lists child counts. |
| Page layout | Persistent split pane: tree on left (~2533% width), template detail/editor on right. | | Page layout | **Tree browser only** — no split-pane editor. Selecting a template navigates to `/design/templates/{id}` (TemplateEdit page); creating navigates to `/design/templates/create`. |
| Tree node visuals | Per `Component-TreeView.md` Visual Design Guide V7: Bootstrap Icons (`bi-folder` / `bi-folder2-open` / `bi-file-earmark-text` / `bi-arrow-return-right`), name-only labels (no count/inherit badges on template nodes), folder child-count pill, composition `→ $Target` muted secondary text. |
## Data model ## Data model
@@ -138,72 +139,65 @@ private record TmplNode(
| `KeySelector` | `n => (object)n.Key` | | `KeySelector` | `n => (object)n.Key` |
| `StorageKey` | `"templates-tree"` (preserved from current usage) | | `StorageKey` | `"templates-tree"` (preserved from current usage) |
| `Selectable` | `true` | | `Selectable` | `true` |
| `SelectedKeyChanged` | dispatch on key prefix: `t:`load template into right pane; `f:` → no-op; `c:`reveal + select composed template | | `SelectedKeyChanged` | dispatch on key prefix: `t:``NavigationManager.NavigateTo($"/design/templates/{id}")` (TemplateEdit page); `f:` → no-op; `c:``NavigateTo` the composed template's edit page |
**Inline node labels:** **Inline node labels** (see `Component-TreeView.md` V7 for the canonical recipe):
- Folder: glyph + name + child-count badge. - Folder: `<i class="bi bi-folder">` (closed) or `<i class="bi bi-folder2-open">` (expanded) + name (semibold when has children) + count-pill badge of direct children.
- Template: `<strong>$Name</strong>` + optional "inherits $Parent" muted text + existing attr/alarm/script/comp count badges. - Template: `<i class="bi bi-file-earmark-text">` + `$Name` (semibold when has compositions). **No** inheritance hint, **no** attr/alarm/script count, **no** composition count on the node.
- Composition: `<span>InstanceName</span>` + muted `→ $ComposedTemplateName`. - Composition: `<i class="bi bi-arrow-return-right">` + composition instance name + muted `→ $ComposedTemplateName` secondary text.
**Search/filter:** out of scope for v1; the underlying component supports external filtering (per `Component-TreeView.md` R8) so it can be added later without component changes. **Search/filter:** out of scope for v1; the underlying component supports external filtering (per `Component-TreeView.md` R8) so it can be added later without component changes.
## Page layout ## Page layout
Two-column split inside the existing page container: `/design/templates` is a **single-column tree browser** — no inline editor, no split pane.
``` ```
+-------------------------------+----------------------------------------+ +--------------------------------------------+
| Templates | $TestMachine | | Templates |
| [+Folder][+Template][Expand] | inherits $gMachine | | [+Folder] [+Template] [Expand] [Collapse] |
| | [Properties] [Validate] | | |
| ▶ 📁 _Default Templates | | | ▶ 📁 _Default Templates |
| ▼ 📁 Dev | Tabs: Attributes | Alarms | Scripts | | ▼ 📂 Dev |
| $TestMachine | | Compositions | | 📄 $TestMachine |
| DelmiaReceiver → ... | ... | | DelmiaReceiver → $DelmiaSvc |
| MESReceiver → ... | | | MESReceiver → $MesSvc |
| $TestObject | | | 📄 $TestObject |
| ▶ 📁 System | | | ▶ 📁 System |
| $UnfiledTemplate | | | 📄 $UnfiledTemplate |
+-------------------------------+----------------------------------------+ +--------------------------------------------+
``` ```
- Left column: ~2533% (`col-md-4 col-lg-3`), scrollable (`max-height: calc(100vh - 160px); overflow-y: auto`). - Tree scrollable region: `max-height: calc(100vh - 160px); overflow-y: auto`. The 2533% sidebar width constraint is removed; the tree uses the page's main container width.
- Right column: existing template properties card, validation block, four-tab editor (Attributes / Alarms / Scripts / Compositions) lifted unchanged into `RenderTemplateDetail()`. - Selecting a template node navigates to `/design/templates/{id}` (TemplateEdit page).
- "Back to List" button is removed — the tree is always visible. - Selecting a composition node navigates to the composed template's edit page.
- Empty state in the right column when nothing is selected. - Selecting a folder node is a no-op (still allowed; expansion and context-menu still work).
- URL contract preserved: `/design/templates/{id}` selects + reveals the template on load via `TreeView.RevealNode("t:" + id, select: true)`. - Creating a template: toolbar "+ Template" button (or folder context-menu "New Template") navigates to `/design/templates/create?folderId={id}`. After successful create, the create page navigates to `/design/templates/{newId}`.
- URL contract for deep links: `/design/templates/{id}` resolves to the TemplateEdit page directly — the browser doesn't need to be on the tree page first.
## Context menus ## Context menus
Per-node-kind `ContextMenu` fragment driven by `node.Kind`: The context menu is the **only** reorganization mechanism. Per-node-kind `ContextMenu` fragment driven by `node.Kind`:
**Folder:** New Folder · New Template · Rename · Delete **Folder:** New Folder · New Template · Rename · Move to Folder… · Delete
**Template:** Edit · Move to Folder… (modal with folder-only mini-tree, "(Root)" option) · Delete **Template:** Edit · Move to Folder… · Delete
**Composition:** Open composed template · Remove composition **Composition:** Open composed template · Remove composition
The "New Template" modal collects name + description and creates with `FolderId = thisFolder.Id`. Root-level "+Folder" and "+Template" buttons live in the tree-sidebar toolbar above the tree. - **Move to Folder…** opens a modal (`MoveFolderDialog` / `MoveTemplateDialog`) with a flat folder picker. The list includes "(Root)" as the first entry. For folder-move, the dialog client-side prunes the folder being moved and its descendants from the candidate list to prevent obvious cycles; the server still validates (authoritative). For template-move, all folders are valid targets.
- **Edit** on a template navigates to `/design/templates/{id}` (TemplateEdit page) — equivalent to clicking the node, kept in the menu for discoverability.
- Root-level "+ Folder" and "+ Template" buttons live in the toolbar above the tree.
## Drag-drop **Server-side validation (authoritative)**:
Native HTML5 drag-drop, no library.
**Draggability:** folders and templates are `draggable="true"`. Composition nodes are not draggable.
**Drop targets:** folder nodes and the root sidebar wrapper. Template/composition nodes are not drop targets in v1.
**Payload:** `_dragPayload = (kind, id)` held in component state on `@ondragstart`.
**Visual feedback:** CSS `drag-over` class toggled via `@ondragenter` / `@ondragleave`; compositions dimmed to 0.5 opacity while drag in progress.
**Server-side validation (authoritative):**
- Folder onto descendant → reject (cycle). - Folder onto descendant → reject (cycle).
- Folder onto itself → no-op. - Folder onto itself → no-op (client prunes).
- Drop on a composition → ignored. - Template-onto-template → not a valid target (templates aren't shown in the folder picker).
- Template-onto-template → **ignored** (no sibling reordering, no surprising "drop into parent's folder").
## Edge cases ## Edge cases
- Deep-link route reveals ancestors via `RevealNode`. - Deep-link route `/design/templates/{id}` resolves directly to the TemplateEdit page; the tree page is not involved. If the user navigates back, the tree's sessionStorage-persisted expansion state is restored.
- Stale `f:{id}` keys in `sessionStorage` after folder delete are harmless (ignored on next render). - Stale `f:{id}` keys in `sessionStorage` after folder delete are harmless (ignored on next render).
- Selected template moved to another folder → tree rebuilds; selection preserved by stable key. - Selected template moved to another folder → tree rebuilds; selection preserved by stable key.
- Selected template deleted → right pane clears to empty state. - Template deleted from the TemplateEdit page → page navigates back to `/design/templates`; the tree rebuilds without the deleted node.
- Last-write-wins on concurrent folder edits, matching existing template policy. - Last-write-wins on concurrent folder edits, matching existing template policy.
- Tree fully rebuilt on every CRUD; expected scale (dozens to low hundreds) makes this trivially cheap. - Tree fully rebuilt on every CRUD; expected scale (dozens to low hundreds) makes this trivially cheap.
@@ -226,14 +220,16 @@ Native HTML5 drag-drop, no library.
**bUnit (`tests/ScadaLink.CentralUI.Tests/`):** **bUnit (`tests/ScadaLink.CentralUI.Tests/`):**
- Tree renders folders / templates / compositions in correct nesting. - Tree renders folders / templates / compositions in correct nesting.
- Empty state when no selection. - Empty state when no roots exist (no folders, no root templates).
- Selecting a template node loads the detail pane. - Selecting a template node invokes `NavigationManager.NavigateTo($"/design/templates/{id}")`.
- Selecting a composition reveals + selects the composed template. - Selecting a composition node invokes `NavigateTo` for the composed template's edit page.
- Right-click menus differ by node kind. - Selecting a folder node is a no-op (no navigation).
- Folder-delete-non-empty surfaces structured error toast. - Right-click menus differ by node kind (Folder / Template / Composition each have distinct items).
- Deep link selects + reveals. - Folder context menu includes "Move to Folder…"; the dialog excludes the folder being moved and its descendants from candidates.
- Folder-delete-non-empty surfaces a structured error toast.
- Bootstrap Icons render in the glyph slot for each node kind (`bi-folder` / `bi-folder2-open` / `bi-file-earmark-text` / `bi-arrow-return-right`).
**Manual smoke (per `CLAUDE.md`):** nested folder creation, drag-drop reorg, cycle rejection, refresh persistence, composition navigation. **Manual smoke (per `CLAUDE.md`):** nested folder creation, context-menu reorg (folder + template Move-to-Folder dialogs), cycle rejection, refresh persistence, composition navigation, navigation from tree to TemplateEdit and back.
## Documentation updates ## Documentation updates
@@ -247,6 +243,6 @@ Native HTML5 drag-drop, no library.
- Tree search / filter input (component already supports it; add when needed). - Tree search / filter input (component already supports it; add when needed).
- CLI commands for folder operations (message contracts make this trivial later). - CLI commands for folder operations (message contracts make this trivial later).
- Sibling reorder via drag-drop (sort stays alphabetical). - Sibling reorder (sort stays alphabetical).
- Root context menu (right-click in empty tree area). - Root context menu (right-click in empty tree area).
- Bootstrap Icons CDN — current pages don't use it, so this design uses Unicode glyphs. - (Removed from out-of-scope.) Bootstrap Icons are now adopted (static files at `wwwroot/lib/bootstrap-icons/`) — see `Component-TreeView.md` V4.

View File

@@ -64,10 +64,9 @@ The `NodeContent` fragment receives the `TItem` and is responsible for rendering
### R4 — Indentation and Visual Structure ### R4 — Indentation and Visual Structure
- Each depth level is indented by a fixed amount (default 24px, configurable via `IndentPx` parameter). The component renders the structural chrome: indent gutters per depth, the toggle slot, and ancestor guide lines. Leaf nodes render an empty toggle placeholder so labels align across siblings.
- Vertical guide lines connect parent to children at each depth level (thin left-border or CSS pseudo-element).
- The toggle icon is inline with the node content, left-aligned at the current depth. The exact tokens (indent unit, toggle glyph, guide-line treatment) are specified in **V2** of the Visual Design Guide.
- Leaf nodes align with sibling branch labels (the content starts at the same horizontal position, with empty space where the toggle would be).
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
|-----------|------|----------|-------------| |-----------|------|----------|-------------|
@@ -132,12 +131,11 @@ This keeps filter logic in the page (domain-specific) while the component handle
### R9 — Styling ### R9 — Styling
- Uses Bootstrap 5 utility classes only (no third-party frameworks). - Uses Bootstrap 5 utility classes and CSS variables. No third-party Blazor component frameworks.
- No hardcoded colors — uses standard Bootstrap text/background utilities. - Adds one icon-library dependency: **Bootstrap Icons** (static files at `wwwroot/lib/bootstrap-icons/`). Distribution rules in **V4** of the Visual Design Guide.
- Toggle icons: Unicode characters (`+` / ``) in a `<span>` with `cursor: pointer`, or a small SVG chevron. No icon library dependency. - Hardcoded colors are forbidden; use Bootstrap utility classes (`bg-primary bg-opacity-10`, `text-muted`) or CSS variables (`var(--bs-tertiary-bg)`, `var(--bs-border-color)`).
- Compact row height for dense data (matching `table-sm` density). - Component-local CSS lives in `TreeView.razor.css` (Blazor CSS isolation).
- Hover effect on rows: subtle background highlight (`bg-light` or similar). - All visual tokens (row density, indent, state visuals, glyphs, labels, badges) are specified in the **Visual Design Guide** (V1V7). This requirement is non-normative summary; the Guide is authoritative.
- CSS scoped to the component via Blazor CSS isolation (`TreeView.razor.css`).
### R10 — No Internal Scrolling ### R10 — No Internal Scrolling
@@ -296,7 +294,183 @@ Future enhancement. Single selection (R5) covers current needs. A future version
- Shift+click for range select, Ctrl+click for toggle - Shift+click for range select, Ctrl+click for toggle
- Use case: bulk operations (select multiple instances → deploy/disable all) - Use case: bulk operations (select multiple instances → deploy/disable all)
## Component API Summary ## Visual Design Guide
This section is the canonical visual specification for the TreeView. It is normative: any change to the chrome (row layout, indentation, glyphs, state visuals, badge styling) must update this section. Consumers' `NodeContent` fragments follow the label and badge recipes in V5V6; `/design/templates` is the worked example in V7.
R4 and R9 above describe *that* the component renders structural chrome and uses Bootstrap utilities. This section says *exactly how*.
### V1 — Density & Row Anatomy
Each `<li role="treeitem">` renders one row. The row is a flexbox so trailing meta can right-align cleanly and the entire row width is a hover/selected/drop-target surface.
**Row container** (replaces today's `.tv-row` styling):
```html
<div class="tv-row d-flex align-items-center"
style="gap:.25rem; padding:.25rem .5rem; padding-left: calc(.5rem + var(--tv-indent, 0px));">
<span class="tv-toggle">…chevron or placeholder…</span>
<span class="tv-glyph">…Bootstrap Icon or placeholder…</span>
<span class="tv-label">…primary + secondary…</span>
<span class="tv-meta ms-auto">…badges…</span>
</div>
```
| Token | Value | Notes |
|---|---|---|
| Row vertical padding | `py-1` (0.25rem top/bottom) | Yields ~32px row height at base font-size + line-height 1.5. |
| Row horizontal padding | `px-2` (0.5rem left/right) | Selected/hover background spans full row including this padding. |
| Inter-slot gap | `gap: .25rem` | Between toggle, glyph, label. The meta slot is offset by `margin-left: auto`. |
| Font size | inherits (1rem base) | Compact pages may opt into `small` per-page, not at the component level. |
| Line height | inherits (1.5) | Aligns the chevron, glyph, and label baselines correctly. |
| Toggle slot width | 20px (`width: 1.25rem`) | Always present, even on leaves (which render an empty placeholder). |
| Glyph slot width | 20px (`width: 1.25rem`) | Always present; consumer may render an empty span to preserve alignment. |
| Label slot | `flex: 1 1 auto; min-width: 0;` | `min-width: 0` is required for ellipsis truncation to work in a flex child. |
| Meta slot | `margin-left: auto;` | Pushes badges to the right edge of the row. |
**Hit semantics**:
- The full row (`tv-row`) is the surface for hover, selected, focus-visible, and drop-target backgrounds.
- Click-to-select fires only on the **label slot** (preserves R5: toggle clicks do not select).
- The toggle slot's invisible tap target is enlarged by negative margins inside the 20px slot so it remains a comfortable 24×24px target.
### V2 — Depth, Indent & Guide Lines
| Token | Value |
|---|---|
| Indent per depth | 24px (`IndentPx` default, unchanged) |
| Toggle glyph (collapsed) | `<i class="bi bi-chevron-right">` |
| Toggle glyph (expanded) | `<i class="bi bi-chevron-down">` (or `bi-chevron-right` rotated 90° via CSS) |
| Guide line color | `var(--bs-border-color)` |
| Guide line width | 1px |
| Guide line style | solid, vertical-only (no horizontal stubs) |
| Guide line position | one line per ancestor depth, drawn down the indent column (left edge of each 24px indent slot) |
| Guide lines enabled | `ShowGuideLines` parameter (default true) |
| Leaf alignment | identical depth gutter as siblings; the toggle slot renders an empty placeholder so glyphs and labels align across leaves and branches |
Implementation note: guide lines are drawn by repeating a `linear-gradient` background or by stacking `border-left` on indent spacers — both are pure CSS, no extra DOM. The current `tv-guides` class is the hook.
### V3 — State Visuals
States compose: focus rings layer on top of hover/selected; drop-target overrides hover and selected. All states paint the full row width (V1).
| State | Visual | Implementation |
|---|---|---|
| Default | none | — |
| Hover | full-row tint | `background: var(--bs-tertiary-bg);` on `:hover` of `.tv-row` |
| Focus-visible | inset 2px primary ring | `box-shadow: inset 0 0 0 2px var(--bs-primary);` on `:focus-visible` |
| Selected | full-row primary tint | `class="bg-primary bg-opacity-10"` (existing `SelectedCssClass` default, unchanged) |
| Selected + hover | selected tint persists; hover does not deepen | hover background applies only when not selected (`:hover:not(.bg-primary)`) |
| Selected + focus | tint + ring both visible | focus ring layers via box-shadow |
| Drop-target (valid) | `bg-info bg-opacity-25` | overrides hover/selected backgrounds; opt-in per consumer |
| Drop-target (invalid) | cursor `not-allowed`, no tint change | absence of valid-tint is the cue |
| Dragging source | `opacity: 0.5` | applied to the row currently being dragged |
| Dimmed (non-droppable while a drag is in progress) | `opacity: 0.5` | applied to nodes the consumer marks as unsuitable drop targets |
Drag-drop is **not** part of the TreeView component's intrinsic behavior — it is opt-in per consuming page. The drag-related state visuals (drop-target, dragging, dimmed) are documented here so consumers that *do* implement DnD share the same visual language. The `/design/templates` page (V7) explicitly does **not** use drag-drop; reorganization happens via the right-click context menu.
### V4 — Glyph & Icon System
**Distribution**: Bootstrap Icons ships as static files under `src/ScadaLink.CentralUI/wwwroot/lib/bootstrap-icons/` (`bootstrap-icons.css` + `fonts/*.woff2`). Referenced once from `MainLayout.razor`:
```html
<link rel="stylesheet" href="~/lib/bootstrap-icons/bootstrap-icons.css" />
```
No CDN dependency — works on air-gapped industrial deployments. Version pinned in the file path or filename.
**Rules**:
- Glyphs are inline `<i class="bi bi-…"></i>` elements inside the 20px glyph slot.
- Branches render an **open/closed pair**: a `closed` glyph when collapsed, an `open` glyph when expanded (consumer chooses both via `NodeContent`). The chevron toggle reinforces the same state.
- Leaves render a single static glyph or no glyph (empty span preserves alignment).
- **Color**: glyphs inherit `color` from their row. Default is body text; consumers may apply `text-muted` for de-emphasis. Kind is communicated by *shape*, not by color, to keep the palette available for status badges.
- **Size**: glyphs render at `1em` (inherits row font-size). No fixed pixel size.
### V5 — Label Recipe & Typography
The label slot contains, in order: **[primary] [secondary modifiers]**. Trailing meta lives in the separate `.tv-meta` slot (V1).
| Element | Style |
|---|---|
| Primary label (branches) | `class="fw-semibold"` |
| Primary label (leaves) | normal weight |
| Secondary modifiers | `class="text-muted small ms-1"` |
| Overflow handling | `.tv-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }` |
| Tooltip | `title` attribute on the primary label span, set to the full name on every row (cheap, helps when the row is narrower than the name) |
**Rule of thumb**: font-weight tracks *has children*, not *kind*. A folder with no children renders regular weight; a leaf-template promoted to a branch by adding compositions becomes semibold automatically.
### V6 — Badge Taxonomy
Three semantic badge roles. The meta slot holds **at most two** badges per row. All badges live in `.tv-meta`, right-aligned (V1).
| Role | Purpose | Markup | Examples |
|---|---|---|---|
| Count | numeric child aggregation | `<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@N</span>` | folder child count; area instance count |
| Status | semantic state | `<span class="badge bg-{success\|warning\|danger\|info}">@Label</span>` | Enabled / Disabled / Stale / Error |
| Kind | category / type tag | same filled semantic style, used sparingly | Protocol (OPC UA), Source (Inherited) |
**Rules**:
- Counts represent **direct children only**. Never transitive descendants.
- A count of 0 **renders nothing** — no badge at all.
- Status uses Bootstrap semantic colors; do not introduce custom palettes.
- The component does not enforce the 2-badge cap; it is a documented convention. PR review should catch violations.
### V7 — Worked Example: `/design/templates`
**Page model**: the templates page is a **tree browser only**. Selecting a template in the tree navigates to a dedicated edit page (`/design/templates/{id}`); creating a template navigates to `/design/templates/create`. No split-pane editor. Reorganization (move folder, move template) happens exclusively through the **right-click context menu** with modal dialog pickers — there is no drag-and-drop on this page.
Three node kinds; concrete recipes following V1V6.
| Kind | Glyph (collapsed) | Glyph (expanded) | Primary | Secondary | Badges |
|---|---|---|---|---|---|
| Folder | `bi-folder` | `bi-folder2-open` | folder name (semibold when has children, regular otherwise) | — | count of direct children (subtle pill), only if ≥ 1 |
| Template | `bi-file-earmark-text` | same (templates with compositions still use the same glyph — chevron carries state) | `$Name` (semibold when has compositions, regular otherwise) | — | none |
| Composition | `bi-arrow-return-right` | n/a (leaf, no expanded state) | composition instance name (regular weight) | `<span class="text-muted small ms-1">→ $TargetName</span>` | none |
**`NodeContent` fragment** for the templates page (replaces the current `RenderNodeLabel` in `Templates.razor`):
```razor
@switch (node.Kind)
{
case TmplNodeKind.Folder:
var folderOpen = _tree.IsExpanded(node.Key);
<span class="tv-glyph"><i class="bi @(folderOpen ? "bi-folder2-open" : "bi-folder")"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span>
@if (node.Children.Count > 0)
{
<span class="tv-meta ms-auto">
<span class="badge rounded-pill bg-secondary-subtle text-secondary-emphasis">@node.Children.Count</span>
</span>
}
break;
case TmplNodeKind.Template:
<span class="tv-glyph"><i class="bi bi-file-earmark-text"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Label">@node.Label</span>
break;
case TmplNodeKind.Composition:
var composedName = _templates.FirstOrDefault(t => t.Id == node.Composition!.ComposedTemplateId)?.Name
?? $"#{node.Composition!.ComposedTemplateId}";
<span class="tv-glyph"><i class="bi bi-arrow-return-right"></i></span>
<span class="tv-label" title="@node.Label">
@node.Label
<span class="text-muted small ms-1">→ @composedName</span>
</span>
break;
}
```
**Locked subtractions from the previous design**:
- Template node "inherits $Parent" muted text — **removed**. Inheritance is shown in the right pane only.
- Template node "X attr, Y alm, Z scr" compound badge — **removed**.
- Template node "N comp" accent badge — **removed**.
These subtractions are deliberate: templates are leaves-from-the-tree's-perspective (their inner attributes/alarms/scripts are not tree-navigable), so the tree row should carry only what's needed to identify and pick the template. All counts and inheritance information live in the right detail pane.
```csharp ```csharp
@typeparam TItem @typeparam TItem

View File

@@ -56,6 +56,8 @@
</div> </div>
@code { @code {
[SupplyParameterFromQuery] public int? FolderId { get; set; }
private List<Template> _templates = new(); private List<Template> _templates = new();
private bool _loading = true; private bool _loading = true;
@@ -87,11 +89,12 @@
var user = await GetCurrentUserAsync(); var user = await GetCurrentUserAsync();
var result = await TemplateService.CreateTemplateAsync( var result = await TemplateService.CreateTemplateAsync(
_createName.Trim(), _createDescription?.Trim(), _createName.Trim(), _createDescription?.Trim(),
_createParentId == 0 ? null : _createParentId, user); _createParentId == 0 ? null : _createParentId, user,
folderId: FolderId);
if (result.IsSuccess) if (result.IsSuccess)
{ {
NavigationManager.NavigateTo("/design/templates"); NavigationManager.NavigateTo($"/design/templates/{result.Value.Id}");
} }
else else
{ {

View File

@@ -0,0 +1,860 @@
@page "/design/templates/{Id:int}"
@using ScadaLink.Security
@using ScadaLink.Commons.Entities.Templates
@using ScadaLink.Commons.Interfaces.Repositories
@using ScadaLink.Commons.Types.Enums
@using ScadaLink.TemplateEngine
@using ScadaLink.TemplateEngine.Services
@using ScadaLink.TemplateEngine.Validation
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject ITemplateEngineRepository TemplateEngineRepository
@inject TemplateService TemplateService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
<div class="container-fluid mt-3">
<ToastNotification @ref="_toast" />
<ConfirmDialog @ref="_confirmDialog" />
<div class="mb-3">
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">&larr; Templates</button>
</div>
@if (_loading)
{
<LoadingSpinner IsLoading="true" />
}
else if (_loadError != null)
{
<div class="alert alert-danger">@_loadError</div>
}
else if (_selectedTemplate == null)
{
<div class="alert alert-warning">Template not found.</div>
}
else
{
@RenderTemplateDetail()
}
</div>
@code {
[Parameter] public int Id { get; set; }
private List<Template> _templates = new();
private Template? _selectedTemplate;
private List<TemplateAttribute> _attributes = new();
private List<TemplateAlarm> _alarms = new();
private List<TemplateScript> _scripts = new();
private List<TemplateComposition> _compositions = new();
private bool _loading = true;
private string? _loadError;
private string _activeTab = "attributes";
// Edit properties
private string _editName = string.Empty;
private string? _editDescription;
private int _editParentId;
// Validation
private bool _validating;
private Commons.Types.Flattening.ValidationResult? _validationResult;
// Member add forms
private bool _showAttrForm;
private string _attrName = string.Empty;
private string? _attrValue;
private DataType _attrDataType;
private bool _attrIsLocked;
private string? _attrDataSourceRef;
private string? _attrFormError;
private bool _showAlarmForm;
private string _alarmName = string.Empty;
private int _alarmPriority;
private AlarmTriggerType _alarmTriggerType;
private string? _alarmTriggerConfig;
private bool _alarmIsLocked;
private string? _alarmFormError;
private bool _showScriptForm;
private string _scriptName = string.Empty;
private string _scriptCode = string.Empty;
private string? _scriptTriggerType;
private string? _scriptTriggerConfig;
private bool _scriptIsLocked;
private string? _scriptFormError;
private bool _showCompForm;
private int _compComposedTemplateId;
private string _compInstanceName = string.Empty;
private string? _compFormError;
private ToastNotification _toast = default!;
private ConfirmDialog _confirmDialog = default!;
protected override async Task OnParametersSetAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
_loading = true;
_loadError = null;
try
{
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
_selectedTemplate = await TemplateEngineRepository.GetTemplateWithChildrenAsync(Id)
?? _templates.FirstOrDefault(t => t.Id == Id);
if (_selectedTemplate == null) { _loading = false; return; }
_editName = _selectedTemplate.Name;
_editDescription = _selectedTemplate.Description;
_editParentId = _selectedTemplate.ParentTemplateId ?? 0;
_attributes = (await TemplateEngineRepository.GetAttributesByTemplateIdAsync(Id)).ToList();
_alarms = (await TemplateEngineRepository.GetAlarmsByTemplateIdAsync(Id)).ToList();
_scripts = (await TemplateEngineRepository.GetScriptsByTemplateIdAsync(Id)).ToList();
_compositions = (await TemplateEngineRepository.GetCompositionsByTemplateIdAsync(Id)).ToList();
_validationResult = null;
}
catch (Exception ex)
{
_loadError = $"Failed to load template: {ex.Message}";
}
_loading = false;
}
private void GoBack()
{
NavigationManager.NavigateTo("/design/templates");
}
private async Task<string> GetCurrentUserAsync()
{
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
return authState.User.FindFirst("Username")?.Value ?? "unknown";
}
private RenderFragment RenderTemplateDetail() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="d-inline mb-0">@_selectedTemplate!.Name</h4>
@if (_selectedTemplate.ParentTemplateId.HasValue)
{
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
}
</div>
<div>
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
@if (_validating)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Validate
</button>
<button class="btn btn-outline-danger btn-sm" @onclick="DeleteTemplate">Delete</button>
</div>
</div>
@* Validation results *@
@if (_validationResult != null)
{
<div class="mb-3">
@if (_validationResult.Errors.Count > 0)
{
<div class="alert alert-danger py-2">
<strong>Validation Errors (@_validationResult.Errors.Count)</strong>
<ul class="mb-0 small">
@foreach (var err in _validationResult.Errors)
{
<li>[@err.Category] @err.Message @(err.EntityName != null ? $"({err.EntityName})" : "")</li>
}
</ul>
</div>
}
@if (_validationResult.Warnings.Count > 0)
{
<div class="alert alert-warning py-2">
<strong>Warnings (@_validationResult.Warnings.Count)</strong>
<ul class="mb-0 small">
@foreach (var warn in _validationResult.Warnings)
{
<li>[@warn.Category] @warn.Message</li>
}
</ul>
</div>
}
@if (_validationResult.Errors.Count == 0 && _validationResult.Warnings.Count == 0)
{
<div class="alert alert-success py-2">Validation passed with no errors or warnings.</div>
}
</div>
}
@* Template info edit *@
<div class="card mb-3">
<div class="card-header">Template Properties</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_editName" />
</div>
<div class="col-md-4">
<label class="form-label small">Description</label>
<input type="text" class="form-control form-control-sm" @bind="_editDescription" />
</div>
<div class="col-md-3">
<label class="form-label small">Parent Template</label>
<select class="form-select form-select-sm" @bind="_editParentId">
<option value="0">(None)</option>
@foreach (var t in _templates.Where(t => t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary btn-sm" @onclick="UpdateTemplateProperties">Save Properties</button>
</div>
</div>
</div>
</div>
@* Tabs: Attributes, Alarms, Scripts, Compositions *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(_activeTab == "attributes" ? "active" : "")" @onclick='() => _activeTab = "attributes"'>
Attributes <span class="badge bg-secondary">@_attributes.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "alarms" ? "active" : "")" @onclick='() => _activeTab = "alarms"'>
Alarms <span class="badge bg-secondary">@_alarms.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "scripts" ? "active" : "")" @onclick='() => _activeTab = "scripts"'>
Scripts <span class="badge bg-secondary">@_scripts.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link @(_activeTab == "compositions" ? "active" : "")" @onclick='() => _activeTab = "compositions"'>
Compositions <span class="badge bg-secondary">@_compositions.Count</span>
</button>
</li>
</ul>
@if (_activeTab == "attributes")
{
@RenderAttributesTab()
}
else if (_activeTab == "alarms")
{
@RenderAlarmsTab()
}
else if (_activeTab == "scripts")
{
@RenderScriptsTab()
}
else if (_activeTab == "compositions")
{
@RenderCompositionsTab()
}
};
private async Task DeleteTemplate()
{
if (_selectedTemplate == null) return;
var confirmed = await _confirmDialog.ShowAsync(
$"Delete template '{_selectedTemplate.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(_selectedTemplate.Id, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Template '{_selectedTemplate.Name}' deleted.");
NavigationManager.NavigateTo("/design/templates");
}
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.");
_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
{
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);
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 =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Attributes</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAttrForm = true; _attrFormError = null; _attrName = string.Empty; _attrValue = null; _attrIsLocked = false; _attrDataSourceRef = null; }">Add Attribute</button>
</div>
@if (_showAttrForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_attrName" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Type</label>
<select class="form-select form-select-sm" @bind="_attrDataType">
@foreach (var dt in Enum.GetValues<DataType>())
{
<option value="@dt">@dt</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Value</label>
<input type="text" class="form-control form-control-sm" @bind="_attrValue" />
</div>
<div class="col-md-2">
<label class="form-label small">Data Source Ref</label>
<input type="text" class="form-control form-control-sm" @bind="_attrDataSourceRef" placeholder="Tag path" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_attrIsLocked" id="attrLocked" />
<label class="form-check-label small" for="attrLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAttribute">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAttrForm = false">Cancel</button>
</div>
</div>
@if (_attrFormError != null) { <div class="text-danger small mt-1">@_attrFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Type</th>
<th>Value</th>
<th>Data Source</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var attr in _attributes)
{
<tr>
<td>@attr.Name</td>
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
<td class="small">@(attr.Value ?? "—")</td>
<td class="small text-muted">@(attr.DataSourceReference ?? "—")</td>
<td>
@if (attr.IsLocked)
{
<span class="badge bg-danger" title="Locked">L</span>
}
else
{
<span class="badge bg-light text-dark" title="Unlocked">U</span>
}
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAttribute(attr)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Alarms Tab ----
private RenderFragment RenderAlarmsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Alarms</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showAlarmForm = true; _alarmFormError = null; _alarmName = string.Empty; _alarmPriority = 500; _alarmTriggerConfig = null; _alarmIsLocked = false; }">Add Alarm</button>
</div>
@if (_showAlarmForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<select class="form-select form-select-sm" @bind="_alarmTriggerType">
@foreach (var tt in Enum.GetValues<AlarmTriggerType>())
{
<option value="@tt">@tt</option>
}
</select>
</div>
<div class="col-md-1">
<label class="form-label small">Priority</label>
<input type="number" class="form-control form-control-sm" @bind="_alarmPriority" min="0" max="1000" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_alarmTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="_alarmIsLocked" id="alarmLocked" />
<label class="form-check-label small" for="alarmLocked">Locked</label>
</div>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddAlarm">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showAlarmForm = false">Cancel</button>
</div>
</div>
@if (_alarmFormError != null) { <div class="text-danger small mt-1">@_alarmFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Priority</th>
<th>Config</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var alarm in _alarms)
{
<tr>
<td>@alarm.Name</td>
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
<td>@alarm.PriorityLevel</td>
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
<td>
@if (alarm.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteAlarm(alarm)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Scripts Tab ----
private RenderFragment RenderScriptsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Scripts</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showScriptForm = true; _scriptFormError = null; _scriptName = string.Empty; _scriptCode = string.Empty; _scriptTriggerType = null; _scriptTriggerConfig = null; _scriptIsLocked = false; }">Add Script</button>
</div>
@if (_showScriptForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptName" />
</div>
<div class="col-md-2">
<label class="form-label small">Trigger Type</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerType" placeholder="e.g. ValueChange" />
</div>
<div class="col-md-3">
<label class="form-label small">Trigger Config (JSON)</label>
<input type="text" class="form-control form-control-sm" @bind="_scriptTriggerConfig" />
</div>
<div class="col-md-1">
<div class="form-check mt-4">
<input class="form-check-input" type="checkbox" @bind="_scriptIsLocked" id="scriptLocked" />
<label class="form-check-label small" for="scriptLocked">Locked</label>
</div>
</div>
</div>
<div class="mt-2">
<label class="form-label small">Code</label>
<textarea class="form-control form-control-sm font-monospace" rows="6" @bind="_scriptCode"
style="font-size: 0.8rem;"></textarea>
</div>
<div class="mt-2">
<button class="btn btn-success btn-sm me-1" @onclick="AddScript">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showScriptForm = false">Cancel</button>
</div>
@if (_scriptFormError != null) { <div class="text-danger small mt-1">@_scriptFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Code (preview)</th>
<th>Lock</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var script in _scripts)
{
<tr>
<td>@script.Name</td>
<td class="small">@(script.TriggerType ?? "—")</td>
<td class="small text-muted text-truncate font-monospace" style="max-width: 300px;">@script.Code[..Math.Min(80, script.Code.Length)]@(script.Code.Length > 80 ? "..." : "")</td>
<td>
@if (script.IsLocked) { <span class="badge bg-danger">L</span> }
else { <span class="badge bg-light text-dark">U</span> }
</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteScript(script)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- Compositions Tab ----
private RenderFragment RenderCompositionsTab() => __builder =>
{
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Compositions</h6>
<button class="btn btn-primary btn-sm" @onclick="() => { _showCompForm = true; _compFormError = null; _compInstanceName = string.Empty; _compComposedTemplateId = 0; }">Add Composition</button>
</div>
@if (_showCompForm)
{
<div class="card mb-2">
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small">Instance Name</label>
<input type="text" class="form-control form-control-sm" @bind="_compInstanceName" />
</div>
<div class="col-md-4">
<label class="form-label small">Composed Template</label>
<select class="form-select form-select-sm" @bind="_compComposedTemplateId">
<option value="0">Select template...</option>
@foreach (var t in _templates.Where(t => _selectedTemplate == null || t.Id != _selectedTemplate.Id))
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div class="col-md-3">
<button class="btn btn-success btn-sm me-1" @onclick="AddComposition">Add</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => _showCompForm = false">Cancel</button>
</div>
</div>
@if (_compFormError != null) { <div class="text-danger small mt-1">@_compFormError</div> }
</div>
</div>
}
<table class="table table-sm table-striped">
<thead class="table-light">
<tr>
<th>Instance Name</th>
<th>Composed Template</th>
<th style="width: 80px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var comp in _compositions)
{
<tr>
<td><code>@comp.InstanceName</code></td>
<td>@(_templates.FirstOrDefault(t => t.Id == comp.ComposedTemplateId)?.Name ?? $"#{comp.ComposedTemplateId}")</td>
<td>
<button class="btn btn-outline-danger btn-sm py-0 px-1"
@onclick="() => DeleteComposition(comp)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
};
// ---- 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 LoadAsync();
}
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.");
await LoadAsync();
}
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 LoadAsync();
}
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.");
await LoadAsync();
}
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 LoadAsync();
}
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.");
await LoadAsync();
}
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 _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.");
await LoadAsync();
}
else { _toast.ShowError(result.Error); }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,23 +4,21 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h6 class="modal-title">New Template</h6> <h6 class="modal-title">Move '@FolderName' to…</h6>
<button type="button" class="btn-close" @onclick="Close"></button> <button type="button" class="btn-close" @onclick="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-2"> <select class="form-select form-select-sm" @bind="_targetParentId">
<label class="form-label small">Name</label> @foreach (var opt in FolderOptions)
<input class="form-control form-control-sm" @bind="_name" /> {
</div> <option value="@opt.Id">@opt.Label</option>
<div class="mb-2"> }
<label class="form-label small">Description</label> </select>
<input class="form-control form-control-sm" @bind="_description" />
</div>
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> } @if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button> <button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button> <button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
</div> </div>
</div> </div>
</div> </div>
@@ -30,21 +28,20 @@
@code { @code {
[Parameter] public bool IsVisible { get; set; } [Parameter] public bool IsVisible { get; set; }
[Parameter] public EventCallback<bool> IsVisibleChanged { get; set; } [Parameter] public EventCallback<bool> IsVisibleChanged { get; set; }
[Parameter] public int? FolderId { get; set; } [Parameter] public int FolderId { get; set; }
[Parameter] public string FolderName { get; set; } = string.Empty;
[Parameter] public IEnumerable<(int? Id, string Label)> FolderOptions { get; set; } = Array.Empty<(int?, string)>();
[Parameter] public string? ErrorMessage { get; set; } [Parameter] public string? ErrorMessage { get; set; }
[Parameter] public EventCallback<(int? FolderId, string Name, string? Description)> OnSubmit { get; set; } [Parameter] public EventCallback<(int FolderId, int? NewParentId)> OnSubmit { get; set; }
private bool _wasVisible; private bool _wasVisible;
private string _name = string.Empty; private int? _targetParentId;
private string? _description;
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
// Reset internal state on transition from hidden -> visible.
if (IsVisible && !_wasVisible) if (IsVisible && !_wasVisible)
{ {
_name = string.Empty; _targetParentId = null;
_description = null;
} }
_wasVisible = IsVisible; _wasVisible = IsVisible;
} }
@@ -56,6 +53,6 @@
private async Task Submit() private async Task Submit()
{ {
await OnSubmit.InvokeAsync((FolderId, _name.Trim(), _description?.Trim())); await OnSubmit.InvokeAsync((FolderId, _targetParentId));
} }
} }

View File

@@ -11,7 +11,7 @@
} }
else else
{ {
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")" style="list-style:none;padding-left:0;margin:0;"> <ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
@foreach (var item in _items) @foreach (var item in _items)
{ {
RenderNode(item, 0); RenderNode(item, 0);
@@ -22,7 +22,8 @@ else
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null) @if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
{ {
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div> <div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
<div class="dropdown-menu show" style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;"> <div class="dropdown-menu show" tabindex="-1" @ref="_contextMenuRef" @onkeydown="OnContextMenuKeyDown"
style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;outline:none;">
@ContextMenu(_contextMenuItem) @ContextMenu(_contextMenuItem)
</div> </div>
} }
@@ -33,19 +34,21 @@ else
var children = ChildrenSelector(item); var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item); var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(KeyStr(key)); var isExpanded = _expandedKeys.Contains(KeyStr(key));
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
<li role="treeitem" @key="key" <li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)" aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? "true" : null)"> aria-selected="@(isSelected ? "true" : null)">
<div class="tv-row @(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? SelectedCssClass : "")" style="padding-left: @(depth * IndentPx)px" <div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)"> @oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
@if (isBranch) @if (isBranch)
{ {
<span class="tv-toggle" style="display:inline-block;width:1.2em;text-align:center;cursor:pointer;" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation>@(isExpanded ? "\u2212" : "+")</span> <span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation><i class="bi bi-chevron-right"></i></span>
} }
else else
{ {
<span class="tv-spacer" style="display:inline-block;width:1.2em;"></span> <span class="tv-spacer"></span>
} }
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation> <span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
@NodeContent(item) @NodeContent(item)
@@ -53,7 +56,7 @@ else
</div> </div>
@if (isBranch && isExpanded && children is { Count: > 0 }) @if (isBranch && isExpanded && children is { Count: > 0 })
{ {
<ul role="group" style="list-style:none;padding-left:0;margin:0;"> <ul role="group">
@foreach (var child in children) @foreach (var child in children)
{ {
RenderNode(child, depth + 1); RenderNode(child, depth + 1);
@@ -76,6 +79,8 @@ else
private double _contextMenuX; private double _contextMenuX;
private double _contextMenuY; private double _contextMenuY;
private bool _showContextMenu; private bool _showContextMenu;
private bool _contextMenuNeedsFocus;
private ElementReference _contextMenuRef;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = []; [Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!; [Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
@@ -117,6 +122,12 @@ else
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (_contextMenuNeedsFocus && _showContextMenu)
{
_contextMenuNeedsFocus = false;
try { await _contextMenuRef.FocusAsync(); } catch { /* element may have been disposed if dismissed during render */ }
}
if (firstRender && StorageKey != null) if (firstRender && StorageKey != null)
{ {
var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey); var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
@@ -212,6 +223,7 @@ else
_contextMenuX = e.ClientX; _contextMenuX = e.ClientX;
_contextMenuY = e.ClientY; _contextMenuY = e.ClientY;
_showContextMenu = true; _showContextMenu = true;
_contextMenuNeedsFocus = true;
} }
private void DismissContextMenu() private void DismissContextMenu()
@@ -220,6 +232,17 @@ else
_contextMenuItem = default; _contextMenuItem = default;
} }
private void OnContextMenuKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Escape")
{
DismissContextMenu();
}
}
/// <summary>Whether the node with the given key is currently expanded.</summary>
public bool IsExpanded(object key) => _expandedKeys.Contains(KeyStr(key));
/// <summary>Expand every branch node in the tree.</summary> /// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll() public void ExpandAll()
{ {

View File

@@ -0,0 +1,135 @@
/* TreeView component styling — see docs/requirements/Component-TreeView.md "Visual Design Guide" (V1V7). */
/* Root list — no list styling. */
.tv-root,
.tv-root ul {
list-style: none;
padding-left: 0;
margin: 0;
}
/* V1 — Row anatomy. Flex container; full-width hit surface; ~32px row. */
.tv-row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
cursor: default;
border-radius: 0.25rem;
transition: background-color 0.08s linear;
}
/* V1 — slot widths. Toggle and glyph are always present so labels align across siblings. */
.tv-row .tv-toggle,
.tv-row .tv-spacer {
flex: 0 0 auto;
width: 1.25rem; /* 20px */
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--bs-secondary-color);
user-select: none;
}
.tv-row .tv-spacer {
cursor: default;
}
.tv-row .tv-content {
flex: 1 1 auto;
min-width: 0; /* required so child label can ellipsis-truncate inside a flex item */
display: flex;
align-items: center;
gap: 0.25rem;
}
/* V5 — primary label truncation. Consumers add their own bold class for branches. */
.tv-row .tv-label {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* V1 — meta slot right-aligns trailing badges/text against row edge. */
.tv-row .tv-meta {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex: 0 0 auto;
}
/* V4 — glyph slot. Bootstrap Icons render at 1em, inherit text color. */
.tv-row .tv-glyph {
flex: 0 0 auto;
width: 1.25rem; /* 20px, same slot size as toggle */
display: inline-flex;
align-items: center;
justify-content: center;
}
/* V3 — hover. Subtle gray wash; suppressed when row is selected. */
.tv-row:hover:not(.tv-selected) {
background-color: var(--bs-tertiary-bg);
}
/* V3 — selected. Bootstrap utility `bg-primary bg-opacity-10` is the SelectedCssClass default;
the .tv-selected hook is provided for consumers that prefer scoped styling. */
.tv-row.tv-selected {
background-color: rgba(var(--bs-primary-rgb), 0.1);
}
/* V3 — keyboard focus. Inset ring composes with hover/selected without layout shift. */
.tv-row:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--bs-primary);
}
/* V3 — drop-target (valid). Overrides hover/selected. */
.tv-row.tv-drop-target {
background-color: rgba(var(--bs-info-rgb), 0.25);
}
/* V3 — dimmed (e.g. non-droppable while a drag is in progress; reserved for future use). */
.tv-row.tv-dimmed {
opacity: 0.5;
}
/* V2 — guide lines. A pseudo-element overlays the row's depth gutter and draws
one vertical line per ancestor depth at 24px intervals. The `--tv-depth` variable
is set inline per row; lines never extend into the content area. */
.tv-guides .tv-row {
position: relative;
}
.tv-guides .tv-row::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: calc(var(--tv-depth, 0) * 1.5rem);
pointer-events: none;
background-image: linear-gradient(
to right,
transparent calc(0.625rem - 0.5px),
var(--bs-border-color) calc(0.625rem - 0.5px),
var(--bs-border-color) calc(0.625rem + 0.5px),
transparent calc(0.625rem + 0.5px)
);
background-size: 1.5rem 100%;
background-repeat: repeat-x;
}
/* Branch chevron rotates on expand via the aria-expanded attribute on the parent treeitem.
Consumers using `bi-chevron-right` get the down-rotation for free. */
.tv-row .tv-toggle .bi-chevron-right {
transition: transform 0.1s linear;
}
[role="treeitem"][aria-expanded="true"] > .tv-row .tv-toggle .bi-chevron-right {
transform: rotate(90deg);
}

View File

@@ -6,6 +6,8 @@
<base href="/" /> <base href="/" />
<title>ScadaLink</title> <title>ScadaLink</title>
<link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" /> <link href="/lib/bootstrap/css/bootstrap.min.css" rel="stylesheet" />
<link href="/lib/bootstrap-icons/bootstrap-icons.css" rel="stylesheet" />
<link href="/ScadaLink.Host.styles.css" rel="stylesheet" />
<style> <style>
.sidebar { .sidebar {
min-width: 220px; min-width: 220px;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Bunit; using Bunit;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NSubstitute; using NSubstitute;
using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Entities.Templates;
@@ -120,41 +119,6 @@ public class TemplatesPageTests : BunitContext
Assert.Contains("→", cut.Markup); Assert.Contains("→", cut.Markup);
} }
[Fact]
public async Task DragTemplateOntoRoot_CallsMoveTemplateAsync_WithNullFolderId()
{
// Arrange: one template currently parented to a folder; the test simulates
// the user dragging that template onto the root drop-zone, which should
// result in TemplateService.MoveTemplateAsync(..., newFolderId: null) being
// invoked. We keep the template at the root in the rendered tree (FolderId
// null) so it renders without needing an expand-click; the drag payload only
// cares about the in-memory id captured by OnDragStart, not the visual
// parent.
var template = new Template("RootDragTarget") { Id = 5, FolderId = null };
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Template>>(new List<Template> { template }));
_repo.GetAllFoldersAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<TemplateFolder>>(new List<TemplateFolder>()));
_repo.GetTemplateByIdAsync(5, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Template?>(template));
var cut = Render<TemplatesPage>();
// Act: fire ondragstart on the template's draggable <div> (RenderNodeLabel
// emits draggable="true" for template/folder nodes), then ondrop on the
// root wrapper marked with the test-affordance class added to the page.
var templateNode = cut.Find("div[draggable='true']");
await templateNode.TriggerEventAsync("ondragstart", new DragEventArgs());
var rootDropZone = cut.Find("div.templates-root-dropzone");
await rootDropZone.TriggerEventAsync("ondrop", new DragEventArgs());
// Assert: TemplateService.MoveTemplateAsync delegates to the repository's
// UpdateTemplateAsync with FolderId set to null.
await _repo.Received(1).UpdateTemplateAsync(
Arg.Is<Template>(t => t.Id == 5 && t.FolderId == null),
Arg.Any<CancellationToken>());
}
} }
internal sealed class TestAuthStateProvider : AuthenticationStateProvider internal sealed class TestAuthStateProvider : AuthenticationStateProvider

View File

@@ -127,7 +127,8 @@ public class TreeViewTests : BunitContext
Assert.Equal("false", alphaLi.GetAttribute("aria-expanded")); Assert.Equal("false", alphaLi.GetAttribute("aria-expanded"));
var toggle = alphaLi.QuerySelector(".tv-toggle"); var toggle = alphaLi.QuerySelector(".tv-toggle");
Assert.NotNull(toggle); Assert.NotNull(toggle);
Assert.Equal("+", toggle!.TextContent); // V2 spec: toggle uses Bootstrap Icons chevron-right; CSS rotates on aria-expanded.
Assert.NotNull(toggle!.QuerySelector("i.bi.bi-chevron-right"));
} }
[Fact] [Fact]
@@ -610,6 +611,43 @@ public class TreeViewTests : BunitContext
Assert.Equal("Alpha", btn!.TextContent); Assert.Equal("Alpha", btn!.TextContent);
} }
[Fact]
public void ContextMenu_EscapeKeyDismissesMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
Assert.NotNull(cut.Find(".dropdown-menu"));
// Press Escape on the menu — R15 spec requires it to dismiss.
var menu = cut.Find(".dropdown-menu");
menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "Escape" });
Assert.Empty(cut.FindAll(".dropdown-menu"));
}
[Fact]
public void ContextMenu_NonEscapeKey_DoesNotDismiss()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
var menu = cut.Find(".dropdown-menu");
menu.TriggerEvent("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" });
Assert.NotEmpty(cut.FindAll(".dropdown-menu"));
}
[Fact] [Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu() public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{ {