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:
@@ -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 (~25–33% 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: ~25–33% (`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 25–33% 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.
|
||||||
|
|||||||
@@ -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** (V1–V7). 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 V5–V6; `/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 V1–V6.
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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">← 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
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
135
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css
Normal file
135
src/ScadaLink.CentralUI/Components/Shared/TreeView.razor.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/* TreeView component styling — see docs/requirements/Component-TreeView.md "Visual Design Guide" (V1–V7). */
|
||||||
|
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
2078
src/ScadaLink.Host/wwwroot/lib/bootstrap-icons/bootstrap-icons.css
vendored
Normal file
2078
src/ScadaLink.Host/wwwroot/lib/bootstrap-icons/bootstrap-icons.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user