Compare commits
64
Commits
@@ -120,6 +120,7 @@ trace.zip
|
||||
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
|
||||
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
|
||||
/src/Umbraco.Cms/appsettings-schema.json
|
||||
.worktrees
|
||||
.playwright-mcp/
|
||||
|
||||
# SonarQube local analysis cache
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Visual Editor — Partial Re-render (Phase 3 remainder) — Design
|
||||
|
||||
**Status**: Implemented (spike passed 2026-06-11; see `2026-06-11-visual-editor-partial-rerender-plan.md`). Built via cache-node override + `IPublishedContentFactory` rather than a decorator — see the plan's "Deliberate deviation" note.
|
||||
**Date**: 2026-06-11
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: The "Still to build" items of Phase 3 in `docs/plans/visual-page-builder.md` — server-side partial re-render with unsaved values, and client-side DOM patching. Block manipulation itself is already done.
|
||||
**Relates to**: `docs/plans/visual-page-builder.md` §4.4 (original endpoint sketch), §2.3 (BlockPreview pattern); supersedes the isolated-region endpoint idea in §4.4 in favour of full-page render + client morph.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Make partial re-render the **single, universal mechanism** for reflecting edits in the visual editor preview, retiring both the optimistic-text-only path and the save-and-full-reload path. Every edit — plain text, RTE/Markdown/media, block content/settings, and structural block add/delete/move/reorder — is reflected by re-rendering the page server-side with the workspace's unsaved values and morphing the live iframe DOM in place (no reload, scroll/selection preserved).
|
||||
|
||||
## Decisions locked
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| Trigger scope | **All** edit types route through re-render: block content/settings, block add/delete/move/reorder, RTE/Markdown/transformed properties, and plain text. |
|
||||
| Feedback model | **Optimistic + authoritative**: instant optimistic `textContent` paint for plain text on keystroke; a debounced (~500ms) server re-render then replaces the region with true Razor output. Blocks/RTE show a subtle pending state (no meaningful optimistic paint) until the render returns. |
|
||||
| Rendering approach | **A — full-page render + client DOM morph.** One endpoint renders the whole page via the existing preview path with unsaved values injected; guest morphs the live DOM. Chosen over isolated-region (B) because only a full-page render covers arbitrary-template property placement with guaranteed fidelity, and over hybrid (C) for single-path simplicity. |
|
||||
| DOM patch | Bundle **morphdom** in the guest bundle; morph `<body>`, touching only changed nodes; preserves scroll. |
|
||||
| Failure mode | **Keep last good DOM + quiet notice.** Leave current DOM untouched, log, transient non-blocking indicator; the workspace already holds the edit so the next successful render reconciles. Never silently swallow. |
|
||||
| Save + SignalR | **Suppress self-reload, keep as multi-user net.** After a local save, a short-lived guard makes the editor ignore its own `refreshed` SignalR event (DOM already authoritative — no flicker). Refreshes not caused by this editor still reload. |
|
||||
|
||||
## Architecture & data flow
|
||||
|
||||
```
|
||||
edit (property / block / structural)
|
||||
→ element updates workspace value (source of truth) [+ optimistic textContent for plain text]
|
||||
→ UmbVisualEditorRenderController: debounce ~500ms, latest-wins (AbortController cancels in-flight)
|
||||
→ POST /umbraco/management/api/v1/visual-editor/render
|
||||
body: { unique, culture?, segment?, values: [{ alias, value, culture?, segment? }] }
|
||||
→ server:
|
||||
EnsureUmbracoContext + force preview mode + VisualEditorPropertyTracker.Enable() for the render scope
|
||||
base = DRAFT content from the published cache (preview read — same as the iframe shows)
|
||||
wrap in PropertyOverridePublishedContent(unsaved values)
|
||||
render the assigned template → HTML string (data-umb-* annotations emitted)
|
||||
→ { html }
|
||||
→ element posts umb:ve:render to the guest with the HTML
|
||||
→ guest morphs <body> (morphdom) → re-runs initRegions() → restores selection highlight
|
||||
```
|
||||
|
||||
The base is the **draft** content the iframe already renders (preview-mode cache read); the override layer is the workspace's even-newer unsaved edits on top.
|
||||
|
||||
## Server components (new)
|
||||
|
||||
| Unit | Project | Responsibility |
|
||||
|---|---|---|
|
||||
| Override-content builder (conversion) | `Umbraco.PublishedCache.HybridCache` (or a public seam exposed from it) | Produce an `IPublishedContent` representing the draft + unsaved overrides. **Approach proven by the spike** (and mirroring the in-tree `BlockElementService.BuildElementAsync`): for each overridden alias, run the editor-format value through `dataType.Editor.GetValueEditor().FromEditor(new ContentPropertyData(value, dataType.ConfigurationObject), null)` to get the source value; reuse the existing saved source values (`property.GetValue(published)`) for non-overridden aliases; assemble `PropertyData[]` → `ContentData` → `ContentCacheNode` → `IPublishedContentFactory.ToIPublishedContent(node, preview: true).CreateModel(...)`. Threads `Culture`/`Segment` onto `PropertyData` and sets `ContentData.CultureInfos` for variant content. **Not** a `GetProperty` decorator — a cache-node rebuild. (`IPublishedContentFactory` is `internal` to HybridCache, hence this unit lives there or a small public seam is added — resolved in the plan.) |
|
||||
| `IVisualEditorRenderService` + impl | `Umbraco.Web.Common` | Renders a supplied `IPublishedContent` to an HTML string. Modeled on `TemplateRenderer` (`src/Umbraco.Web.Common/Templates/TemplateRenderer.cs`): build an `IPublishedRequest` via `IPublishedRouter`, `SetPublishedContent(overriddenContent)`, set culture/segment + template, swap onto `UmbracoContext.PublishedRequest`, render the template view to a `StringWriter`, restore. Forces preview mode + enables `VisualEditorPropertyTracker` for the render scope so annotations are emitted. RTE-embedded blocks render via the partial-view block engine, which this render context satisfies. |
|
||||
| `RenderVisualEditorController` | `Umbraco.Cms.Api.Management` | `POST /umbraco/management/api/v1/visual-editor/render`, `[Authorize(Policy = BackOfficeAccess)]`. Ensures an `UmbracoContext`, resolves the draft content for `unique`, builds the override content from the request `values`, calls the render service, returns `{ html }`. |
|
||||
|
||||
**Value conversion — DE-RISKED by the spike (2026-06-11).** All three property kinds convert correctly via `IPublishedContentFactory.ToIPublishedContent`:
|
||||
- **TextBox** — `FromEditor` → string source → published string. Clean.
|
||||
- **Rich Text** — `FromEditor` → source JSON → `RteBlockRenderingValueConverter`; all link/url/image parsing happens at value-conversion time (no `IPublishedRequest` needed). RTE-*embedded blocks* additionally use the partial-view block engine at render time (covered by the full-page render context — smoke-test specifically).
|
||||
- **Block List** — `FromEditor` source IS the block JSON; the converter resolves element types from the published content-type cache (no parent content / `IPublishedRequest` needed). Blocks need an `Expose` entry for the relevant culture/segment to surface.
|
||||
|
||||
Recommended primitive: reuse `IPublishedContentFactory` rather than hand-assembling per property. Variant content must populate `PropertyData` per culture/segment + `ContentData.CultureInfos`, and read-time resolution depends on the ambient `IVariationContextAccessor`.
|
||||
|
||||
## Client components
|
||||
|
||||
| Unit | Responsibility |
|
||||
|---|---|
|
||||
| `UmbVisualEditorRenderController` (new sibling, follows the SignalR/router/resolver extraction pattern) | Debounce (~500ms) + latest-wins cancellation via `AbortController`. Collects the active variant's current values from the workspace, calls the endpoint, posts `umb:ve:render` to the guest with the returned HTML. On failure: keep DOM, log, transient notice. Invoked from every mutation site (property submit, block submit, add/move/delete/reorder, and the debounced optimistic text input). |
|
||||
| guest `injected.ts` | Bundle **morphdom**. Refactor the one-shot init (default outlines, drag-sort setup, add-button insertion, region discovery) into a re-runnable `initRegions()`. On `umb:ve:render`: morph `document.body` to the new HTML, then run `initRegions()` and restore the selection highlight. Delegated document-level listeners (click capture, mouseover) survive the morph; per-node styles/attributes are re-applied by `initRegions()`. |
|
||||
| element SignalR (`visual-editor-signalr.controller.ts` + element) | Reintroduce a short-lived **suppress-self-reload** guard set when this editor saves, so the `refreshed` event for our own document key is ignored. Refreshes outside the guard window still reload (multi-user / external cache changes). |
|
||||
|
||||
## Error handling
|
||||
|
||||
- Render failure (network/500/timeout): keep last good DOM, log, show a transient non-blocking "preview out of date" indicator. The edit is already in the workspace; a later successful render reconciles. No silent swallow.
|
||||
- Latest-wins: a newer edit aborts the in-flight render so stale HTML never overwrites newer DOM.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Render caching / output pooling beyond debounce + concurrency cap.
|
||||
- Headless / Delivery-API rendering in the iframe.
|
||||
- Surfacing validation state in the preview.
|
||||
- Server-side sub-region extraction (full-page render + client morph already delivers partial DOM updates).
|
||||
- Inline (`contenteditable`) editing — that is Phase 4 and now has its server-rendered source of truth from this phase.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Backend integration test** (the riskiest, and testable C#, unlike the UI surface): the spike's throwaway test at `tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/VisualEditorConversionSpikeTests.cs` (uncommitted) is the basis — the plan's first task formalizes it into a real test of the override-content builder for TextBox, RTE, and Block List (assert converted-without-saving == save-then-read). A second test renders a seeded document through the render service and asserts the HTML reflects overridden values and carries `data-umb-*` annotations.
|
||||
- **Frontend**: `npm run build` + `npm run lint` + manual smoke (no VE test harness exists; consistent with the prior phase).
|
||||
|
||||
## Spike outcome (2026-06-11) — PASSED
|
||||
|
||||
A throwaway integration test (`VisualEditorConversionSpikeTests`, uncommitted) booted Umbraco on SQLite, seeded a doc with TextBox + Rich Text + Block List, and proved that each property's editor-format value converts to the correct published value **without saving**, via `FromEditor` + `IPublishedContentFactory.ToIPublishedContent`. All 3 assertions passed (convert-without-saving == save-then-read). Findings folded into "Server components" above:
|
||||
|
||||
- Conversion primitive: `IPublishedContentFactory` (HybridCache, `internal`) — plan must resolve the access seam.
|
||||
- Approach is a cache-node rebuild, **not** a `GetProperty` decorator (in-tree precedent: `BlockElementService`).
|
||||
- Variants: thread `Culture`/`Segment` + `ContentData.CultureInfos`; read-time needs `IVariationContextAccessor`.
|
||||
- Render-to-string is independently de-risked by the existing `TemplateRenderer`; RTE-embedded-block partials are the one spot needing the render context (not the value conversion).
|
||||
|
||||
No design fallback required — the approach is viable as chosen.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
# Visual Editor Tidy-Up — Design
|
||||
|
||||
**Status**: Implemented (manual smoke pass pending)
|
||||
**Date**: 2026-06-11
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: Tidy-up round on `feature/visual-editor` after merging `main` — no new feature phases.
|
||||
**Relates to**: `docs/plans/visual-page-builder.md` (the feature plan; updated as part of this round)
|
||||
|
||||
---
|
||||
|
||||
## Decisions locked in this round
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| Architecture | **Embedded document-workspace view** is the current direction. The standalone-window evolution (plan doc §10, Open Q11) is **deferred**, not the next step. |
|
||||
| Round scope | **Tidy-up only** — security, semantics, refactor, docs. No partial re-render API, no inline editing. |
|
||||
| Editability semantics | **Strict opt-in everywhere** for document properties: a property is annotated/editable only when `appearance.editableInVisualEditor === true`. The frontend opt-out fallback is removed. |
|
||||
| Block modal properties | **No filter**: the block editing modal shows all of the element type's content/settings properties. The `EditableInVisualEditor` setting governs document property annotation only. |
|
||||
|
||||
## Why
|
||||
|
||||
The branch is functionally far ahead of its plan doc (Phases 1–2 complete plus most block manipulation), but an audit found:
|
||||
|
||||
1. **Security**: the guest script (`src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts:540`) accepts `message` events with no `evt.origin` check, and posts with target `'*'`. The backoffice-side listener also lacks source/origin validation.
|
||||
2. **Semantic mismatch**: backend tracking is strict opt-in (`PublishedContentExtensions.TrackVisualEditorAccess` checks `EditableInVisualEditor`), while the frontend had a conflicting "if none opt in, include all" fallback — dead code for properties, but confusing and wrong.
|
||||
3. **Maintainability**: `document-workspace-view-visual-editor.element.ts` is 1,210 lines with ~11 responsibilities.
|
||||
4. **Gap**: root-level empty Block Lists cannot offer "Add content" (container lacks a property-alias annotation; `injected.ts:1040` TODO).
|
||||
5. **Stale docs**: `visual-page-builder.md` predates the `EditableInVisualEditor` setting and records "standalone window" as decided.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Security hardening
|
||||
|
||||
**Guest script** (`injected.ts`):
|
||||
- Derive `PARENT_ORIGIN` once: `document.referrer ? new URL(document.referrer).origin : window.location.origin`.
|
||||
- Incoming handler: drop messages where `evt.origin !== PARENT_ORIGIN`.
|
||||
- Outgoing: `window.parent.postMessage(msg, PARENT_ORIGIN)` instead of `'*'`.
|
||||
- Referrer-based derivation keeps cross-origin dev (Vite 5173 → server 44339) working.
|
||||
|
||||
**Workspace view element**: the `message` listener accepts only events where `evt.source === iframe.contentWindow` **and** `evt.origin` equals the server origin from `UMB_SERVER_CONTEXT`.
|
||||
|
||||
### 2. Strict opt-in semantics
|
||||
|
||||
In the element's property-structure resolution:
|
||||
- Remove the `anyExplicitlyEnabled` hybrid entirely.
|
||||
- Document property METADATA stays unfiltered (it doubles as block-config lookup for `#getBlocksConfig`); enforcement is at the interaction points instead: `#onPropertyClicked` and the property modal `onSetup` both require `editableInVisualEditor === true` (defense-in-depth on top of server-side annotation gating).
|
||||
- Remove the filter entirely from block content/settings structure resolution (blocks show all fields).
|
||||
- Drop the `as { editableInVisualEditor?: boolean }` casts — the generated API types carry `appearance.editableInVisualEditor` natively; the resolver maps it onto `UmbVisualEditorPropertyInfo.editableInVisualEditor`.
|
||||
|
||||
Backend is already strict — no backend change.
|
||||
|
||||
### 3. Element refactor (extraction-only)
|
||||
|
||||
Extract from `document-workspace-view-visual-editor.element.ts` into sibling files; no behavior change:
|
||||
|
||||
| New file | Responsibility |
|
||||
|---|---|
|
||||
| `visual-editor-signalr.controller.ts` | `HubConnection` lifecycle, `refreshed` event, refresh-suppression guard |
|
||||
| `visual-editor-property-structure.resolver.ts` | Document/block/settings property-structure resolution incl. composition-chain fetch and caching; `Map`-indexed by alias (replaces 6× O(n) `find()`); sole home of the opt-in filter |
|
||||
| `visual-editor-message-router.ts` | Typed message-map routing of guest messages (replaces 7-case switch); performs the origin/source validation from §1 |
|
||||
|
||||
The element keeps iframe lifecycle, modal registrations, selection state and preview URL — target ≤ ~600 lines. Also: `Object.keys(pastedBlocks.layout)[0]` → `Object.values(pastedBlocks.layout)[0]` (line 972).
|
||||
|
||||
### 4. Root-level empty block lists
|
||||
|
||||
- `BlockListTemplateExtensions` passes the property alias to the partial via `ViewData` (alias-aware overloads; empty models no longer short-circuit so the partial can render an annotated empty container in preview mode).
|
||||
- `Views/Partials/blocklist/default.cshtml` emits `data-umb-block-property="<alias>"` on the list container — a distinct attribute, because `data-umb-property` is the guest script's property-region selector and would turn the whole list into a clickable property region.
|
||||
- `injected.ts` resolves the alias from the container for empty root-level lists and renders the existing "Add content" button via a new `umb:ve:block-add-to-property` message (closes the `injected.ts:1040` TODO).
|
||||
|
||||
### 5. Docs & polish
|
||||
|
||||
- XML docs: class-level summary on `VisualEditorPropertyTracker`; `<param>` tags on `VisualEditorGuestScript.GetScriptTag()`.
|
||||
- `docs/plans/visual-page-builder.md`: refresh status header and phase statuses; close Open Q5 (setting shipped, strict opt-in); mark §10/Q11 standalone window **Deferred** with embedded view as current; update Appendix B attribute table.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Partial re-render API (Phase 3 remainder), inline editing (Phase 4), headless rendering, validation surfaced in preview, scroll retention.
|
||||
- Moving the visual editor to its own package / lifting block-manipulation logic to library level — revisit with Phase 3.
|
||||
- Automated tests for the visual editor (no harness exists for this surface yet; E2E coverage noted in the plan doc as future work).
|
||||
|
||||
## Verification
|
||||
|
||||
1. `npm run build` and `npm run lint` in `src/Umbraco.Web.UI.Client`.
|
||||
2. `dotnet build umbraco.sln` — zero errors, no new warnings.
|
||||
3. Manual smoke in the visual editor tab: property edit (flagged + unflagged property), block add/edit/settings/move/delete, empty root-level block list "Add content", save → SignalR refresh → selection restore, postMessage still works in dev (Vite) and built modes.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
# Visual Editor — Framework-Emitted Empty-Block Affordance — Design
|
||||
|
||||
**Status**: Implemented
|
||||
**Date**: 2026-06-12
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: Move the "empty editable block property" visual-editor affordance (the annotated container that lets the guest offer an "Add content" button) out of per-view template code and into the framework block-rendering helpers, so it works automatically for every template — including custom ones — with zero template boilerplate.
|
||||
**Supersedes**: the per-view empty-state edits to `blockgrid/blocklist/singleblock/default.cshtml` (sample site) and `EmbeddedResources/BlockGrid/default.cshtml`, plus the `PropertyAliasViewDataKey` ViewData plumbing in `BlockListTemplateExtensions`/`BlockGridTemplateExtensions`.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The visual editor needs a DOM anchor for empty, editable block properties so the guest can render an "Add content" affordance (it has no blocks to attach inter-block "+" buttons to). The current implementation puts this in the Razor templates:
|
||||
|
||||
- `GetBlock{List,Grid}HtmlAsync` short-circuits empty models to `HtmlString.Empty`.
|
||||
- Each `default.cshtml` was patched to read a `PropertyAliasViewDataKey` from ViewData and render an annotated empty `<div ... data-umb-block-property="{alias}">` in visual-editor mode.
|
||||
|
||||
This is unfriendly and incomplete:
|
||||
- Every block template (block list, block grid, single block — default **and** any custom template) must carry framework annotation boilerplate.
|
||||
- Custom templates that don't include it silently lose the feature.
|
||||
- It contrasts with regular property annotation, which is fully automatic (`UmbracoViewPage` wraps editable property output in `data-umb-property` spans with no template code).
|
||||
|
||||
## Goal
|
||||
|
||||
Make the empty-block affordance **fully automatic**: no template code, working for the default templates and any custom template, gated on the property's `EditableInVisualEditor` opt-in and on visual-editor/preview mode. Revert all per-view edits and the ViewData plumbing.
|
||||
|
||||
## Why not the obvious alternatives
|
||||
|
||||
- **Emit it from `UmbracoViewPage` (like `data-umb-property`)**: the automatic span is only emitted when the property is accessed via the tracked `IPublishedContent.Value()` path; the block helpers read the value via `GetProperty().GetValue()`, which bypasses the tracker. Making block access reliably tracked and anchoring an affordance on an empty span touches the core annotation pipeline — bigger and riskier (this is the deferred "unify all property annotation" direction).
|
||||
- **Emit HTML from the Core block model**: `BlockListModel`/`BlockGridModel` live in `Umbraco.Core`, which has no web/HTML concern — emitting annotation markup from the model crosses a layer boundary.
|
||||
|
||||
## Approach (chosen)
|
||||
|
||||
The block-rendering helpers in `Umbraco.Web.Common` are the web-layer choke point essentially all block rendering flows through. Move the empty-state emission there.
|
||||
|
||||
### Component 1 — Helpers emit the annotated container
|
||||
|
||||
In `BlockListTemplateExtensions`, `BlockGridTemplateExtensions`, and the single-block rendering helper:
|
||||
|
||||
- When the model is **empty** AND `VisualEditorPropertyTracker.IsEnabled` AND the property's `PropertyType.EditableInVisualEditor` is `true`, return a minimal annotated container as an `HtmlString`:
|
||||
- Block list: `<div class="umb-block-list" data-umb-block-property="{alias}"></div>`
|
||||
- Block grid: `<div class="umb-block-grid" data-umb-block-property="{alias}"></div>` (with the existing `data-grid-columns`/`--umb-block-grid--grid-columns` styling, defaulting columns to `12`)
|
||||
- Single block: an analogous annotated empty container (see Component 3)
|
||||
- Otherwise return `HtmlString.Empty` exactly as today. Non-empty models render their partial unchanged.
|
||||
|
||||
The helper builds this small fixed container directly (no partial, no ViewData). The `PropertyAliasViewDataKey` constant, the `WithPropertyAlias` helper, and the alias-via-ViewData private overloads are **removed** from both extensions.
|
||||
|
||||
Gating predicate (shared intent across all three helpers): `model is empty && VisualEditorPropertyTracker.IsEnabled && propertyType?.EditableInVisualEditor == true`.
|
||||
|
||||
### Component 2 — Emission lives in the alias-bearing overloads only (no model metadata)
|
||||
|
||||
The helpers have three call styles:
|
||||
|
||||
| Overload | Has alias + editable flag? |
|
||||
|---|---|
|
||||
| `GetBlock*HtmlAsync(IPublishedContent content, string alias[, template])` | Yes — resolves the `IPublishedProperty` (`alias`, `PropertyType.EditableInVisualEditor`) |
|
||||
| `GetBlock*HtmlAsync(IPublishedProperty property[, template])` | Yes — `property.Alias`, `property.PropertyType.EditableInVisualEditor` |
|
||||
| `GetBlock*HtmlAsync(BlockListModel/BlockGridModel model[, template])` | **No** |
|
||||
|
||||
The empty-state container is emitted **only by the two alias-bearing overloads**, because they carry the alias and editable flag regardless of whether the value is empty.
|
||||
|
||||
**Why not "alias on the model" (rejected):** empty block values resolve to a process-wide **singleton** — the value creators return `BlockListModel.Empty` / `BlockGridModel.Empty` (`public static`), and an empty single block converts to `null`. There is no per-property instance to carry an alias for the empty case, and setting a mutable alias on the shared singleton would corrupt every empty block property on the site. The alias is also unavailable where the model is built (the value *creators* don't receive `IPublishedPropertyType` — only the *converters* do). So model metadata is out; **no changes to Core models, value creators, or converters.**
|
||||
|
||||
**Consequence for the model-only overload:** `GetBlock*HtmlAsync(Model.BlockProperty)` (model-only, including the bare ModelsBuilder property) keeps its current behaviour — empty renders nothing, no affordance. The alias-bearing overload (`GetBlock*HtmlAsync(Model, "alias")` / `(IPublishedProperty)`) is the documented, default pattern used by all sample templates (and `Home.cshtml` was aligned to it), so "fully automatic" holds for the standard pattern. The model-only gap is in the same class as fully hand-rolled rendering — see Out of scope.
|
||||
|
||||
### Component 3 — Single block
|
||||
|
||||
The single-block helper is `SingleBlockTemplateExtensions.GetBlockHtmlAsync`; an empty single-block property surfaces as a **null** `BlockListItem` (the helper already returns `HtmlString.Empty` for null). Emit an annotated empty container when the value is null/empty + `VisualEditorPropertyTracker.IsEnabled` + the property is `EditableInVisualEditor`.
|
||||
|
||||
Consistent with Component 2: annotation comes only from the **alias-bearing overloads** — `GetBlockHtmlAsync(IPublishedProperty)` and `GetBlockHtmlAsync(IPublishedContent, alias)` — which expose `property.Alias` and `property.PropertyType.EditableInVisualEditor` even when `property.GetValue()` is null. The model-only `GetBlockHtmlAsync(BlockListItem? model)` overload, given a null model, has no alias and cannot annotate (documented gap; the sample/default and documented usage use the alias-bearing overloads).
|
||||
|
||||
"Add content" reuses the existing `umb:ve:block-add-to-property` message (single-block semantics: one block, `insertIndex 0`). The guest gains a single-block empty-container branch mirroring the list/grid ones (or a shared selector). Exact container markup + the guest branch are finalized in the plan.
|
||||
|
||||
### Component 4 — Guest + element (mostly unchanged)
|
||||
|
||||
- The guest already attaches the "Add content" placeholder to empty `.umb-block-list` / `.umb-block-grid` containers carrying `data-umb-block-property`, and the element's grid-aware add (`#resolveBlockSchemaAlias`) already produces the correct list/grid value shape. These are unchanged.
|
||||
- The only guest addition is the single-block empty-container handling.
|
||||
- The `data-umb-block-property` attribute and the `umb:ve:block-add-to-property` postMessage protocol are retained — the helper now emits the attribute that the templates previously emitted.
|
||||
|
||||
### Component 5 — Revert the per-view changes
|
||||
|
||||
Revert to original form (removing the empty-state boilerplate and ViewData reads):
|
||||
- `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml`
|
||||
- `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml`
|
||||
- `src/Umbraco.Web.UI/Views/Partials/singleblock/default.cshtml` (unchanged from original — never modified, but confirm it needs no edit under the new mechanism)
|
||||
- `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml`
|
||||
|
||||
`Home.cshtml`'s switch to the alias-aware overload (`GetBlockGridHtmlAsync(Model, "bodyText")`) may be **kept or reverted** — under Component 2 the model-only overload also works, so reverting it is safe; keeping it is harmless. The plan picks one (default: keep, as the alias-aware overload is the documented norm).
|
||||
|
||||
## Data flow (after)
|
||||
|
||||
```
|
||||
template: @await Html.GetBlockGridHtmlAsync(Model, "bodyText") (or Model.BodyText, or an IPublishedProperty)
|
||||
→ helper resolves model + property alias + EditableInVisualEditor
|
||||
→ model non-empty? → render partial as today (unchanged)
|
||||
→ model empty?
|
||||
→ VisualEditorPropertyTracker.IsEnabled && EditableInVisualEditor?
|
||||
→ return <div class="umb-block-grid" data-umb-block-property="bodyText"></div>
|
||||
→ else HtmlString.Empty (production: nothing, as today)
|
||||
→ guest sees the empty annotated container → renders "Add content" → umb:ve:block-add-to-property
|
||||
→ element #onBlockAddToProperty → grid/list-aware value creation (unchanged)
|
||||
```
|
||||
|
||||
## Error handling / edge cases
|
||||
|
||||
- Not in VE/preview, or property not editable, or model non-empty → byte-for-byte the same output as before this change (no behavioural change to production rendering).
|
||||
- Property alias unknown on the model-only overload (metadata not populated, e.g. a model constructed outside the value creators) → no annotation (graceful: treated as "alias unknown", returns empty as today). Not silent in a harmful way — it just falls back to current behaviour.
|
||||
- A custom template that hand-renders blocks without any `GetBlock*HtmlAsync` helper → no affordance. Documented as the one uncovered path (the helper is the documented rendering API).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Backend (unit/integration)**: the block helpers return an annotated container for an empty editable block property when `VisualEditorPropertyTracker.IsEnabled`, and `HtmlString.Empty` when (a) the tracker is disabled, (b) the property is not `EditableInVisualEditor`, or (c) the model is non-empty. Cover all three overloads (content+alias, property, model-only) — the model-only case asserts the `PropertyAlias` metadata path.
|
||||
- **Value-creator test**: the produced block model carries the correct `PropertyAlias` / `EditableInVisualEditor` metadata.
|
||||
- **Frontend/guest**: `npm run build` + `npm run lint` + manual smoke (no VE guest test harness; consistent with the feature's established posture). Manual smoke covers empty list, empty grid, empty single block in the visual editor.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Unifying all property annotation under a single `data-umb-property` mechanism (the deferred Approach 2).
|
||||
- Shipping a default block-list render template (block list intentionally ships none).
|
||||
- Covering hand-rolled block rendering that bypasses the `GetBlock*HtmlAsync` helpers.
|
||||
- Covering the **model-only** helper overload (`GetBlock*HtmlAsync(Model.BlockProperty)`): empty values resolve to the shared `.Empty` singleton (or `null` for single block), which has no per-property identity to annotate. Use the alias-bearing overload (`GetBlock*HtmlAsync(Model, "alias")`) — the documented default — to get the empty-state affordance.
|
||||
|
||||
## Implementation note
|
||||
|
||||
This change **reverts** the prior per-view empty-state commits and the ViewData plumbing in favour of the helper-based mechanism. Those commits remain in history as superseded steps; the revert is part of this work, not a separate cleanup.
|
||||
@@ -0,0 +1,947 @@
|
||||
# Visual Editor — Framework-Emitted Empty-Block Affordance — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the visual-editor empty-block "Add content" affordance fully automatic via the block-rendering helpers, removing the per-view template boilerplate and the ViewData plumbing.
|
||||
|
||||
**Architecture:** The block helpers (`BlockListTemplateExtensions`, `BlockGridTemplateExtensions`, `SingleBlockTemplateExtensions` in `Umbraco.Web.Common`) emit an annotated empty container `<div class="umb-block-{list,grid,single}" data-umb-block-property="{alias}">` themselves — but only from the **alias-bearing overloads** (which carry the alias + `PropertyType.EditableInVisualEditor` even when the value is empty), and only when `VisualEditorPropertyTracker.IsEnabled` and the property is editable-in-VE. A shared `BlockEmptyState` helper DRYs the gating + markup. The default views revert to their plain form, and the ViewData plumbing is deleted. No changes to Core models / value creators / converters.
|
||||
|
||||
**Tech Stack:** C# / ASP.NET Core Razor helpers (`Umbraco.Web.Common`), Razor views (`Umbraco.Web.UI`, embedded `Umbraco.Core`), TypeScript guest (`injected.ts`) + Lit element (backoffice client). Working dir for ALL tasks: `D:/CMS/Umbraco-CMS/.worktrees/feature-visual-editor`.
|
||||
|
||||
**Spec:** `docs/plans/2026-06-12-visual-editor-block-empty-state-design.md`
|
||||
|
||||
**Standing instruction:** the user asked for **no commits yet**. Implement and verify each task; leave changes in the working tree **uncommitted**. The "Commit" steps below are written for completeness but are GATED — do not run them until the user approves committing. Report each task's diff for review instead.
|
||||
|
||||
**Verified facts:**
|
||||
- Empty block values resolve to the shared singletons `BlockListModel.Empty` / `BlockGridModel.Empty`; an empty single block converts to `null`. Hence the alias can only come from the alias-bearing overloads, not the model. (No model/creator/converter changes.)
|
||||
- `IPublishedPropertyType` exposes `string Alias` and `bool EditableInVisualEditor` (default `false`) — `src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs:27,52`.
|
||||
- `VisualEditorPropertyTracker.IsEnabled` is a public static in `Umbraco.Cms.Core.Models.PublishedContent`.
|
||||
- `SingleBlockValue : BlockValue<SingleBlockLayoutItem>` with `PropertyEditorAlias => Constants.PropertyEditors.Aliases.SingleBlock` — so single-block add reuses `addBlockToValue` with the single-block schema alias.
|
||||
- The guest already has empty-container branches for `.umb-block-list` and `.umb-block-grid` reading `dataset.umbBlockProperty`; there is **no** single-block handling.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared empty-state helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs`:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using NUnit.Framework;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockEmptyStateTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Annotated_Container_When_Enabled_And_Editable()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Tracker_Disabled()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Not_Editable()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-grid", "bodyText", editableInVisualEditor: false);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Alias_Missing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", string.Empty, editableInVisualEditor: true);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encodes_Alias_And_Class()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "a\"b", editableInVisualEditor: true);
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Not.Contain("a\"b"));
|
||||
Assert.That(html, Does.Contain("a"b").Or.Contain("a"b"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockEmptyStateTests"`
|
||||
Expected: FAIL (build error — `BlockEmptyState` does not exist).
|
||||
|
||||
- [ ] **Step 3: Implement `BlockEmptyState`**
|
||||
|
||||
Create `src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Produces the annotated empty container the visual editor uses to offer an "add content"
|
||||
/// affordance on an empty, editable block property. Returns empty content outside the visual editor.
|
||||
/// </summary>
|
||||
internal static class BlockEmptyState
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an annotated empty container (<c><div class="{cssClass}" data-umb-block-property="{alias}"></c>)
|
||||
/// when the property is editable in the visual editor and the visual editor is active; otherwise empty content.
|
||||
/// </summary>
|
||||
public static IHtmlContent Container(string cssClass, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (!editableInVisualEditor
|
||||
|| string.IsNullOrEmpty(propertyAlias)
|
||||
|| !VisualEditorPropertyTracker.IsEnabled)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
var encodedClass = HtmlEncoder.Default.Encode(cssClass);
|
||||
var encodedAlias = HtmlEncoder.Default.Encode(propertyAlias);
|
||||
return new HtmlString($"<div class=\"{encodedClass}\" data-umb-block-property=\"{encodedAlias}\"></div>");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockEmptyStateTests"`
|
||||
Expected: PASS (5 passed).
|
||||
|
||||
- [ ] **Step 5: Commit (GATED — only if the user has approved committing)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs
|
||||
git commit -m "feat(visual-editor): shared empty-state container helper for block properties"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Block list helper emits the affordance; remove ViewData plumbing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockListTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns(BlockListModel.Empty);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Property_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockListTemplateExtensionsTests"`
|
||||
Expected: FAIL — the current helper short-circuits empty to `HtmlString.Empty` (no container), so the first test fails.
|
||||
|
||||
- [ ] **Step 3: Rewrite `BlockListTemplateExtensions.cs`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs` with:
|
||||
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
public static class BlockListTemplateExtensions
|
||||
{
|
||||
public const string DefaultFolder = "blocklist/";
|
||||
public const string DefaultTemplate = "default";
|
||||
|
||||
#region Async
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, BlockListModel? model, string template = DefaultTemplate)
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockListHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockListHtmlAsync(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sync
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, BlockListModel? model, string template = DefaultTemplate)
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockListHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockListHtml(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static string DefaultFolderTemplate(string template) => $"{DefaultFolder}{template}";
|
||||
|
||||
private static IPublishedProperty GetRequiredProperty(IPublishedContent contentItem, string propertyAlias)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(propertyAlias);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(propertyAlias))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Value can't be empty or consist only of white-space characters.",
|
||||
nameof(propertyAlias));
|
||||
}
|
||||
|
||||
IPublishedProperty? property = contentItem.GetProperty(propertyAlias);
|
||||
if (property == null)
|
||||
{
|
||||
throw new InvalidOperationException("No property type found with alias " + propertyAlias);
|
||||
}
|
||||
|
||||
return property;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This removes `PropertyAliasViewDataKey`, `WithPropertyAlias`, the `Microsoft.AspNetCore.Mvc.ViewFeatures` using, and the old alias-via-ViewData private overloads.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockListTemplateExtensionsTests"`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Build Web.Common**
|
||||
|
||||
Run: `dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs
|
||||
git commit -m "feat(visual-editor): block list helper emits empty-state affordance, drop ViewData plumbing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Block grid helper emits the affordance; remove ViewData plumbing
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockGridTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var emptyGrid = new BlockGridModel(new List<BlockGridItem>(), null);
|
||||
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns(emptyGrid);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-grid\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Property_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Note: `new BlockGridModel(new List<BlockGridItem>(), null)` is used instead of `BlockGridModel.Empty` because `Empty` has `Count == 0` and either works; the explicit list keeps the test independent of the singleton.)
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockGridTemplateExtensionsTests"`
|
||||
Expected: FAIL (no container emitted by current helper).
|
||||
|
||||
- [ ] **Step 3: Edit `BlockGridTemplateExtensions.cs`**
|
||||
|
||||
In `src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs`:
|
||||
|
||||
(a) Remove the `using Microsoft.AspNetCore.Mvc.ViewFeatures;` line.
|
||||
|
||||
(b) Remove the `PropertyAliasViewDataKey` const + its XML doc (lines 20-24).
|
||||
|
||||
(c) Replace the async property/content overloads + private method (lines 51-66) — change the property overloads to pass the alias **and** editable flag, and rewrite the private method to emit the empty-state:
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockGridHtmlAsync(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockGridHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockGridHtmlAsync(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockGridHtmlAsync(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
(d) Mirror the same change in the sync region (lines 104-118):
|
||||
|
||||
```csharp
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockGridHtml(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockGridHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockGridHtml(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockGridHtml(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
(e) Remove the now-unused `WithPropertyAlias` private method (lines 139-140). Leave `GetBlockGridItemsHtmlAsync`/`GetBlockGridItemAreasHtmlAsync`/etc. and `GetRequiredProperty` unchanged. The model-only `GetBlockGridHtmlAsync(BlockGridModel? model, ...)` overload (lines 41-49) keeps its `model?.Count == 0 → HtmlString.Empty` form unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockGridTemplateExtensionsTests"`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Build Web.Common**
|
||||
|
||||
Run: `dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs
|
||||
git commit -m "feat(visual-editor): block grid helper emits empty-state affordance, drop ViewData plumbing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Single block helper emits the affordance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs`
|
||||
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs`
|
||||
|
||||
The single-block value is a `BlockListItem?`; empty = `null`. The alias-bearing overloads have the property even when the value is null.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs`:
|
||||
|
||||
```csharp
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class SingleBlockTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty NullEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns((object?)null);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Single_Block_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-single-block\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"hero\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Single_Block_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Single_Block_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~SingleBlockTemplateExtensionsTests"`
|
||||
Expected: FAIL (current helper returns `HtmlString.Empty` for null model).
|
||||
|
||||
- [ ] **Step 3: Edit `SingleBlockTemplateExtensions.cs`**
|
||||
|
||||
Change the alias-bearing overloads to pass the alias + editable flag through to a private method that emits the empty-state. The model-only overloads keep their `model is null → HtmlString.Empty` behaviour.
|
||||
|
||||
Replace the async property/content overloads (lines 27-37) with:
|
||||
|
||||
```csharp
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockHtmlAsync(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
Replace the sync property/content overloads (lines 52-62) with:
|
||||
|
||||
```csharp
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockHtml(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
```
|
||||
|
||||
Leave the model-only `GetBlockHtmlAsync(BlockListItem? model, ...)` / `GetBlockHtml(BlockListItem? model, ...)` overloads (lines 17-25, 42-50), `SingleBlockPartialWithFallback`, `DefaultFolderTemplate`, and `GetRequiredProperty` unchanged.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~SingleBlockTemplateExtensionsTests"`
|
||||
Expected: PASS (3 passed).
|
||||
|
||||
- [ ] **Step 5: Build + commit (GATED)**
|
||||
|
||||
```bash
|
||||
dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj
|
||||
git add src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs
|
||||
git commit -m "feat(visual-editor): single block helper emits empty-state affordance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Revert the views to their plain form
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml`
|
||||
- Modify: `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml`
|
||||
- Modify: `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml`
|
||||
|
||||
These no longer carry empty-state logic — the helper handles it. (The `singleblock/default.cshtml` was never modified and stays as-is: the helper now handles the empty/null case before the partial is invoked, so the partial only ever renders a non-null block.)
|
||||
|
||||
- [ ] **Step 1: Revert `blocklist/default.cshtml`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml` with:
|
||||
|
||||
```razor
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockListModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
}
|
||||
<div class="umb-block-list">
|
||||
@foreach (var block in Model)
|
||||
{
|
||||
if (block?.ContentKey == null) { continue; }
|
||||
var data = block.Content;
|
||||
|
||||
<div data-umb-block-key="@block.ContentKey" data-umb-content-type="@data.ContentType.Alias">
|
||||
@await Html.PartialAsync("blocklist/Components/" + data.ContentType.Alias, block)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
```
|
||||
|
||||
(Note: the per-block `data-umb-block-key`/`data-umb-content-type` annotations on populated blocks are retained — they were present before the empty-state work and are needed for selecting existing blocks. Only the empty-state `data-umb-block-property` + ViewData read are removed.)
|
||||
|
||||
- [ ] **Step 2: Revert `blockgrid/default.cshtml`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml` with:
|
||||
|
||||
```razor
|
||||
@using Umbraco.Extensions
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
var gridColumns = Model.GridColumns?.ToString() ?? "12";
|
||||
}
|
||||
|
||||
<div class="umb-block-grid" data-grid-columns="@(gridColumns)" style="--umb-block-grid--grid-columns: @(gridColumns);">
|
||||
@await Html.GetBlockGridItemsHtmlAsync(Model)
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Revert embedded `BlockGrid/default.cshtml`**
|
||||
|
||||
Replace the full contents of `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml` with the identical plain form:
|
||||
|
||||
```razor
|
||||
@using Umbraco.Extensions
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
var gridColumns = Model.GridColumns?.ToString() ?? "12";
|
||||
}
|
||||
|
||||
<div class="umb-block-grid" data-grid-columns="@(gridColumns)" style="--umb-block-grid--grid-columns: @(gridColumns);">
|
||||
@await Html.GetBlockGridItemsHtmlAsync(Model)
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Confirm no remaining references to the removed ViewData keys**
|
||||
|
||||
Run: `grep -rn "PropertyAliasViewDataKey\|umbBlockListPropertyAlias\|umbBlockGridPropertyAlias" src/`
|
||||
Expected: zero hits (the consts were removed in Tasks 2-3 and the views no longer read them).
|
||||
|
||||
- [ ] **Step 5: Build Web.UI (validates compile; Razor is runtime-compiled)**
|
||||
|
||||
Run: `dotnet build src/Umbraco.Web.UI/Umbraco.Web.UI.csproj`
|
||||
Expected: 0 errors. (Stop any running dev instance first to avoid DLL file-locks.)
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml
|
||||
git commit -m "refactor(visual-editor): revert block view empty-state boilerplate (now framework-emitted)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Guest — single-block empty-container branch
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts`
|
||||
|
||||
The guest already handles empty `.umb-block-list` and `.umb-block-grid` containers (unchanged — the helper now emits the same `data-umb-block-property` markup). Add a parallel branch for the single-block container class `umb-single-block`.
|
||||
|
||||
- [ ] **Step 1: Add the single-block empty-container branch**
|
||||
|
||||
In `insertAddButtons()`, immediately after the existing empty `.umb-block-grid` branch (the block that does `document.querySelectorAll<HTMLElement>('.umb-block-grid').forEach(...)`), add:
|
||||
|
||||
```typescript
|
||||
// Empty single block at root level. The container carries data-umb-block-property
|
||||
// (emitted by the single block helper in visual-editor mode).
|
||||
document.querySelectorAll<HTMLElement>('.umb-single-block').forEach((single) => {
|
||||
if (single.querySelector(BLOCK_SELECTOR)) return; // Has a block
|
||||
if (single.querySelector(`[${ADD_BTN_ATTR}]`)) return; // Already handled
|
||||
|
||||
const propertyAlias = single.dataset.umbBlockProperty || '';
|
||||
if (!propertyAlias) return;
|
||||
|
||||
single.appendChild(
|
||||
createEmptyPlaceholder(() => {
|
||||
send({ type: 'umb:ve:block-add-to-property', propertyAlias, insertIndex: 0 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
Also update the file-header doc comment line for `data-umb-block-property` to read: `Property alias on a block list, block grid, or single block container (empty-state block creation)`.
|
||||
|
||||
- [ ] **Step 2: Build the client**
|
||||
|
||||
Run: `cd src/Umbraco.Web.UI.Client && npm run build`
|
||||
Expected: tsc exits 0. (Allow up to 600000ms.)
|
||||
|
||||
- [ ] **Step 3: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts
|
||||
git commit -m "feat(visual-editor): single block empty-state add-content affordance (guest)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Element — single-block-aware add
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/visual-editor/document-workspace-view-visual-editor.element.ts`
|
||||
|
||||
When the guest sends `umb:ve:block-add-to-property` for a single-block property, the element must create a single-block-shaped value (layout key `Umbraco.SingleBlock`). Today `#resolveBlockSchemaAlias` only maps grid vs list; extend it for single block so `addBlockToValue` writes the right layout key.
|
||||
|
||||
- [ ] **Step 1: Confirm the single-block client constants**
|
||||
|
||||
Run: `grep -rn "PROPERTY_EDITOR_SCHEMA_ALIAS\|PROPERTY_EDITOR_UI_ALIAS" src/Umbraco.Web.UI.Client/src/packages/block/block-single/`
|
||||
Expected: find the exported constants for the single block editor — the schema alias (value `Umbraco.SingleBlock`) and the UI alias (value `Umb.PropertyEditorUi.BlockSingle` or similar). Note their exact exported names and the import path (`@umbraco-cms/backoffice/block-single`). If the names differ from those used below, substitute the real names.
|
||||
|
||||
- [ ] **Step 2: Extend `#resolveBlockSchemaAlias`**
|
||||
|
||||
Add the import (next to the existing block-grid import):
|
||||
|
||||
```typescript
|
||||
import {
|
||||
UMB_BLOCK_SINGLE_PROPERTY_EDITOR_SCHEMA_ALIAS,
|
||||
UMB_BLOCK_SINGLE_PROPERTY_EDITOR_UI_ALIAS,
|
||||
} from '@umbraco-cms/backoffice/block-single';
|
||||
```
|
||||
|
||||
Replace `#resolveBlockSchemaAlias` with:
|
||||
|
||||
```typescript
|
||||
#resolveBlockSchemaAlias(propertyAlias: string): string {
|
||||
const editorUiAlias = this.#structures.getDocumentProperty(propertyAlias)?.editorUiAlias ?? '';
|
||||
if (editorUiAlias === UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS) {
|
||||
return UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
}
|
||||
if (editorUiAlias === UMB_BLOCK_SINGLE_PROPERTY_EDITOR_UI_ALIAS) {
|
||||
return UMB_BLOCK_SINGLE_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
}
|
||||
return UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
}
|
||||
```
|
||||
|
||||
(`addBlockToValue` keys its grid-specific layout logic on `UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS`; for the single-block alias it falls through to the plain list-shaped layout item, which matches `SingleBlockValue`'s `BlockValue<SingleBlockLayoutItem>` structure — one block under the `Umbraco.SingleBlock` layout key, no columnSpan/rowSpan.)
|
||||
|
||||
- [ ] **Step 3: Build the client**
|
||||
|
||||
Run: `cd src/Umbraco.Web.UI.Client && npm run build`
|
||||
Expected: tsc exits 0. (Allow up to 600000ms.) If the single-block constant names differ, fix the import to the real names found in Step 1.
|
||||
|
||||
- [ ] **Step 4: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/visual-editor/document-workspace-view-visual-editor.element.ts
|
||||
git commit -m "feat(visual-editor): single-block-aware add for empty single block properties"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification + mark spec implemented
|
||||
|
||||
- [ ] **Step 1: Unit tests**
|
||||
|
||||
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~TemplateExtensionsTests|FullyQualifiedName~BlockEmptyStateTests"`
|
||||
Expected: all helper + empty-state tests pass.
|
||||
|
||||
- [ ] **Step 2: Full client build + lint**
|
||||
|
||||
```bash
|
||||
cd src/Umbraco.Web.UI.Client
|
||||
npm run build
|
||||
npm run lint
|
||||
```
|
||||
Expected: build exits 0; lint reports no NEW errors in the visual-editor files (the `umb:ve:*` keys are already lint-exempt).
|
||||
|
||||
- [ ] **Step 3: Full solution build**
|
||||
|
||||
Run: `dotnet build umbraco.sln`
|
||||
Expected: 0 errors (pre-existing StyleCop warnings out of scope).
|
||||
|
||||
- [ ] **Step 4: Manual smoke** (run the site, backoffice at https://localhost:44339/umbraco)
|
||||
|
||||
1. Empty editable block **list** property → preview shows the annotated empty container with an "Add content" button; clicking it adds a block.
|
||||
2. Empty editable block **grid** property (e.g. Blogpost `bodyText`) → same.
|
||||
3. Empty editable **single block** property → same; clicking adds exactly one block.
|
||||
4. A **non-editable** empty block property → renders nothing, no affordance.
|
||||
5. A custom template that renders a block property via `@Html.GetBlock*HtmlAsync(Model, "alias")` → affordance appears with **no template code** for the empty state.
|
||||
6. Non-empty block properties render unchanged.
|
||||
|
||||
- [ ] **Step 5: Update the design doc status**
|
||||
|
||||
In `docs/plans/2026-06-12-visual-editor-block-empty-state-design.md` replace:
|
||||
|
||||
```markdown
|
||||
**Status**: Approved design, pending implementation plan
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
**Status**: Implemented
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit (GATED)**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-06-12-visual-editor-block-empty-state-design.md
|
||||
git commit -m "docs(visual-editor): mark framework-emitted empty-block affordance implemented"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Component 1 (helper emits container) → Tasks 2-4 + the `BlockEmptyState` helper (Task 1); Component 2 (alias-bearing overloads only, no model metadata) → Tasks 2-4 pass alias + `EditableInVisualEditor` from the property; Component 3 (single block) → Tasks 4, 6, 7; Component 4 (guest/element) → Tasks 6-7; Component 5 (revert views) → Task 5; Testing → unit tests in Tasks 1-4 + manual in Task 8.
|
||||
- **Verify-at-execution (not placeholders):** the single-block client constant names (Task 7 Step 1) — exact exported names confirmed by grep before use.
|
||||
- **No model/creator/converter changes** — consistent with the singleton finding.
|
||||
- **Commits are GATED** per the user's "no commits yet" instruction — execute and review; commit only on approval.
|
||||
File diff suppressed because it is too large
Load Diff
+53
@@ -0,0 +1,53 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.VisualEditor;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document's template with the visual editor's unsaved values for live preview.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class RenderVisualEditorController : VisualEditorControllerBase
|
||||
{
|
||||
private readonly IVisualEditorRenderService _renderService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RenderVisualEditorController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="renderService">The <see cref="IVisualEditorRenderService"/> used to render document templates with visual editor overrides.</param>
|
||||
public RenderVisualEditorController(IVisualEditorRenderService renderService)
|
||||
=> _renderService = renderService;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document's template with the unsaved property values supplied by the visual editor.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="requestModel">The model containing the document key, culture, segment, and property value overrides to render.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> containing a <see cref="VisualEditorRenderResponseModel"/> with the rendered HTML on success.
|
||||
/// </returns>
|
||||
[HttpPost("render")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(VisualEditorRenderResponseModel), StatusCodes.Status200OK)]
|
||||
[EndpointSummary("Renders a document with unsaved visual editor values.")]
|
||||
public async Task<IActionResult> Render(
|
||||
CancellationToken cancellationToken,
|
||||
VisualEditorRenderRequestModel requestModel)
|
||||
{
|
||||
var overrides = requestModel.Values
|
||||
.Select(v => new VisualEditorPropertyOverride(v.Alias, v.Value, v.Culture, v.Segment))
|
||||
.ToList();
|
||||
|
||||
var html = await _renderService.RenderAsync(
|
||||
requestModel.Unique,
|
||||
requestModel.Culture,
|
||||
requestModel.Segment,
|
||||
overrides);
|
||||
|
||||
return Ok(new VisualEditorRenderResponseModel { Html = html });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.VisualEditor;
|
||||
|
||||
/// <summary>
|
||||
/// Base controller for visual editor management API endpoints.
|
||||
/// </summary>
|
||||
[VersionedApiBackOfficeRoute("visual-editor")]
|
||||
[ApiExplorerSettings(GroupName = "Visual Editor")]
|
||||
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
|
||||
public abstract class VisualEditorControllerBase : ManagementApiControllerBase
|
||||
{
|
||||
}
|
||||
@@ -118,7 +118,7 @@ internal abstract class ContentTypeEditingPresentationFactory<TContentType>
|
||||
{
|
||||
Alias = property.Alias,
|
||||
Appearance =
|
||||
new ContentTypeEditingModels.PropertyTypeAppearance { LabelOnTop = property.Appearance.LabelOnTop },
|
||||
new ContentTypeEditingModels.PropertyTypeAppearance { LabelOnTop = property.Appearance.LabelOnTop, EditableInVisualEditor = property.Appearance.EditableInVisualEditor },
|
||||
Name = property.Name,
|
||||
Validation = new ContentTypeEditingModels.PropertyTypeValidation
|
||||
{
|
||||
|
||||
@@ -49,7 +49,8 @@ public abstract class ContentTypeMapDefinition<TContentType, TPropertyTypeModel,
|
||||
},
|
||||
Appearance = new PropertyTypeAppearance
|
||||
{
|
||||
LabelOnTop = propertyType.LabelOnTop
|
||||
LabelOnTop = propertyType.LabelOnTop,
|
||||
EditableInVisualEditor = propertyType.EditableInVisualEditor,
|
||||
}
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
+141
-1
@@ -38519,6 +38519,76 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/visual-editor/render": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Visual Editor"
|
||||
],
|
||||
"summary": "Renders a document with unsaved visual editor values.",
|
||||
"operationId": "PostVisualEditorRender",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VisualEditorRenderRequestModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VisualEditorRenderResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/item/webhook": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -49208,12 +49278,16 @@
|
||||
},
|
||||
"PropertyTypeAppearanceModel": {
|
||||
"required": [
|
||||
"labelOnTop"
|
||||
"labelOnTop",
|
||||
"editableInVisualEditor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"labelOnTop": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"editableInVisualEditor": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -53134,6 +53208,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisualEditorPropertyValueModel": {
|
||||
"required": [
|
||||
"alias"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {},
|
||||
"culture": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"segment": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisualEditorRenderRequestModel": {
|
||||
"required": [
|
||||
"unique",
|
||||
"values"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unique": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"culture": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"segment": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/VisualEditorPropertyValueModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisualEditorRenderResponseModel": {
|
||||
"required": [
|
||||
"html"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"html": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebhookEventModel": {
|
||||
"required": [
|
||||
"eventName",
|
||||
|
||||
@@ -73,6 +73,6 @@ public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes
|
||||
Controller = ControllerExtensions.GetControllerName<BackOfficeDefaultController>(),
|
||||
Action = nameof(BackOfficeDefaultController.Index),
|
||||
},
|
||||
constraints: new { slug = @"^(section|preview|upgrade|install|oauth_complete|logout|error).*$" });
|
||||
constraints: new { slug = @"^(section|preview|visual-editor|upgrade|install|oauth_complete|logout|error).*$" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
|
||||
/// Gets or sets a value indicating whether the label for the property type is displayed above the input.
|
||||
/// </summary>
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
|
||||
|
||||
/// <summary>
|
||||
/// A single unsaved property value submitted for a visual editor preview render.
|
||||
/// </summary>
|
||||
public class VisualEditorPropertyValueModel
|
||||
{
|
||||
/// <summary>Gets or sets the property alias.</summary>
|
||||
public required string Alias { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the editor-format value (raw string for simple editors, JSON for complex editors).</summary>
|
||||
public object? Value { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the culture this value applies to, or <c>null</c> for invariant.</summary>
|
||||
public string? Culture { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the segment this value applies to, or <c>null</c> for none.</summary>
|
||||
public string? Segment { get; set; }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
|
||||
|
||||
/// <summary>
|
||||
/// Request to render a document's template with unsaved visual editor values overlaid.
|
||||
/// </summary>
|
||||
public class VisualEditorRenderRequestModel
|
||||
{
|
||||
/// <summary>Gets or sets the document key to render.</summary>
|
||||
public Guid Unique { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the culture to render, or <c>null</c> for the default/invariant.</summary>
|
||||
public string? Culture { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the segment to render, or <c>null</c> for none.</summary>
|
||||
public string? Segment { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the unsaved property values to overlay onto the draft content.</summary>
|
||||
public IEnumerable<VisualEditorPropertyValueModel> Values { get; set; } = [];
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
|
||||
|
||||
/// <summary>
|
||||
/// The rendered HTML for a visual editor preview render request.
|
||||
/// </summary>
|
||||
public class VisualEditorRenderResponseModel
|
||||
{
|
||||
/// <summary>Gets or sets the rendered page HTML.</summary>
|
||||
public required string Html { get; set; }
|
||||
}
|
||||
@@ -316,6 +316,8 @@ public static class PublishedContentExtensions
|
||||
{
|
||||
IPublishedProperty? property = content.GetProperty(alias);
|
||||
|
||||
TrackVisualEditorAccess(property, alias, content.Key);
|
||||
|
||||
// if we have a property, and it has a value, return that value
|
||||
if (property != null && property.HasValue(culture, segment))
|
||||
{
|
||||
@@ -356,6 +358,8 @@ public static class PublishedContentExtensions
|
||||
{
|
||||
IPublishedProperty? property = content.GetProperty(alias);
|
||||
|
||||
TrackVisualEditorAccess(property, alias, content.Key);
|
||||
|
||||
// if we have a property, and it has a value, return that value
|
||||
if (property != null && property.HasValue(culture, segment))
|
||||
{
|
||||
@@ -373,6 +377,23 @@ public static class PublishedContentExtensions
|
||||
return property == null ? default : property.Value<T>(publishedValueFallback, culture, segment, fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a visual editor property access for property types
|
||||
/// that have been marked as editable in the visual editor.
|
||||
/// </summary>
|
||||
private static void TrackVisualEditorAccess(IPublishedProperty? property, string alias, Guid contentKey)
|
||||
{
|
||||
if (property is null || !VisualEditorPropertyTracker.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType.EditableInVisualEditor)
|
||||
{
|
||||
VisualEditorPropertyTracker.RecordAccess(alias, contentKey);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsSomething: misc.
|
||||
|
||||
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
|
||||
/// Gets or sets a value indicating whether the label should be displayed above the property editor.
|
||||
/// </summary>
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ public interface IPropertyType : IEntity, IRememberBeingDirty
|
||||
/// </summary>
|
||||
bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
bool EditableInVisualEditor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets of sets the sort order of the property type.
|
||||
/// </summary>
|
||||
|
||||
@@ -21,6 +21,7 @@ public class PropertyType : EntityBase, IPropertyType, IEquatable<PropertyType>
|
||||
private Guid _dataTypeKey;
|
||||
private string? _description;
|
||||
private bool _labelOnTop;
|
||||
private bool _editableInVisualEditor;
|
||||
private bool _mandatory;
|
||||
private string? _mandatoryMessage;
|
||||
private string _name;
|
||||
@@ -225,6 +226,14 @@ public class PropertyType : EntityBase, IPropertyType, IEquatable<PropertyType>
|
||||
set => SetPropertyValueAndDetectChanges(value, ref _labelOnTop, nameof(LabelOnTop));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[DataMember]
|
||||
public bool EditableInVisualEditor
|
||||
{
|
||||
get => _editableInVisualEditor;
|
||||
set => SetPropertyValueAndDetectChanges(value, ref _editableInVisualEditor, nameof(EditableInVisualEditor));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[DataMember]
|
||||
public int SortOrder
|
||||
|
||||
@@ -46,6 +46,11 @@ public interface IPublishedPropertyType
|
||||
/// </remarks>
|
||||
bool IsUserProperty { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
bool EditableInVisualEditor => false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content variations of the property type.
|
||||
/// </summary>
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent
|
||||
: this(propertyType.Alias, propertyType.DataTypeId, true, propertyType.Variations, propertyValueConverters, publishedModelFactory, factory)
|
||||
{
|
||||
ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType));
|
||||
EditableInVisualEditor = propertyType.EditableInVisualEditor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,6 +95,9 @@ namespace Umbraco.Cms.Core.Models.PublishedContent
|
||||
/// <inheritdoc />
|
||||
public bool IsUserProperty { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool EditableInVisualEditor { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ContentVariation Variations { get; }
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks property accesses during Razor rendering so that the visual editor
|
||||
/// can automatically wrap property output with annotation attributes.
|
||||
///
|
||||
/// <para>
|
||||
/// When <c>@Model.Title</c> or <c>@Model.Value("title")</c> is evaluated in a Razor view,
|
||||
/// the <c>Value()</c> extension method records the property alias and content key here.
|
||||
/// When Razor subsequently calls <c>Write()</c>, the recorded access is consumed and the output
|
||||
/// is wrapped with <c>data-umb-property</c> attributes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class VisualEditorPropertyTracker
|
||||
{
|
||||
private static readonly AsyncLocal<PropertyAccess?> _lastAccess = new();
|
||||
private static readonly AsyncLocal<bool> _enabled = new();
|
||||
|
||||
/// <summary>
|
||||
/// Enables tracking for the current async context.
|
||||
/// Should be called when the request is in visual edit / preview mode.
|
||||
/// </summary>
|
||||
public static void Enable() => _enabled.Value = true;
|
||||
|
||||
/// <summary>
|
||||
/// Disables tracking for the current async context. Pair with <see cref="Enable"/> in a finally block.
|
||||
/// </summary>
|
||||
public static void Disable() => _enabled.Value = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether tracking is currently enabled for this async context.
|
||||
/// </summary>
|
||||
public static bool IsEnabled => _enabled.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Records a property access. Called from <c>Value()</c> / <c>Value<T>()</c> extension methods.
|
||||
/// </summary>
|
||||
public static void RecordAccess(string alias, Guid contentKey)
|
||||
{
|
||||
if (_enabled.Value)
|
||||
{
|
||||
_lastAccess.Value = new PropertyAccess(alias, contentKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Consumes the last recorded access, returning it and clearing the state.
|
||||
/// </summary>
|
||||
public static PropertyAccess? ConsumeAccess()
|
||||
{
|
||||
PropertyAccess? access = _lastAccess.Value;
|
||||
_lastAccess.Value = null;
|
||||
return access;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears any pending recorded access without consuming it.
|
||||
/// </summary>
|
||||
public static void Clear()
|
||||
=> _lastAccess.Value = null;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recorded property access.
|
||||
/// </summary>
|
||||
public readonly record struct PropertyAccess(string Alias, Guid ContentKey);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="IPublishedContent"/> for the visual editor preview: the requested document's
|
||||
/// draft content with a set of unsaved property values overlaid on top, converted to their published form.
|
||||
/// </summary>
|
||||
public interface IVisualEditorContentFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the draft content for <paramref name="documentKey"/> and returns a preview
|
||||
/// <see cref="IPublishedContent"/> whose overridden aliases yield the converted unsaved values.
|
||||
/// Returns <c>null</c> if the document does not exist.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The key of the document whose draft content will be used as the base.</param>
|
||||
/// <param name="overrides">The unsaved property values to overlay on top of the draft content.</param>
|
||||
/// <returns>
|
||||
/// A preview <see cref="IPublishedContent"/> with the overrides applied,
|
||||
/// or <c>null</c> if the document cannot be resolved.
|
||||
/// </returns>
|
||||
Task<IPublishedContent?> CreateWithOverridesAsync(
|
||||
Guid documentKey,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// A single unsaved property value to overlay onto draft content when rendering the visual editor preview.
|
||||
/// </summary>
|
||||
/// <param name="Alias">The property alias to override.</param>
|
||||
/// <param name="EditorValue">
|
||||
/// The editor-format value as held by the backoffice workspace. Complex editors (rich text, block list)
|
||||
/// expect their serialized JSON; plain editors (e.g. text box) expect the raw value.
|
||||
/// </param>
|
||||
/// <param name="Culture">The culture the override applies to, or <c>null</c> for invariant.</param>
|
||||
/// <param name="Segment">The segment the override applies to, or <c>null</c> for none.</param>
|
||||
public readonly record struct VisualEditorPropertyOverride(string Alias, object? EditorValue, string? Culture, string? Segment);
|
||||
@@ -821,6 +821,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
propertyType.Description = property.Description;
|
||||
propertyType.SortOrder = property.SortOrder;
|
||||
propertyType.LabelOnTop = property.Appearance.LabelOnTop;
|
||||
propertyType.EditableInVisualEditor = property.Appearance.EditableInVisualEditor;
|
||||
|
||||
propertyType.PropertyGroupId = propertyGroup is null
|
||||
? null
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
namespace Umbraco.Cms.Core.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document's assigned template to an HTML string using the visual editor's unsaved values,
|
||||
/// with property-access tracking enabled so the output carries <c>data-umb-*</c> annotations.
|
||||
/// </summary>
|
||||
public interface IVisualEditorRenderService
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the document identified by <paramref name="documentKey"/> with the supplied unsaved
|
||||
/// <paramref name="overrides"/> overlaid. Returns the rendered HTML, or an empty string if the
|
||||
/// document or its template cannot be resolved.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The key of the document to render.</param>
|
||||
/// <param name="culture">The culture to render, or <c>null</c> for the default/invariant.</param>
|
||||
/// <param name="segment">The segment to render, or <c>null</c> for none.</param>
|
||||
/// <param name="overrides">The unsaved editor values to overlay onto the draft content.</param>
|
||||
/// <returns>The rendered page HTML, or an empty string if the document or template is unavailable.</returns>
|
||||
Task<string> RenderAsync(
|
||||
Guid documentKey,
|
||||
string? culture,
|
||||
string? segment,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
|
||||
}
|
||||
@@ -94,6 +94,7 @@ public partial class UmbracoPlan : MigrationPlan
|
||||
To<V_17_3_0.PopulateSortableValueForDatePropertyData>("{6748CB56-CC16-49F0-BA91-B8ECE31BF456}");
|
||||
|
||||
// To 17.4.0
|
||||
To<V_17_4_0.AddEditableInVisualEditorToPropertyType>("{C3D4E5F6-A7B8-49C0-D1E2-F3A4B5C6D7E8}");
|
||||
To<V_17_4_0.AddContentVersionDateIndex>("{D4E5F6A7-B8C9-4D0E-A1F2-3B4C5D6E7F80}");
|
||||
To<V_17_4_0.AddDimensionsToSvg>("{72970B86-59D8-403C-B322-FFF43F9DB199}");
|
||||
To<V_17_4_0.AddExternalMemberTables>("{D7E8F9A0-B1C2-4D3E-A5F6-7890ABCDEF12}");
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_4_0;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to add the editableInVisualEditor column to the cmsPropertyType table.
|
||||
/// </summary>
|
||||
public class AddEditableInVisualEditorToPropertyType : AsyncMigrationBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AddEditableInVisualEditorToPropertyType"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The migration context.</param>
|
||||
public AddEditableInVisualEditorToPropertyType(IMigrationContext context)
|
||||
: base(context)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task MigrateAsync()
|
||||
{
|
||||
if (TableExists(Constants.DatabaseSchema.Tables.PropertyType) is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string columnName = "editableInVisualEditor";
|
||||
var hasColumn = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database)
|
||||
.Any(c =>
|
||||
c.TableName == Constants.DatabaseSchema.Tables.PropertyType &&
|
||||
c.ColumnName == columnName);
|
||||
|
||||
if (hasColumn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddColumn<PropertyTypeDto>(Constants.DatabaseSchema.Tables.PropertyType, columnName);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,13 @@ internal class PropertyTypeDto
|
||||
[Constraint(Default = "0")]
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
[Column("editableInVisualEditor")]
|
||||
[Constraint(Default = "0")]
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the variation flags for the property type, indicating whether the property supports culture, segment, or invariant variations.
|
||||
/// The value corresponds to the <c>ContentVariation</c> enum.
|
||||
|
||||
@@ -90,6 +90,12 @@ internal sealed class PropertyTypeReadOnlyDto
|
||||
[Column("labelOnTop")]
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
[Column("editableInVisualEditor")]
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
|
||||
/* cmsMemberType */
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -48,6 +48,7 @@ internal static class PropertyGroupFactory
|
||||
UniqueId = propertyType.Key,
|
||||
Variations = (byte)propertyType.Variations,
|
||||
LabelOnTop = propertyType.LabelOnTop,
|
||||
EditableInVisualEditor = propertyType.EditableInVisualEditor,
|
||||
};
|
||||
|
||||
if (groupId != default)
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class PropertyTypeMapper : BaseMapper
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.ValidationRegExp), nameof(PropertyTypeDto.ValidationRegExp));
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.ValidationRegExpMessage), nameof(PropertyTypeDto.ValidationRegExpMessage));
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.LabelOnTop), nameof(PropertyTypeDto.LabelOnTop));
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.EditableInVisualEditor), nameof(PropertyTypeDto.EditableInVisualEditor));
|
||||
DefineMap<PropertyType, DataTypeDto>(nameof(PropertyType.PropertyEditorAlias), nameof(DataTypeDto.EditorAlias));
|
||||
DefineMap<PropertyType, DataTypeDto>(nameof(PropertyType.ValueStorageType), nameof(DataTypeDto.DbType));
|
||||
}
|
||||
|
||||
+1
@@ -440,6 +440,7 @@ internal sealed class ContentTypeCommonRepository : IContentTypeCommonRepository
|
||||
ValidationRegExpMessage = dto.ValidationRegExpMessage,
|
||||
Variations = (ContentVariation)dto.Variations,
|
||||
LabelOnTop = dto.LabelOnTop,
|
||||
EditableInVisualEditor = dto.EditableInVisualEditor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ public static class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
|
||||
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
|
||||
builder.Services.AddSingleton<ICacheNodeFactory, CacheNodeFactory>();
|
||||
builder.Services.AddSingleton<IVisualEditorContentFactory, VisualEditorContentFactory>();
|
||||
builder.Services.AddSingleton<ICacheManager, CacheManager>();
|
||||
builder.Services.AddSingleton<IDatabaseCacheRebuilder, DatabaseCacheRebuilder>();
|
||||
builder.Services.AddSingleton<IDeferredCacheRebuildService, DeferredCacheRebuildService>();
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
internal sealed class VisualEditorContentFactory : IVisualEditorContentFactory
|
||||
{
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
private readonly IContentService _contentService;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly ICacheNodeFactory _cacheNodeFactory;
|
||||
private readonly IPublishedContentFactory _publishedContentFactory;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IPublishedModelFactory _publishedModelFactory;
|
||||
|
||||
public VisualEditorContentFactory(
|
||||
IIdKeyMap idKeyMap,
|
||||
IContentService contentService,
|
||||
IDataTypeService dataTypeService,
|
||||
ICacheNodeFactory cacheNodeFactory,
|
||||
IPublishedContentFactory publishedContentFactory,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IPublishedModelFactory publishedModelFactory)
|
||||
{
|
||||
_idKeyMap = idKeyMap;
|
||||
_contentService = contentService;
|
||||
_dataTypeService = dataTypeService;
|
||||
_cacheNodeFactory = cacheNodeFactory;
|
||||
_publishedContentFactory = publishedContentFactory;
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_publishedModelFactory = publishedModelFactory;
|
||||
}
|
||||
|
||||
public async Task<IPublishedContent?> CreateWithOverridesAsync(
|
||||
Guid documentKey,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides)
|
||||
{
|
||||
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(documentKey, UmbracoObjectTypes.Document);
|
||||
if (idAttempt.Success is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IContent? content = _contentService.GetById(idAttempt.Result);
|
||||
if (content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ContentCacheNode baseNode = _cacheNodeFactory.ToContentCacheNode(content, preview: true);
|
||||
if (baseNode.Data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, PropertyData[]>(baseNode.Data.Properties);
|
||||
|
||||
foreach (VisualEditorPropertyOverride @override in overrides)
|
||||
{
|
||||
(IDataValueEditor valueEditor, object? configuration)? resolved = await ResolveEditorAsync(content, @override);
|
||||
if (resolved is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var editorValue = @override.EditorValue is string s ? s : _jsonSerializer.Serialize(@override.EditorValue);
|
||||
object? source = resolved.Value.valueEditor.FromEditor(
|
||||
new ContentPropertyData(editorValue, resolved.Value.configuration),
|
||||
null);
|
||||
|
||||
string overrideCulture = @override.Culture ?? string.Empty;
|
||||
string overrideSegment = @override.Segment ?? string.Empty;
|
||||
|
||||
var overrideEntry = new PropertyData
|
||||
{
|
||||
Culture = overrideCulture,
|
||||
Segment = overrideSegment,
|
||||
Value = source,
|
||||
};
|
||||
|
||||
if (properties.TryGetValue(@override.Alias, out PropertyData[]? existing))
|
||||
{
|
||||
PropertyData[] merged = existing
|
||||
.Where(p => !(p.Culture == overrideCulture && p.Segment == overrideSegment))
|
||||
.Append(overrideEntry)
|
||||
.ToArray();
|
||||
properties[@override.Alias] = merged;
|
||||
}
|
||||
else
|
||||
{
|
||||
properties[@override.Alias] = [overrideEntry];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS0618 // ContentData ctor obsolete usage mirrored from the cache node source
|
||||
var overriddenNode = new ContentCacheNode
|
||||
{
|
||||
Id = baseNode.Id,
|
||||
Key = baseNode.Key,
|
||||
SortOrder = baseNode.SortOrder,
|
||||
CreateDate = baseNode.CreateDate,
|
||||
CreatorId = baseNode.CreatorId,
|
||||
ContentTypeId = baseNode.ContentTypeId,
|
||||
IsDraft = true,
|
||||
Data = new ContentData(
|
||||
name: baseNode.Data.Name,
|
||||
urlSegment: baseNode.Data.UrlSegment,
|
||||
versionId: baseNode.Data.VersionId,
|
||||
versionDate: baseNode.Data.VersionDate,
|
||||
writerId: baseNode.Data.WriterId,
|
||||
templateId: baseNode.Data.TemplateId,
|
||||
published: baseNode.Data.Published,
|
||||
properties: properties,
|
||||
cultureInfos: baseNode.Data.CultureInfos),
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
return _publishedContentFactory.ToIPublishedContent(overriddenNode, preview: true).CreateModel(_publishedModelFactory);
|
||||
}
|
||||
|
||||
private async Task<(IDataValueEditor, object? configuration)?> ResolveEditorAsync(IContent content, VisualEditorPropertyOverride @override)
|
||||
{
|
||||
IProperty? property = content.Properties[@override.Alias];
|
||||
if (property is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IDataType? dataType = await _dataTypeService.GetAsync(property.PropertyType.DataTypeKey);
|
||||
if (dataType?.Editor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (dataType.Editor.GetValueEditor(), dataType.ConfigurationObject);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection.Infrastructure;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
@@ -55,6 +56,7 @@ using Umbraco.Cms.Web.Common.Preview;
|
||||
using Umbraco.Cms.Web.Common.Profiler;
|
||||
using Umbraco.Cms.Web.Common.Repositories;
|
||||
using Umbraco.Cms.Web.Common.Security;
|
||||
using Umbraco.Cms.Web.Common.TagHelpers;
|
||||
using Umbraco.Cms.Web.Common.Templates;
|
||||
using Umbraco.Cms.Web.Common.UmbracoContext;
|
||||
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
|
||||
@@ -335,10 +337,13 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<BootFailedMiddleware>();
|
||||
builder.Services.AddSingleton<ProtectRecycleBinMediaMiddleware>();
|
||||
|
||||
builder.Services.AddScoped<ITagHelperComponent, VisualEditorScriptTagHelperComponent>();
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<UmbracoReadinessHealthCheck>("umbraco-ready", tags: [UmbracoReadinessHealthCheck.ReadyTag]);
|
||||
|
||||
builder.Services.AddUnique<ITemplateRenderer, TemplateRenderer>();
|
||||
builder.Services.AddTransient<IVisualEditorRenderService, VisualEditorRenderService>();
|
||||
builder.Services.AddUnique<IPublicAccessChecker, PublicAccessChecker>();
|
||||
|
||||
builder.Services.AddSingleton<ContentModelBinder>();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Produces the annotated empty container the visual editor uses to offer an "add content"
|
||||
/// affordance on an empty, editable block property. Returns empty content outside the visual editor.
|
||||
/// </summary>
|
||||
internal static class BlockEmptyState
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an annotated empty container (<c><div class="{cssClass}" data-umb-block-property="{alias}"></c>)
|
||||
/// when the property is editable in the visual editor and the visual editor is active; otherwise empty content.
|
||||
/// </summary>
|
||||
public static IHtmlContent Container(string cssClass, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (!editableInVisualEditor
|
||||
|| string.IsNullOrEmpty(propertyAlias)
|
||||
|| !VisualEditorPropertyTracker.IsEnabled)
|
||||
{
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
var encodedClass = HtmlEncoder.Default.Encode(cssClass);
|
||||
var encodedAlias = HtmlEncoder.Default.Encode(propertyAlias);
|
||||
return new HtmlString($"<div class=\"{encodedClass}\" data-umb-block-property=\"{encodedAlias}\"></div>");
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public static class BlockGridTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
@@ -43,7 +43,7 @@ public static class BlockGridTemplateExtensions
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockGridHtmlAsync(html, property.GetValue() as BlockGridModel, template);
|
||||
=> await GetBlockGridHtmlAsync(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
@@ -52,7 +52,17 @@ public static class BlockGridTemplateExtensions
|
||||
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockGridHtmlAsync(html, prop.GetValue() as BlockGridModel, template);
|
||||
return await GetBlockGridHtmlAsync(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockGridHtmlAsync(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockGridItemsHtmlAsync(this IHtmlHelper html, IEnumerable<BlockGridItem> items, string template = DefaultItemsTemplate)
|
||||
@@ -84,7 +94,7 @@ public static class BlockGridTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
@@ -92,7 +102,7 @@ public static class BlockGridTemplateExtensions
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockGridHtml(html, property.GetValue() as BlockGridModel, template);
|
||||
=> GetBlockGridHtml(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
@@ -101,7 +111,17 @@ public static class BlockGridTemplateExtensions
|
||||
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockGridHtml(html, prop.GetValue() as BlockGridModel, template);
|
||||
return GetBlockGridHtml(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockGridHtml(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockGridItemsHtml(this IHtmlHelper html, IEnumerable<BlockGridItem> items, string template = DefaultItemsTemplate)
|
||||
|
||||
@@ -16,14 +16,14 @@ public static class BlockListTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template);
|
||||
=> await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockListHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
@@ -31,8 +31,19 @@ public static class BlockListTemplateExtensions
|
||||
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template);
|
||||
return await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockListHtmlAsync(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sync
|
||||
@@ -41,14 +52,14 @@ public static class BlockListTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockListHtml(html, property.GetValue() as BlockListModel, template);
|
||||
=> GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockListHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
@@ -56,7 +67,17 @@ public static class BlockListTemplateExtensions
|
||||
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockListHtml(html, property.GetValue() as BlockListModel, template);
|
||||
return GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockListHtml(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null || model.Count == 0)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -25,7 +25,7 @@ public static class SingleBlockTemplateExtensions
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template);
|
||||
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> await GetBlockHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
@@ -33,8 +33,19 @@ public static class SingleBlockTemplateExtensions
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template);
|
||||
return await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static async Task<IHtmlContent> GetBlockHtmlAsync(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sync
|
||||
@@ -50,7 +61,7 @@ public static class SingleBlockTemplateExtensions
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template);
|
||||
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
|
||||
=> GetBlockHtml(html, contentItem, propertyAlias, DefaultTemplate);
|
||||
@@ -58,7 +69,17 @@ public static class SingleBlockTemplateExtensions
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
|
||||
{
|
||||
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
|
||||
return GetBlockHtml(html, property.GetValue() as BlockListItem, template);
|
||||
return GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
|
||||
}
|
||||
|
||||
private static IHtmlContent GetBlockHtml(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
|
||||
{
|
||||
if (model is null)
|
||||
{
|
||||
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
}
|
||||
|
||||
public static string SingleBlockPartialWithFallback(this IHtmlHelper html, string template, string fallbackTemplate)
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Preview;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -55,6 +56,10 @@ public class PreviewAuthenticationMiddleware : IMiddleware
|
||||
|
||||
if (isPreview)
|
||||
{
|
||||
// Enable visual editor property tracking for this request.
|
||||
// This allows UmbracoViewPage.Write() to automatically annotate property output.
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
|
||||
Attempt<ClaimsIdentity> backOfficeIdentityAttempt = await _previewService.TryGetPreviewClaimsIdentityAsync();
|
||||
|
||||
if (backOfficeIdentityAttempt.Success)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Cms.Web.Common.Hosting;
|
||||
using Umbraco.Cms.Web.Common.Views;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.TagHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Injects the visual editor guest script into the <c><body></c> tag when in preview mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses the standard ASP.NET Core <see cref="ITagHelperComponent"/> extensibility point to append
|
||||
/// the guest script to the body tag. The script runs inside the preview iframe and communicates
|
||||
/// with the backoffice via postMessage.
|
||||
/// </remarks>
|
||||
internal sealed class VisualEditorScriptTagHelperComponent : TagHelperComponent
|
||||
{
|
||||
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
|
||||
private readonly ICspNonceService _cspNonceService;
|
||||
private readonly IBackOfficePathGenerator _backOfficePathGenerator;
|
||||
|
||||
public VisualEditorScriptTagHelperComponent(
|
||||
IUmbracoContextAccessor umbracoContextAccessor,
|
||||
ICspNonceService cspNonceService,
|
||||
IBackOfficePathGenerator backOfficePathGenerator)
|
||||
{
|
||||
_umbracoContextAccessor = umbracoContextAccessor;
|
||||
_cspNonceService = cspNonceService;
|
||||
_backOfficePathGenerator = backOfficePathGenerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run after other tag helper components (e.g. the preview badge).
|
||||
/// </summary>
|
||||
public override int Order => 100;
|
||||
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
if (!string.Equals(output.TagName, "body", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)
|
||||
|| !umbracoContext.InPreviewMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nonce = _cspNonceService.GetNonce();
|
||||
var backOfficeAssetsPath = _backOfficePathGenerator.BackOfficeAssetsPath;
|
||||
output.PostContent.AppendHtml(VisualEditorGuestScript.GetScriptTag(nonce, backOfficeAssetsPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Templates;
|
||||
|
||||
internal sealed class VisualEditorRenderService : IVisualEditorRenderService
|
||||
{
|
||||
private readonly IUmbracoContextFactory _umbracoContextFactory;
|
||||
private readonly IPublishedRouter _publishedRouter;
|
||||
private readonly ITemplateService _templateService;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ICompositeViewEngine _viewEngine;
|
||||
private readonly IModelMetadataProvider _modelMetadataProvider;
|
||||
private readonly ITempDataDictionaryFactory _tempDataDictionaryFactory;
|
||||
private readonly IVisualEditorContentFactory _contentFactory;
|
||||
private readonly ILogger<VisualEditorRenderService> _logger;
|
||||
|
||||
public VisualEditorRenderService(
|
||||
IUmbracoContextFactory umbracoContextFactory,
|
||||
IPublishedRouter publishedRouter,
|
||||
ITemplateService templateService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ICompositeViewEngine viewEngine,
|
||||
IModelMetadataProvider modelMetadataProvider,
|
||||
ITempDataDictionaryFactory tempDataDictionaryFactory,
|
||||
IVisualEditorContentFactory contentFactory,
|
||||
ILogger<VisualEditorRenderService> logger)
|
||||
{
|
||||
_umbracoContextFactory = umbracoContextFactory;
|
||||
_publishedRouter = publishedRouter;
|
||||
_templateService = templateService;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_viewEngine = viewEngine;
|
||||
_modelMetadataProvider = modelMetadataProvider;
|
||||
_tempDataDictionaryFactory = tempDataDictionaryFactory;
|
||||
_contentFactory = contentFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> RenderAsync(
|
||||
Guid documentKey,
|
||||
string? culture,
|
||||
string? segment,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides)
|
||||
{
|
||||
using UmbracoContextReference contextReference = _umbracoContextFactory.EnsureUmbracoContext();
|
||||
IUmbracoContext umbracoContext = contextReference.UmbracoContext;
|
||||
|
||||
IPublishedContent? content = await _contentFactory.CreateWithOverridesAsync(documentKey, overrides);
|
||||
if (content?.TemplateId is null)
|
||||
{
|
||||
_logger.LogWarning("Visual editor render skipped: no draft content or template for document {DocumentKey}.", documentKey);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
ITemplate? template = await _templateService.GetAsync(content.TemplateId.Value);
|
||||
if (template is null)
|
||||
{
|
||||
_logger.LogWarning("Visual editor render skipped: template {TemplateId} not found for document {DocumentKey}.", content.TemplateId.Value, documentKey);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
IPublishedRequestBuilder requestBuilder = await _publishedRouter.CreateRequestAsync(umbracoContext.CleanedUmbracoUrl);
|
||||
requestBuilder.SetCulture(culture);
|
||||
requestBuilder.SetSegment(segment);
|
||||
requestBuilder.SetPublishedContent(content);
|
||||
requestBuilder.SetTemplate(template);
|
||||
IPublishedRequest request = requestBuilder.Build();
|
||||
|
||||
IPublishedRequest? oldRequest = umbracoContext.PublishedRequest;
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
try
|
||||
{
|
||||
umbracoContext.PublishedRequest = request;
|
||||
return ExecuteTemplateRendering(request);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
umbracoContext.PublishedRequest = oldRequest;
|
||||
}
|
||||
}
|
||||
|
||||
private string ExecuteTemplateRendering(IPublishedRequest request)
|
||||
{
|
||||
HttpContext httpContext = _httpContextAccessor.GetRequiredHttpContext();
|
||||
|
||||
// isMainPage is set to true here to ensure ViewStart(s) found in the view hierarchy are rendered
|
||||
ViewEngineResult viewResult = _viewEngine.GetView(null, $"~/Views/{request.GetTemplateAlias()}.cshtml", true);
|
||||
if (viewResult.Success is false)
|
||||
{
|
||||
_logger.LogWarning("Visual editor render skipped: view for template alias {TemplateAlias} not found.", request.GetTemplateAlias());
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var viewData = new ViewDataDictionary(_modelMetadataProvider, new ModelStateDictionary())
|
||||
{
|
||||
Model = request.PublishedContent,
|
||||
};
|
||||
|
||||
using var writer = new StringWriter();
|
||||
var viewContext = new ViewContext(
|
||||
new ActionContext(httpContext, httpContext.GetRouteData(), new ControllerActionDescriptor()),
|
||||
viewResult.View,
|
||||
viewData,
|
||||
_tempDataDictionaryFactory.GetTempData(httpContext),
|
||||
writer,
|
||||
new HtmlHelperOptions());
|
||||
|
||||
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
|
||||
return writer.GetStringBuilder().ToString();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public abstract class UmbracoViewPage : UmbracoViewPage<IPublishedContent>
|
||||
public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
|
||||
{
|
||||
private UmbracoHelper? _helper;
|
||||
private int _attributeDepth;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Umbraco helper.
|
||||
@@ -108,10 +109,64 @@ public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(object? value)
|
||||
{
|
||||
if (TryWriteVisualEditorAnnotation(value, () => WriteCore(value)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteCore(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// In .NET 10+, Razor resolves <c>Write(stringValue)</c> to this overload rather than
|
||||
/// <see cref="Write(object?)"/>. Both must participate in visual editor annotation.
|
||||
/// </remarks>
|
||||
public override void Write(string? value)
|
||||
{
|
||||
if (TryWriteVisualEditorAnnotation(value, () => base.Write(value)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
base.Write(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for a tracked property access and wraps the output with visual editor annotation attributes.
|
||||
/// Returns true if annotation was applied (and the value was written), false otherwise.
|
||||
/// </summary>
|
||||
private bool TryWriteVisualEditorAnnotation(object? value, Action writeValue)
|
||||
{
|
||||
var propertyAccess = VisualEditorPropertyTracker.ConsumeAccess();
|
||||
|
||||
if (propertyAccess.HasValue
|
||||
&& value is not null
|
||||
&& _attributeDepth == 0
|
||||
&& ((UmbracoContext?.InPreviewMode ?? false) || VisualEditorPropertyTracker.IsEnabled))
|
||||
{
|
||||
var access = propertyAccess.Value;
|
||||
var escapedAlias = System.Web.HttpUtility.HtmlAttributeEncode(access.Alias);
|
||||
base.WriteLiteral($"<span data-umb-property=\"{escapedAlias}\" data-umb-content-key=\"{access.ContentKey}\">");
|
||||
|
||||
writeValue();
|
||||
|
||||
base.WriteLiteral("</span>");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core write logic for the object overload, shared by annotated and non-annotated paths.
|
||||
/// </summary>
|
||||
private void WriteCore(object? value)
|
||||
{
|
||||
if (value is IHtmlEncodedString htmlEncodedString)
|
||||
{
|
||||
WriteLiteral(htmlEncodedString.ToHtmlString());
|
||||
base.WriteLiteral(htmlEncodedString.ToHtmlString());
|
||||
}
|
||||
else if (value is TagHelperOutput tagHelperOutput)
|
||||
{
|
||||
@@ -124,6 +179,40 @@ public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void BeginWriteAttribute(string name, string prefix, int prefixOffset, string suffix, int suffixOffset, int attributeValuesCount)
|
||||
{
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
_attributeDepth++;
|
||||
base.BeginWriteAttribute(name, prefix, prefixOffset, suffix, suffixOffset, attributeValuesCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void EndWriteAttribute()
|
||||
{
|
||||
_attributeDepth--;
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
base.EndWriteAttribute();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void WriteLiteral(object? value)
|
||||
{
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
base.WriteLiteral(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// In .NET 10+, Razor resolves <c>WriteLiteral(stringValue)</c> to this overload.
|
||||
/// Both must participate in visual editor tracker clearing.
|
||||
/// </remarks>
|
||||
public override void WriteLiteral(string? value)
|
||||
{
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
base.WriteLiteral(value);
|
||||
}
|
||||
|
||||
public void WriteUmbracoContent(TagHelperOutput tagHelperOutput)
|
||||
{
|
||||
// filter / add preview banner
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Umbraco.Cms.Web.Common.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Generates the script tag for the visual editor guest script.
|
||||
/// The script is built from <c>src/apps/visual-editor/injected.ts</c> in the backoffice client
|
||||
/// and served from the backoffice static assets under <c>apps/visual-editor/injected.js</c>.
|
||||
/// </summary>
|
||||
internal static class VisualEditorGuestScript
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the script tag referencing the visual editor guest script asset.
|
||||
/// </summary>
|
||||
/// <param name="nonce">The CSP nonce for the current request, or <c>null</c>/empty when CSP is not in use.</param>
|
||||
/// <param name="backOfficePath">The (cache-busted) backoffice assets path the script is served under.</param>
|
||||
/// <returns>The <c><script></c> tag markup.</returns>
|
||||
public static string GetScriptTag(string? nonce, string backOfficePath = "/umbraco/backoffice")
|
||||
{
|
||||
var nonceAttr = string.IsNullOrEmpty(nonce) ? string.Empty : $" nonce=\"{nonce}\"";
|
||||
return $"<script data-umb-visual-editor src=\"{backOfficePath}/apps/visual-editor/injected.js\"{nonceAttr}></script>";
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -99,6 +100,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -123,6 +125,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -147,6 +150,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -167,6 +171,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,6 +192,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -207,6 +213,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -227,6 +234,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -247,6 +255,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -267,6 +276,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -287,6 +297,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -307,6 +318,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -327,6 +339,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -347,6 +360,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -367,6 +381,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -387,6 +402,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -407,6 +423,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -427,6 +444,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -447,6 +465,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -467,6 +486,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -487,6 +507,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -507,6 +528,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -527,6 +549,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -547,6 +570,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -567,6 +591,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -587,6 +612,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -607,6 +633,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -627,6 +654,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -647,6 +675,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -667,6 +696,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -687,6 +717,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -707,6 +738,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -727,6 +759,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -751,6 +784,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -771,6 +805,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -791,6 +826,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -811,6 +847,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -831,6 +868,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -888,6 +926,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -908,6 +947,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -969,6 +1009,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -989,6 +1030,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1009,6 +1051,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1029,6 +1072,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: true,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1049,6 +1093,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1143,6 +1188,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1200,6 +1246,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1220,6 +1267,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1240,6 +1288,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1304,6 +1353,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1371,6 +1421,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1431,6 +1482,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1451,6 +1503,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1543,6 +1596,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1563,6 +1617,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1620,6 +1675,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1677,6 +1733,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1734,6 +1791,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1791,6 +1849,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1900,6 +1959,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1957,6 +2017,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1977,6 +2038,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2034,6 +2096,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,6 +49,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,6 +70,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -121,6 +124,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -174,6 +178,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -227,6 +232,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -280,6 +286,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -333,6 +340,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,7 @@ export const data: Array<UmbMockMemberTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -81,6 +82,7 @@ export const data: Array<UmbMockMemberTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+17
-2
@@ -11,10 +11,12 @@
|
||||
"workspaces": [
|
||||
"./src/libs/*",
|
||||
"./src/packages/*",
|
||||
"./src/external/*"
|
||||
"./src/external/*",
|
||||
"./src/apps/visual-editor"
|
||||
],
|
||||
"dependencies": {
|
||||
"element-internals-polyfill": "^3.0.2"
|
||||
"element-internals-polyfill": "^3.0.2",
|
||||
"morphdom": "^2.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
@@ -3962,6 +3964,10 @@
|
||||
"resolved": "src/packages/webhook",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@umbraco-cms/visual-editor": {
|
||||
"resolved": "src/apps/visual-editor",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@umbraco-ui/uui": {
|
||||
"version": "2.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.2.tgz",
|
||||
@@ -11073,6 +11079,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/morphdom": {
|
||||
"version": "2.7.8",
|
||||
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.8.tgz",
|
||||
"integrity": "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -16390,6 +16402,9 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"src/apps/visual-editor": {
|
||||
"name": "@umbraco-cms/visual-editor"
|
||||
},
|
||||
"src/external/dompurify": {
|
||||
"name": "@umbraco-backoffice/dompurify",
|
||||
"dependencies": {
|
||||
|
||||
@@ -171,7 +171,8 @@
|
||||
"workspaces": [
|
||||
"./src/libs/*",
|
||||
"./src/packages/*",
|
||||
"./src/external/*"
|
||||
"./src/external/*",
|
||||
"./src/apps/visual-editor"
|
||||
],
|
||||
"scripts": {
|
||||
"backoffice:test:e2e": "npx playwright test",
|
||||
@@ -232,7 +233,8 @@
|
||||
"npm": ">=10.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-internals-polyfill": "^3.0.2"
|
||||
"element-internals-polyfill": "^3.0.2",
|
||||
"morphdom": "^2.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@umbraco-cms/visual-editor",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "esbuild injected.ts --bundle --format=iife --outfile=../../../dist-cms/apps/visual-editor/injected.js --minify",
|
||||
"dev": "esbuild injected.ts --bundle --format=iife --outfile=../../../../Umbraco.Cms.StaticAssets/wwwroot/umbraco/backoffice/apps/visual-editor/injected.js --watch"
|
||||
}
|
||||
}
|
||||
@@ -1946,6 +1946,9 @@ export default {
|
||||
displaySettingsHeadline: 'Appearance',
|
||||
displaySettingsLabelOnLeft: 'Label to the left',
|
||||
displaySettingsLabelOnTop: 'Label above (full-width)',
|
||||
visualEditorHeadline: 'Visual Editor',
|
||||
editableInVisualEditor: 'Editable in visual editor',
|
||||
editableInVisualEditorDescription: 'Allow this property to be edited inline in the visual editor',
|
||||
confirmDeleteTabMessage: 'Are you sure you want to delete the tab <strong>%0%</strong>?',
|
||||
confirmDeleteGroupMessage: 'Are you sure you want to delete the group <strong>%0%</strong>?',
|
||||
confirmDeletePropertyMessage: 'Are you sure you want to delete the property <strong>%0%</strong>?',
|
||||
|
||||
+18
-137
@@ -2,15 +2,11 @@ import type { UmbBlockGridLayoutModel, UmbBlockGridTypeModel } from '../types.js
|
||||
import type { UmbBlockGridWorkspaceOriginData } from '../index.js';
|
||||
import { UMB_BLOCK_GRID_DEFAULT_LAYOUT_STYLESHEET } from '../context/constants.js';
|
||||
import {
|
||||
appendToFrozenArray,
|
||||
pushAtToUniqueArray,
|
||||
UmbArrayState,
|
||||
UmbBooleanState,
|
||||
} from '@umbraco-cms/backoffice/observable-api';
|
||||
import { transformServerPathToClientPath } from '@umbraco-cms/backoffice/utils';
|
||||
import { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
|
||||
import { UmbBlockManagerContext, appendLayoutEntryToArea, updateLayoutEntryInPlace } from '@umbraco-cms/backoffice/block';
|
||||
import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server';
|
||||
import { UMB_PROPERTY_SORT_MODE_CONTEXT } from '@umbraco-cms/backoffice/property-sort-mode';
|
||||
import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block';
|
||||
import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
@@ -24,27 +20,6 @@ export class UmbBlockGridManagerContext<
|
||||
BlockLayoutType extends UmbBlockGridLayoutModel = UmbBlockGridLayoutModel,
|
||||
> extends UmbBlockManagerContext<UmbBlockGridTypeModel, UmbBlockGridLayoutModel, UmbBlockGridWorkspaceOriginData> {
|
||||
//
|
||||
#inlineEditingMode = new UmbBooleanState(undefined);
|
||||
readonly inlineEditingMode = this.#inlineEditingMode.asObservable();
|
||||
|
||||
setInlineEditingMode(inlineEditingMode: boolean | undefined) {
|
||||
this.#inlineEditingMode.setValue(inlineEditingMode ?? false);
|
||||
}
|
||||
getInlineEditingMode(): boolean | undefined {
|
||||
return this.#inlineEditingMode.getValue();
|
||||
}
|
||||
|
||||
#sortModeContext?: typeof UMB_PROPERTY_SORT_MODE_CONTEXT.TYPE;
|
||||
#isSortMode = new UmbBooleanState(undefined);
|
||||
readonly isSortMode = this.#isSortMode.asObservable();
|
||||
|
||||
setIsSortMode(isSortMode: boolean) {
|
||||
this.#sortModeContext?.setIsSortMode(isSortMode);
|
||||
}
|
||||
getIsSortMode(): boolean | undefined {
|
||||
return this.#sortModeContext?.getIsSortMode();
|
||||
}
|
||||
|
||||
#initAppUrl: Promise<unknown>;
|
||||
|
||||
#serverUrl?: string;
|
||||
@@ -102,113 +77,9 @@ export class UmbBlockGridManagerContext<
|
||||
this.#initAppUrl = this.consumeContext(UMB_SERVER_CONTEXT, (instance) => {
|
||||
this.#serverUrl = instance?.getServerUrl();
|
||||
}).asPromise({ preventTimeout: true });
|
||||
|
||||
this.consumeContext(UMB_PROPERTY_SORT_MODE_CONTEXT, (sortModeContext) => {
|
||||
this.#sortModeContext = sortModeContext;
|
||||
this.observe(this.#sortModeContext?.isSortMode, (isSortMode) => {
|
||||
this.#isSortMode.setValue(isSortMode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param contentElementTypeKey
|
||||
* @param partialLayoutEntry
|
||||
* @param originData
|
||||
*/
|
||||
async createWithPresets(
|
||||
contentElementTypeKey: string,
|
||||
partialLayoutEntry?: Omit<BlockLayoutType, 'contentKey'>,
|
||||
// This property is used by some implementations, but not used in this.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
originData?: UmbBlockGridWorkspaceOriginData,
|
||||
) {
|
||||
return await super._createBlockData(contentElementTypeKey, partialLayoutEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a layout entry into an area of a layout entry.
|
||||
* @param layoutEntry The layout entry to insert.
|
||||
* @param insert
|
||||
* @param entries The layout entries to search within.
|
||||
* @param parentUnique The parentUnique to search for.
|
||||
* @param parentId
|
||||
* @param areaKey The areaKey to insert the layout entry into.
|
||||
* @param index The index to insert the layout entry at.
|
||||
* @returns a updated layout entries array if the insert was successful.
|
||||
* @remarks
|
||||
* This method is recursive and will search for the parentUnique in the layout entries.
|
||||
* If the parentUnique is found, the layout entry will be inserted into the items of the area that matches the areaKey.
|
||||
* This returns a new array of layout entries with the updated layout entry inserted.
|
||||
* Because the layout entries are frozen, the affected parts is replaced with a new. Only updating/unfreezing the affected part of the structure.
|
||||
*/
|
||||
#appendLayoutEntryToArea(
|
||||
insert: UmbBlockGridLayoutModel,
|
||||
entries: Array<UmbBlockGridLayoutModel>,
|
||||
parentId: string,
|
||||
areaKey: string,
|
||||
index: number,
|
||||
): Array<UmbBlockGridLayoutModel> | undefined {
|
||||
// I'm sorry, this code is not easy to read or maintain [NL]
|
||||
let i: number = entries.length;
|
||||
while (i--) {
|
||||
const currentEntry = entries[i];
|
||||
// Lets check if we found the right parent layout entry:
|
||||
if (currentEntry.contentKey === parentId) {
|
||||
// Append the layout entry to be inserted and unfreeze the rest of the data:
|
||||
const areas =
|
||||
currentEntry.areas?.map((x) =>
|
||||
x.key === areaKey
|
||||
? {
|
||||
...x,
|
||||
items: pushAtToUniqueArray([...x.items], insert, (x) => x.contentKey === insert.contentKey, index),
|
||||
}
|
||||
: x,
|
||||
) ?? [];
|
||||
return appendToFrozenArray(
|
||||
entries,
|
||||
{
|
||||
...currentEntry,
|
||||
areas,
|
||||
},
|
||||
(x) => x.contentKey === currentEntry.contentKey,
|
||||
);
|
||||
}
|
||||
// Otherwise check if any items of the areas are the parent layout entry we are looking for. We do so based on parentId, recursively:
|
||||
if (currentEntry.areas) {
|
||||
let y: number = currentEntry.areas.length;
|
||||
while (y--) {
|
||||
// Recursively ask the items of this area to insert the layout entry, if something returns there was a match in this branch. [NL]
|
||||
const correctedAreaItems = this.#appendLayoutEntryToArea(
|
||||
insert,
|
||||
currentEntry.areas[y].items,
|
||||
parentId,
|
||||
areaKey,
|
||||
index,
|
||||
);
|
||||
if (correctedAreaItems) {
|
||||
// This area got a corrected set of items, lets append those to the area and unfreeze the surrounding data:
|
||||
const area = currentEntry.areas[y];
|
||||
return appendToFrozenArray(
|
||||
entries,
|
||||
{
|
||||
...currentEntry,
|
||||
areas: appendToFrozenArray(
|
||||
currentEntry.areas,
|
||||
{ ...area, items: correctedAreaItems },
|
||||
(z) => z.key === area.key,
|
||||
),
|
||||
},
|
||||
(x) => x.contentKey === currentEntry.contentKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
insert(
|
||||
override insert(
|
||||
layoutEntry: BlockLayoutType,
|
||||
content: UmbBlockDataModel,
|
||||
settings: UmbBlockDataModel | undefined,
|
||||
@@ -226,21 +97,31 @@ export class UmbBlockGridManagerContext<
|
||||
|
||||
if (originData?.parentUnique && originData?.areaKey) {
|
||||
// Find layout entry based on parentUnique, recursively, as it needs to check layout of areas as well:
|
||||
const layoutEntries = this.#appendLayoutEntryToArea(
|
||||
const layoutEntries = appendLayoutEntryToArea(
|
||||
layoutEntry,
|
||||
this._layouts.getValue(),
|
||||
originData?.parentUnique,
|
||||
originData?.areaKey,
|
||||
originData.parentUnique,
|
||||
originData.areaKey,
|
||||
index,
|
||||
);
|
||||
|
||||
// If this appending was successful, we got a new set of layout entries which we can set as the new value: [NL]
|
||||
// If this appending was successful, we got a new set of layout entries which we can set as the new value:
|
||||
if (layoutEntries) {
|
||||
this._layouts.setValue(layoutEntries);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
this._layouts.appendOneAt(layoutEntry, index);
|
||||
// Parent not found — fall through to root
|
||||
}
|
||||
|
||||
// For blocks that may be nested inside grid areas, try updating in-place
|
||||
// before falling through to root-level append (which would duplicate).
|
||||
const updatedInPlace = updateLayoutEntryInPlace(layoutEntry, this._layouts.getValue());
|
||||
if (updatedInPlace) {
|
||||
this._layouts.setValue(updatedInPlace);
|
||||
return;
|
||||
}
|
||||
|
||||
this._layouts.appendOneAt(layoutEntry, index);
|
||||
}
|
||||
|
||||
onDragStart() {
|
||||
|
||||
+8
@@ -33,6 +33,7 @@ import {
|
||||
UMB_BLOCK_CATALOGUE_MODAL,
|
||||
UmbBlockEntriesContext,
|
||||
type UmbBlockDataModel,
|
||||
findLayoutEntryInAreas,
|
||||
} from '@umbraco-cms/backoffice/block';
|
||||
|
||||
interface UmbBlockGridAreaTypeInvalidRuleType {
|
||||
@@ -295,6 +296,13 @@ export class UmbBlockGridEntriesContext
|
||||
});
|
||||
}
|
||||
|
||||
protected override _findLayout(
|
||||
source: Array<UmbBlockGridLayoutModel>,
|
||||
contentKey: string,
|
||||
): UmbBlockGridLayoutModel | undefined {
|
||||
return findLayoutEntryInAreas(source, contentKey);
|
||||
}
|
||||
|
||||
async #clipboardEntriesFilter(propertyValue: UmbBlockGridValueModel) {
|
||||
const allowedElementTypeKeys = this.#retrieveAllowedElementTypes().map((x) => x.contentElementTypeKey);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './block-grid-manager/index.js';
|
||||
export * from './constants.js';
|
||||
export * from './workspace/index.js';
|
||||
export type * from './types.js';
|
||||
|
||||
+1
-66
@@ -1,75 +1,10 @@
|
||||
import type { UmbBlockListLayoutModel, UmbBlockListTypeModel } from '../types.js';
|
||||
import type { UmbBlockListWorkspaceOriginData } from '../index.js';
|
||||
import type { UmbBlockDataModel } from '../../block/types.js';
|
||||
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
|
||||
import { UMB_PROPERTY_SORT_MODE_CONTEXT } from '@umbraco-cms/backoffice/property-sort-mode';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
/**
|
||||
* A implementation of the Block Manager specifically for the Block List Editor.
|
||||
*/
|
||||
export class UmbBlockListManagerContext<
|
||||
BlockLayoutType extends UmbBlockListLayoutModel = UmbBlockListLayoutModel,
|
||||
> extends UmbBlockManagerContext<UmbBlockListTypeModel, BlockLayoutType, UmbBlockListWorkspaceOriginData> {
|
||||
//
|
||||
#inlineEditingMode = new UmbBooleanState(undefined);
|
||||
readonly inlineEditingMode = this.#inlineEditingMode.asObservable();
|
||||
|
||||
setInlineEditingMode(inlineEditingMode: boolean | undefined) {
|
||||
this.#inlineEditingMode.setValue(inlineEditingMode ?? false);
|
||||
}
|
||||
getInlineEditingMode(): boolean | undefined {
|
||||
return this.#inlineEditingMode.getValue();
|
||||
}
|
||||
|
||||
#sortModeContext?: typeof UMB_PROPERTY_SORT_MODE_CONTEXT.TYPE;
|
||||
#isSortMode = new UmbBooleanState(undefined);
|
||||
readonly isSortMode = this.#isSortMode.asObservable();
|
||||
|
||||
setIsSortMode(isSortMode: boolean) {
|
||||
this.#sortModeContext?.setIsSortMode(isSortMode);
|
||||
}
|
||||
getIsSortMode(): boolean | undefined {
|
||||
return this.#sortModeContext?.getIsSortMode();
|
||||
}
|
||||
|
||||
constructor(host: UmbControllerHost) {
|
||||
super(host);
|
||||
|
||||
this.consumeContext(UMB_PROPERTY_SORT_MODE_CONTEXT, (sortPropertyContext) => {
|
||||
this.#sortModeContext = sortPropertyContext;
|
||||
this.observe(this.#sortModeContext?.isSortMode, (isSortMode) => {
|
||||
this.#isSortMode.setValue(isSortMode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param contentElementTypeKey
|
||||
* @param partialLayoutEntry
|
||||
* @param _originData
|
||||
*/
|
||||
async createWithPresets(
|
||||
contentElementTypeKey: string,
|
||||
partialLayoutEntry?: Omit<BlockLayoutType, 'contentKey'>,
|
||||
// This property is used by some implementations, but not used in this. Do not remove. [NL]
|
||||
|
||||
_originData?: UmbBlockListWorkspaceOriginData,
|
||||
) {
|
||||
return await super._createBlockData(contentElementTypeKey, partialLayoutEntry);
|
||||
}
|
||||
|
||||
insert(
|
||||
layoutEntry: BlockLayoutType,
|
||||
content: UmbBlockDataModel,
|
||||
settings: UmbBlockDataModel | undefined,
|
||||
originData: UmbBlockListWorkspaceOriginData,
|
||||
) {
|
||||
this._layouts.appendOneAt(layoutEntry, originData.index ?? -1);
|
||||
this.insertBlockData(layoutEntry, content, settings, originData);
|
||||
this.notifyBlockInserted(layoutEntry, originData);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
> extends UmbBlockManagerContext<UmbBlockListTypeModel, BlockLayoutType, UmbBlockListWorkspaceOriginData> {}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './block-list-entries.context-token.js';
|
||||
export * from './block-list-entry.context-token.js';
|
||||
export * from './block-list-manager.context.js';
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ export class UmbBlockRteManagerContext<
|
||||
* @param partialLayoutEntry
|
||||
* @param _originData
|
||||
*/
|
||||
async createWithPresets(
|
||||
override async createWithPresets(
|
||||
contentElementTypeKey: string,
|
||||
partialLayoutEntry?: Omit<BlockLayoutType, 'contentKey'>,
|
||||
// This property is used by some implementations, but not used in this, do not remove. [NL]
|
||||
@@ -69,7 +69,7 @@ export class UmbBlockRteManagerContext<
|
||||
return data;
|
||||
}
|
||||
|
||||
insert(
|
||||
override insert(
|
||||
layoutEntry: BlockLayoutType,
|
||||
content: UmbBlockDataModel,
|
||||
settings: UmbBlockDataModel | undefined,
|
||||
|
||||
+1
-42
@@ -1,7 +1,5 @@
|
||||
import type { UmbBlockSingleLayoutModel, UmbBlockSingleTypeModel } from '../types.js';
|
||||
import type { UmbBlockSingleWorkspaceOriginData } from '../index.js';
|
||||
import type { UmbBlockDataModel } from '../../block/types.js';
|
||||
import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
|
||||
|
||||
/**
|
||||
@@ -9,43 +7,4 @@ import { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
|
||||
*/
|
||||
export class UmbBlockSingleManagerContext<
|
||||
BlockLayoutType extends UmbBlockSingleLayoutModel = UmbBlockSingleLayoutModel,
|
||||
> extends UmbBlockManagerContext<UmbBlockSingleTypeModel, BlockLayoutType, UmbBlockSingleWorkspaceOriginData> {
|
||||
//
|
||||
#inlineEditingMode = new UmbBooleanState(undefined);
|
||||
readonly inlineEditingMode = this.#inlineEditingMode.asObservable();
|
||||
|
||||
setInlineEditingMode(inlineEditingMode: boolean | undefined) {
|
||||
this.#inlineEditingMode.setValue(inlineEditingMode ?? false);
|
||||
}
|
||||
getInlineEditingMode(): boolean | undefined {
|
||||
return this.#inlineEditingMode.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param contentElementTypeKey
|
||||
* @param partialLayoutEntry
|
||||
* @param _originData
|
||||
*/
|
||||
async createWithPresets(
|
||||
contentElementTypeKey: string,
|
||||
partialLayoutEntry?: Omit<BlockLayoutType, 'contentKey'>,
|
||||
// This property is used by some implementations, but not used in this. Do not remove. [NL]
|
||||
|
||||
_originData?: UmbBlockSingleWorkspaceOriginData,
|
||||
) {
|
||||
return await super._createBlockData(contentElementTypeKey, partialLayoutEntry);
|
||||
}
|
||||
|
||||
insert(
|
||||
layoutEntry: BlockLayoutType,
|
||||
content: UmbBlockDataModel,
|
||||
settings: UmbBlockDataModel | undefined,
|
||||
originData: UmbBlockSingleWorkspaceOriginData,
|
||||
) {
|
||||
this._layouts.appendOneAt(layoutEntry, originData.index ?? -1);
|
||||
this.insertBlockData(layoutEntry, content, settings, originData);
|
||||
this.notifyBlockInserted(layoutEntry, originData);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
> extends UmbBlockManagerContext<UmbBlockSingleTypeModel, BlockLayoutType, UmbBlockSingleWorkspaceOriginData> {}
|
||||
|
||||
+13
-2
@@ -70,10 +70,21 @@ export abstract class UmbBlockEntriesContext<
|
||||
// Public methods:
|
||||
|
||||
layoutOf(contentKey: string) {
|
||||
return this._layoutEntries.asObservablePart((source) => source.find((x) => x.contentKey === contentKey));
|
||||
return this._layoutEntries.asObservablePart((source) => this._findLayout(source, contentKey));
|
||||
}
|
||||
getLayoutOf(contentKey: string) {
|
||||
return this._layoutEntries.getValue().find((x) => x.contentKey === contentKey);
|
||||
return this._findLayout(this._layoutEntries.getValue(), contentKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a layout entry by contentKey in the given source array.
|
||||
* Override in subclasses to support recursive search through nested areas (e.g. block grid).
|
||||
* @param source The layout entries to search.
|
||||
* @param contentKey The contentKey to find.
|
||||
* @returns The matching layout entry, or undefined if not found.
|
||||
*/
|
||||
protected _findLayout(source: Array<BlockLayoutType>, contentKey: string): BlockLayoutType | undefined {
|
||||
return source.find((x) => x.contentKey === contentKey);
|
||||
}
|
||||
setLayouts(layouts: Array<BlockLayoutType>) {
|
||||
return this._layoutEntries.setValue(layouts);
|
||||
|
||||
+42
-5
@@ -12,6 +12,7 @@ import {
|
||||
type MappingFunction,
|
||||
mergeObservables,
|
||||
} from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UMB_PROPERTY_SORT_MODE_CONTEXT } from '@umbraco-cms/backoffice/property-sort-mode';
|
||||
import { UmbDocumentTypeDetailRepository } from '@umbraco-cms/backoffice/document-type';
|
||||
import { UmbContentTypeStructureManager, type UmbContentTypeModel } from '@umbraco-cms/backoffice/content-type';
|
||||
import { UmbId } from '@umbraco-cms/backoffice/id';
|
||||
@@ -78,6 +79,27 @@ export abstract class UmbBlockManagerContext<
|
||||
readonly #settings = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
|
||||
public readonly settings = this.#settings.asObservable();
|
||||
|
||||
#inlineEditingMode = new UmbBooleanState(undefined);
|
||||
readonly inlineEditingMode = this.#inlineEditingMode.asObservable();
|
||||
|
||||
setInlineEditingMode(inlineEditingMode: boolean | undefined) {
|
||||
this.#inlineEditingMode.setValue(inlineEditingMode ?? false);
|
||||
}
|
||||
getInlineEditingMode(): boolean | undefined {
|
||||
return this.#inlineEditingMode.getValue();
|
||||
}
|
||||
|
||||
#sortModeContext?: typeof UMB_PROPERTY_SORT_MODE_CONTEXT.TYPE;
|
||||
#isSortMode = new UmbBooleanState(undefined);
|
||||
readonly isSortMode = this.#isSortMode.asObservable();
|
||||
|
||||
setIsSortMode(isSortMode: boolean) {
|
||||
this.#sortModeContext?.setIsSortMode(isSortMode);
|
||||
}
|
||||
getIsSortMode(): boolean | undefined {
|
||||
return this.#sortModeContext?.getIsSortMode();
|
||||
}
|
||||
|
||||
// TODO: This is a bad seperation of concerns, this should be self initializing, not defined from the outside. [NL]
|
||||
public readonly readOnlyState = new UmbReadOnlyVariantGuardManager(this);
|
||||
|
||||
@@ -174,6 +196,13 @@ export abstract class UmbBlockManagerContext<
|
||||
constructor(host: UmbControllerHost) {
|
||||
super(host, UMB_BLOCK_MANAGER_CONTEXT);
|
||||
|
||||
this.consumeContext(UMB_PROPERTY_SORT_MODE_CONTEXT, (sortModeContext) => {
|
||||
this.#sortModeContext = sortModeContext;
|
||||
this.observe(this.#sortModeContext?.isSortMode, (isSortMode) => {
|
||||
this.#isSortMode.setValue(isSortMode);
|
||||
});
|
||||
});
|
||||
|
||||
this.observe(
|
||||
this.blockTypes,
|
||||
(blockTypes) => {
|
||||
@@ -417,11 +446,14 @@ export abstract class UmbBlockManagerContext<
|
||||
);
|
||||
}
|
||||
|
||||
abstract createWithPresets(
|
||||
async createWithPresets(
|
||||
contentElementTypeKey: string,
|
||||
partialLayoutEntry?: Omit<BlockLayoutType, 'contentKey'>,
|
||||
originData?: BlockOriginDataType,
|
||||
): Promise<UmbBlockDataObjectModel<BlockLayoutType> | undefined>;
|
||||
// This property is used by some implementations, but not used in this base. Do not remove. [NL]
|
||||
_originData?: BlockOriginDataType,
|
||||
) {
|
||||
return await this._createBlockData(contentElementTypeKey, partialLayoutEntry);
|
||||
}
|
||||
|
||||
public async createBlockSettingsData(contentElementTypeKey: string) {
|
||||
const blockType = this.#blockTypes.value.find((x) => x.contentElementTypeKey === contentElementTypeKey);
|
||||
@@ -549,12 +581,17 @@ export abstract class UmbBlockManagerContext<
|
||||
};
|
||||
}
|
||||
|
||||
abstract insert(
|
||||
insert(
|
||||
layoutEntry: BlockLayoutType,
|
||||
content: UmbBlockDataModel,
|
||||
settings: UmbBlockDataModel | undefined,
|
||||
originData: BlockOriginDataType,
|
||||
): boolean;
|
||||
) {
|
||||
this._layouts.appendOneAt(layoutEntry, originData.index ?? -1);
|
||||
this.insertBlockData(layoutEntry, content, settings, originData);
|
||||
this.notifyBlockInserted(layoutEntry, originData);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected insertBlockData(
|
||||
layoutEntry: BlockLayoutType,
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './modals/index.js';
|
||||
export * from './property-value-cloner/index.js';
|
||||
export * from './property-value-resolver/index.js';
|
||||
export * from './validation/index.js';
|
||||
export * from './utils/block-layout-area.utils.js';
|
||||
export * from './workspace/index.js';
|
||||
|
||||
export type * from './types.js';
|
||||
|
||||
+6
-2
@@ -193,7 +193,7 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
|
||||
}
|
||||
|
||||
#renderMain() {
|
||||
return this._manager ? (this._openClipboard ? this.#renderClipboard() : this.#renderCreateEmpty()) : nothing;
|
||||
return this._openClipboard ? this.#renderClipboard() : this.#renderCreateEmpty();
|
||||
}
|
||||
|
||||
#renderClipboard() {
|
||||
@@ -237,8 +237,12 @@ export class UmbBlockCatalogueModalElement extends UmbModalBaseElement<
|
||||
}
|
||||
|
||||
#renderBlockTypeCard(block: UmbBlockTypeItemWithGroupKey) {
|
||||
const hasProperties =
|
||||
this._manager?.getContentTypeHasProperties(block.contentElementTypeKey) ??
|
||||
this.data?.contentTypeHasProperties?.[block.contentElementTypeKey];
|
||||
|
||||
const href =
|
||||
this._workspacePath && this._manager!.getContentTypeHasProperties(block.contentElementTypeKey)
|
||||
this._workspacePath && hasProperties
|
||||
? `${this._workspacePath}create/${block.contentElementTypeKey}`
|
||||
: undefined;
|
||||
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ export interface UmbBlockCatalogueModalData {
|
||||
openClipboard?: boolean;
|
||||
clipboardFilter?: (clipboardDetailEntryModel: UmbClipboardEntryDetailModel) => Promise<boolean>;
|
||||
originData: UmbBlockWorkspaceData['originData'];
|
||||
/** Optional map of content element type key → whether the type has properties. Used as a fallback when UMB_BLOCK_MANAGER_CONTEXT is not available (e.g. visual editor). */
|
||||
contentTypeHasProperties?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export type UmbBlockCatalogueModalValue =
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { UmbBlockLayoutBaseModel } from '../types.js';
|
||||
import { appendToFrozenArray, pushAtToUniqueArray } from '@umbraco-cms/backoffice/observable-api';
|
||||
|
||||
/**
|
||||
* A layout entry that may contain nested areas (e.g. block grid layouts).
|
||||
* This extends the base model with an optional `areas` array for recursive operations.
|
||||
*/
|
||||
export interface UmbBlockLayoutWithAreasModel extends UmbBlockLayoutBaseModel {
|
||||
areas?: Array<UmbBlockLayoutAreaItemModel>;
|
||||
}
|
||||
|
||||
export interface UmbBlockLayoutAreaItemModel {
|
||||
key: string;
|
||||
items: Array<UmbBlockLayoutWithAreasModel>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find a layout entry by contentKey, searching through nested areas.
|
||||
* @param entries The layout entries to search.
|
||||
* @param contentKey The contentKey to find.
|
||||
* @returns The matching layout entry, or undefined if not found.
|
||||
*/
|
||||
export function findLayoutEntryInAreas<T extends UmbBlockLayoutWithAreasModel>(
|
||||
entries: Array<T>,
|
||||
contentKey: string,
|
||||
): T | undefined {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentKey === contentKey) return entry;
|
||||
const areas = entry.areas;
|
||||
if (areas) {
|
||||
for (const area of areas) {
|
||||
const found = findLayoutEntryInAreas(area.items, contentKey) as T | undefined;
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk layout entries to find the parent block by contentKey, then insert
|
||||
* the new entry into the matching area's items array.
|
||||
*
|
||||
* @param insert The layout entry to insert.
|
||||
* @param entries The layout entries to search within.
|
||||
* @param parentId The contentKey of the parent block.
|
||||
* @param areaKey The area key to insert into.
|
||||
* @param index The index at which to insert.
|
||||
* @returns An updated layout entries array if the insert was successful, or undefined if the parent was not found.
|
||||
*
|
||||
* @remarks
|
||||
* This function preserves immutability by using frozen array utilities. Only the affected
|
||||
* branch of the tree is replaced; unaffected entries remain frozen.
|
||||
*/
|
||||
export function appendLayoutEntryToArea<T extends UmbBlockLayoutWithAreasModel>(
|
||||
insert: T,
|
||||
entries: Array<T>,
|
||||
parentId: string,
|
||||
areaKey: string,
|
||||
index: number,
|
||||
): Array<T> | undefined {
|
||||
let i: number = entries.length;
|
||||
while (i--) {
|
||||
const currentEntry = entries[i];
|
||||
if (currentEntry.contentKey === parentId) {
|
||||
const areas =
|
||||
currentEntry.areas?.map((x) =>
|
||||
x.key === areaKey
|
||||
? {
|
||||
...x,
|
||||
items: pushAtToUniqueArray(
|
||||
[...x.items],
|
||||
insert,
|
||||
(x) => x.contentKey === insert.contentKey,
|
||||
index,
|
||||
),
|
||||
}
|
||||
: x,
|
||||
) ?? [];
|
||||
return appendToFrozenArray(
|
||||
entries,
|
||||
{ ...currentEntry, areas } as T,
|
||||
(x) => x.contentKey === currentEntry.contentKey,
|
||||
);
|
||||
}
|
||||
if (currentEntry.areas) {
|
||||
let y: number = currentEntry.areas.length;
|
||||
while (y--) {
|
||||
const correctedAreaItems = appendLayoutEntryToArea(
|
||||
insert,
|
||||
currentEntry.areas[y].items as Array<T>,
|
||||
parentId,
|
||||
areaKey,
|
||||
index,
|
||||
);
|
||||
if (correctedAreaItems) {
|
||||
const area = currentEntry.areas[y];
|
||||
return appendToFrozenArray(
|
||||
entries,
|
||||
{
|
||||
...currentEntry,
|
||||
areas: appendToFrozenArray(
|
||||
currentEntry.areas,
|
||||
{ ...area, items: correctedAreaItems },
|
||||
(z) => z.key === area.key,
|
||||
),
|
||||
} as T,
|
||||
(x) => x.contentKey === currentEntry.contentKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find an existing layout entry by contentKey and replace it
|
||||
* in-place, preserving its position within any nested area structure.
|
||||
*
|
||||
* @param entry The updated layout entry.
|
||||
* @param entries The layout entries to search within.
|
||||
* @returns An updated layout entries array if a match was found, or undefined if not found.
|
||||
*/
|
||||
export function updateLayoutEntryInPlace<T extends UmbBlockLayoutWithAreasModel>(
|
||||
entry: T,
|
||||
entries: Array<T>,
|
||||
): Array<T> | undefined {
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const current = entries[i];
|
||||
if (current.contentKey === entry.contentKey) {
|
||||
return appendToFrozenArray(entries, entry, (x) => x.contentKey === entry.contentKey);
|
||||
}
|
||||
if (current.areas) {
|
||||
for (let y = 0; y < current.areas.length; y++) {
|
||||
const updatedItems = updateLayoutEntryInPlace(entry, current.areas[y].items as Array<T>);
|
||||
if (updatedItems) {
|
||||
const area = current.areas[y];
|
||||
return appendToFrozenArray(
|
||||
entries,
|
||||
{
|
||||
...current,
|
||||
areas: appendToFrozenArray(
|
||||
current.areas,
|
||||
{ ...area, items: updatedItems },
|
||||
(z) => z.key === area.key,
|
||||
),
|
||||
} as T,
|
||||
(x) => x.contentKey === current.contentKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove a layout entry by contentKey, searching through nested areas.
|
||||
* Returns a new array with the entry removed.
|
||||
* @param entries The layout entries to search.
|
||||
* @param contentKey The contentKey of the entry to remove.
|
||||
* @returns A new array with the matching entry removed.
|
||||
*/
|
||||
export function removeLayoutEntryFromAreas<T extends UmbBlockLayoutWithAreasModel>(
|
||||
entries: Array<T>,
|
||||
contentKey: string,
|
||||
): Array<T> {
|
||||
return entries
|
||||
.filter((entry) => entry.contentKey !== contentKey)
|
||||
.map((entry) => {
|
||||
if (!entry.areas) return entry;
|
||||
return {
|
||||
...entry,
|
||||
areas: entry.areas.map((area) => ({
|
||||
...area,
|
||||
items: removeLayoutEntryFromAreas(area.items, contentKey),
|
||||
})),
|
||||
} as T;
|
||||
});
|
||||
}
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import type { UmbWorkspaceModalData, UmbWorkspaceModalValue } from '@umbraco-cms/backoffice/workspace';
|
||||
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface UmbBlockWorkspaceOriginData {}
|
||||
export interface UmbBlockWorkspaceOriginData {
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface UmbBlockWorkspaceData<OriginDataType extends UmbBlockWorkspaceOriginData = UmbBlockWorkspaceOriginData>
|
||||
extends UmbWorkspaceModalData {
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface UmbPropertyTypeValidationModel {
|
||||
|
||||
export interface UmbPropertyTypeAppearanceModel {
|
||||
labelOnTop: boolean;
|
||||
editableInVisualEditor: boolean;
|
||||
}
|
||||
|
||||
export interface UmbContentTypeSortModel {
|
||||
|
||||
+1
@@ -170,6 +170,7 @@ export class UmbPropertyTypeWorkspaceContext
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
+31
-3
@@ -4,7 +4,7 @@ import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, umbBindToValidation } from '@umb
|
||||
import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element';
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content-type';
|
||||
import type { UmbPropertyTypeScaffoldModel } from '@umbraco-cms/backoffice/content-type';
|
||||
import type { UmbPropertyTypeAppearanceModel, UmbPropertyTypeScaffoldModel } from '@umbraco-cms/backoffice/content-type';
|
||||
import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace';
|
||||
import type {
|
||||
UUIBooleanInputEvent,
|
||||
@@ -109,12 +109,16 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
|
||||
});
|
||||
}
|
||||
|
||||
#getAppearanceDefaults(): UmbPropertyTypeAppearanceModel {
|
||||
return { labelOnTop: false, editableInVisualEditor: false, ...this._data?.appearance };
|
||||
}
|
||||
|
||||
#setAppearanceNormal() {
|
||||
const currentValue = this._data?.appearance?.labelOnTop;
|
||||
if (currentValue !== true) return;
|
||||
|
||||
this.updateValue({
|
||||
appearance: { ...this._data?.appearance, labelOnTop: false },
|
||||
appearance: { ...this.#getAppearanceDefaults(), labelOnTop: false },
|
||||
});
|
||||
}
|
||||
#setAppearanceTop() {
|
||||
@@ -122,7 +126,7 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
|
||||
if (currentValue === true) return;
|
||||
|
||||
this.updateValue({
|
||||
appearance: { ...this._data?.appearance, labelOnTop: true },
|
||||
appearance: { ...this.#getAppearanceDefaults(), labelOnTop: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,6 +175,12 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
|
||||
});
|
||||
}
|
||||
|
||||
#onToggleEditableInVisualEditor(event: UUIBooleanInputEvent) {
|
||||
this.updateValue({
|
||||
appearance: { ...this.#getAppearanceDefaults(), editableInVisualEditor: event.target.checked },
|
||||
});
|
||||
}
|
||||
|
||||
#onShareAcrossCulturesChange(event: UUIBooleanInputEvent) {
|
||||
const sharedAcrossCultures = event.target.checked;
|
||||
this.updateValue({ variesByCulture: !sharedAcrossCultures });
|
||||
@@ -246,6 +256,24 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
|
||||
</umb-property-layout>
|
||||
</uui-box>
|
||||
|
||||
<uui-box class="uui-text">
|
||||
<umb-localize key="contentTypeEditor_visualEditorHeadline" slot="headline">Visual Editor</umb-localize>
|
||||
<umb-property-layout
|
||||
orientation="vertical"
|
||||
label=${this.localize.term('contentTypeEditor_editableInVisualEditor')}>
|
||||
<uui-toggle
|
||||
slot="editor"
|
||||
?checked=${this._data?.appearance?.editableInVisualEditor ?? false}
|
||||
@change=${this.#onToggleEditableInVisualEditor}>
|
||||
</uui-toggle>
|
||||
<small slot="description">
|
||||
<umb-localize key="contentTypeEditor_editableInVisualEditorDescription">
|
||||
Allow this property to be edited inline in the visual editor
|
||||
</umb-localize>
|
||||
</small>
|
||||
</umb-property-layout>
|
||||
</uui-box>
|
||||
|
||||
${this.#renderMemberTypeOptions()}
|
||||
`;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3335,6 +3335,7 @@ export type ProfilingStatusResponseModel = {
|
||||
|
||||
export type PropertyTypeAppearanceModel = {
|
||||
labelOnTop: boolean;
|
||||
editableInVisualEditor: boolean;
|
||||
};
|
||||
|
||||
export type PropertyTypeValidationModel = {
|
||||
@@ -4595,6 +4596,24 @@ export type VerifyResetPasswordTokenRequestModel = {
|
||||
resetCode: string;
|
||||
};
|
||||
|
||||
export type VisualEditorPropertyValueModel = {
|
||||
alias: string;
|
||||
value?: unknown;
|
||||
culture?: null | string;
|
||||
segment?: null | string;
|
||||
};
|
||||
|
||||
export type VisualEditorRenderRequestModel = {
|
||||
unique: string;
|
||||
culture?: null | string;
|
||||
segment?: null | string;
|
||||
values: Array<VisualEditorPropertyValueModel>;
|
||||
};
|
||||
|
||||
export type VisualEditorRenderResponseModel = {
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type WebhookEventModel = {
|
||||
eventName: string;
|
||||
eventType: string;
|
||||
@@ -20732,6 +20751,33 @@ export type PostUserGroupByIdUsersResponses = {
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type PostVisualEditorRenderData = {
|
||||
body: VisualEditorRenderRequestModel;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/umbraco/management/api/v1/visual-editor/render';
|
||||
};
|
||||
|
||||
export type PostVisualEditorRenderErrors = {
|
||||
/**
|
||||
* The resource is protected and requires an authentication token
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* The authenticated user does not have access to this resource
|
||||
*/
|
||||
403: unknown;
|
||||
};
|
||||
|
||||
export type PostVisualEditorRenderResponses = {
|
||||
/**
|
||||
* OK
|
||||
*/
|
||||
200: VisualEditorRenderResponseModel;
|
||||
};
|
||||
|
||||
export type PostVisualEditorRenderResponse = PostVisualEditorRenderResponses[keyof PostVisualEditorRenderResponses];
|
||||
|
||||
export type GetItemWebhookData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -2,7 +2,10 @@ import { UMB_DOCUMENT_ENTITY_TYPE } from '../entity.js';
|
||||
import { UMB_DOCUMENT_WORKSPACE_ALIAS } from './constants.js';
|
||||
import { manifests as actionManifests } from './actions/manifests.js';
|
||||
import { UMB_CONTENT_HAS_PROPERTIES_WORKSPACE_CONDITION } from '@umbraco-cms/backoffice/content';
|
||||
import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace';
|
||||
import {
|
||||
UMB_WORKSPACE_CONDITION_ALIAS,
|
||||
UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS,
|
||||
} from '@umbraco-cms/backoffice/workspace';
|
||||
|
||||
export const manifests: Array<UmbExtensionManifest> = [
|
||||
{
|
||||
@@ -36,6 +39,28 @@ export const manifests: Array<UmbExtensionManifest> = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'workspaceView',
|
||||
alias: 'Umb.WorkspaceView.Document.VisualEditor',
|
||||
name: 'Document Workspace Visual Editor View',
|
||||
element: () => import('./views/visual-editor/document-workspace-view-visual-editor.element.js'),
|
||||
weight: 150,
|
||||
meta: {
|
||||
label: 'Visual Editor',
|
||||
pathname: 'visual-editor',
|
||||
icon: 'icon-layout',
|
||||
},
|
||||
conditions: [
|
||||
{
|
||||
alias: UMB_WORKSPACE_CONDITION_ALIAS,
|
||||
match: UMB_DOCUMENT_WORKSPACE_ALIAS,
|
||||
},
|
||||
{
|
||||
alias: UMB_WORKSPACE_ENTITY_IS_NEW_CONDITION_ALIAS,
|
||||
match: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'workspaceView',
|
||||
alias: 'Umb.WorkspaceView.Document.Info',
|
||||
|
||||
+1122
File diff suppressed because it is too large
Load Diff
+420
@@ -0,0 +1,420 @@
|
||||
import { findLayoutEntryInAreas, removeLayoutEntryFromAreas } from '@umbraco-cms/backoffice/block';
|
||||
import type { UmbBlockLayoutWithAreasModel } from '@umbraco-cms/backoffice/block';
|
||||
import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '@umbraco-cms/backoffice/block-grid';
|
||||
import { UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS } from '@umbraco-cms/backoffice/block-list';
|
||||
|
||||
/**
|
||||
* Helper for reading and manipulating block value JSON in the workspace context.
|
||||
* Block List and Block Grid store their data as a JSON structure with
|
||||
* layout, contentData, settingsData, and expose arrays.
|
||||
*/
|
||||
|
||||
export type BlockValueLayout = UmbBlockLayoutWithAreasModel;
|
||||
|
||||
export interface BlockValueData {
|
||||
key: string;
|
||||
contentTypeKey: string;
|
||||
values: Array<{ alias: string; value: unknown; editorAlias?: string; culture?: string | null; segment?: string | null }>;
|
||||
}
|
||||
|
||||
export interface BlockValue {
|
||||
layout: Record<string, BlockValueLayout[] | undefined>;
|
||||
contentData: BlockValueData[];
|
||||
settingsData: BlockValueData[];
|
||||
expose: Array<{ contentKey: string; culture: string | null; segment: string | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a block's content data and layout entry by its content key across all property values.
|
||||
*/
|
||||
export function findBlockInValues(
|
||||
allValues: Array<{ alias: string; value: unknown }>,
|
||||
blockKey: string,
|
||||
): { propertyAlias: string; blockValue: BlockValue; block: BlockValueData; layoutEntry: BlockValueLayout | undefined } | undefined {
|
||||
for (const val of allValues) {
|
||||
const raw = val.value as BlockValue;
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.contentData)) continue;
|
||||
|
||||
const block = raw.contentData.find((b) => b.key === blockKey);
|
||||
if (block) {
|
||||
// Find the matching layout entry to access settingsKey, areas, etc.
|
||||
let layoutEntry: BlockValueLayout | undefined;
|
||||
for (const layouts of Object.values(raw.layout)) {
|
||||
layoutEntry = layouts?.find((entry) => entry.contentKey === blockKey);
|
||||
if (layoutEntry) break;
|
||||
}
|
||||
return { propertyAlias: val.alias, blockValue: raw, block, layoutEntry };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new values into an existing values array (immutable).
|
||||
*/
|
||||
function mergeValues(
|
||||
existing: BlockValueData['values'],
|
||||
newValues: Array<{ alias: string; value: unknown }>,
|
||||
): BlockValueData['values'] {
|
||||
const merged = [...existing];
|
||||
for (const nv of newValues) {
|
||||
const idx = merged.findIndex((v) => v.alias === nv.alias);
|
||||
if (idx >= 0) {
|
||||
merged[idx] = { ...merged[idx], value: nv.value };
|
||||
} else {
|
||||
merged.push({ alias: nv.alias, value: nv.value });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update values within a block's content or settings data (immutable).
|
||||
* @param dataKey - Which data array to update: 'contentData' or 'settingsData'.
|
||||
*/
|
||||
export function updateBlockDataValues(
|
||||
blockValue: BlockValue,
|
||||
dataKey: 'contentData' | 'settingsData',
|
||||
key: string,
|
||||
newValues: Array<{ alias: string; value: unknown }>,
|
||||
): BlockValue {
|
||||
return {
|
||||
...blockValue,
|
||||
[dataKey]: blockValue[dataKey].map((entry) => {
|
||||
if (entry.key !== key) return entry;
|
||||
return { ...entry, values: mergeValues(entry.values, newValues) };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a block within the layout array.
|
||||
* Returns a new BlockValue with the block moved (immutable).
|
||||
*/
|
||||
export function reorderBlockInValue(blockValue: BlockValue, blockKey: string, toIndex: number): BlockValue {
|
||||
const layoutKey = Object.keys(blockValue.layout)[0];
|
||||
if (!layoutKey) return blockValue;
|
||||
|
||||
const existingLayout = blockValue.layout[layoutKey];
|
||||
if (!existingLayout) return blockValue;
|
||||
|
||||
const fromIndex = existingLayout.findIndex((entry) => entry.contentKey === blockKey);
|
||||
if (fromIndex === -1 || fromIndex === toIndex) return blockValue;
|
||||
|
||||
const newLayout = [...existingLayout];
|
||||
const [moved] = newLayout.splice(fromIndex, 1);
|
||||
newLayout.splice(toIndex, 0, moved);
|
||||
|
||||
return {
|
||||
...blockValue,
|
||||
layout: { ...blockValue.layout, [layoutKey]: newLayout },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a block has a settings entry in the value structure.
|
||||
* If the layout entry has no settingsKey, creates a new settings data entry and
|
||||
* sets the settingsKey on the layout. Returns the updated value and the settingsKey.
|
||||
*/
|
||||
export function ensureBlockSettings(
|
||||
blockValue: BlockValue,
|
||||
blockKey: string,
|
||||
settingsElementTypeKey: string,
|
||||
): { updatedValue: BlockValue; settingsKey: string } {
|
||||
const layoutKey = Object.keys(blockValue.layout)[0];
|
||||
if (!layoutKey) throw new Error('No layout key found');
|
||||
|
||||
const existingLayout = blockValue.layout[layoutKey] ?? [];
|
||||
const layoutEntry = existingLayout.find((entry) => entry.contentKey === blockKey);
|
||||
if (!layoutEntry) throw new Error(`No layout entry for block ${blockKey}`);
|
||||
|
||||
// Already has settings — return as-is
|
||||
if (layoutEntry.settingsKey) {
|
||||
return { updatedValue: blockValue, settingsKey: layoutEntry.settingsKey as string };
|
||||
}
|
||||
|
||||
const settingsKey = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
updatedValue: {
|
||||
...blockValue,
|
||||
layout: {
|
||||
...blockValue.layout,
|
||||
[layoutKey]: existingLayout.map((entry) =>
|
||||
entry.contentKey === blockKey ? { ...entry, settingsKey } : entry,
|
||||
),
|
||||
},
|
||||
settingsData: [
|
||||
...blockValue.settingsData,
|
||||
{
|
||||
key: settingsKey,
|
||||
contentTypeKey: settingsElementTypeKey,
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
settingsKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a block from the value structure by its content key.
|
||||
* Returns a new BlockValue with the block removed (immutable).
|
||||
*/
|
||||
export function removeBlockFromValue(blockValue: BlockValue, blockKey: string): BlockValue {
|
||||
const layoutKey = Object.keys(blockValue.layout)[0];
|
||||
const existingLayout = layoutKey ? (blockValue.layout[layoutKey] ?? []) : [];
|
||||
|
||||
// Find the layout entry (may be nested in grid areas) to get the settingsKey before removing
|
||||
const layoutEntry = findLayoutEntryInAreas(existingLayout, blockKey);
|
||||
const settingsKey = layoutEntry?.settingsKey;
|
||||
|
||||
const newLayout = removeLayoutEntryFromAreas(existingLayout, blockKey);
|
||||
|
||||
return {
|
||||
...blockValue,
|
||||
layout: layoutKey ? { ...blockValue.layout, [layoutKey]: newLayout } : blockValue.layout,
|
||||
contentData: blockValue.contentData.filter((b) => b.key !== blockKey),
|
||||
settingsData: settingsKey
|
||||
? blockValue.settingsData.filter((s) => s.key !== settingsKey)
|
||||
: blockValue.settingsData,
|
||||
expose: blockValue.expose.filter((e) => e.contentKey !== blockKey),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new block to a block value structure at the given index.
|
||||
* Returns a new BlockValue with the block added (immutable).
|
||||
*/
|
||||
export function addBlockToValue(
|
||||
blockValue: BlockValue,
|
||||
contentTypeKey: string,
|
||||
insertIndex: number,
|
||||
editorAlias: string = UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS,
|
||||
blockTypeAreas?: Array<{ key: string }>,
|
||||
settingsElementTypeKey?: string,
|
||||
): { updatedValue: BlockValue; contentKey: string } {
|
||||
const contentKey = crypto.randomUUID();
|
||||
|
||||
// Add to contentData
|
||||
const newContentData = [
|
||||
...blockValue.contentData,
|
||||
{
|
||||
key: contentKey,
|
||||
contentTypeKey,
|
||||
values: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Add settings entry if the block type has a settings element type
|
||||
let newSettingsData = [...blockValue.settingsData];
|
||||
let settingsKey: string | undefined;
|
||||
if (settingsElementTypeKey) {
|
||||
settingsKey = crypto.randomUUID();
|
||||
newSettingsData = [
|
||||
...newSettingsData,
|
||||
{
|
||||
key: settingsKey,
|
||||
contentTypeKey: settingsElementTypeKey,
|
||||
values: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Add to layout at the insert index
|
||||
const layoutKey = Object.keys(blockValue.layout)[0] ?? editorAlias;
|
||||
const existingLayout = blockValue.layout[layoutKey] ?? [];
|
||||
const newLayout = [...existingLayout];
|
||||
const layoutEntry: BlockValueLayout = { contentKey };
|
||||
|
||||
if (settingsKey) {
|
||||
layoutEntry.settingsKey = settingsKey;
|
||||
}
|
||||
|
||||
// Block Grid requires rowSpan and columnSpan on layout items
|
||||
if (layoutKey === UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS) {
|
||||
const gridEntry = layoutEntry as BlockValueLayout & { columnSpan: number; rowSpan: number };
|
||||
gridEntry.columnSpan = 12;
|
||||
gridEntry.rowSpan = 1;
|
||||
// Initialize areas from the block type configuration so layout blocks
|
||||
// (e.g. two-column layouts) render their area containers correctly.
|
||||
gridEntry.areas = (blockTypeAreas ?? []).map((a) => ({ key: a.key, items: [] }));
|
||||
}
|
||||
|
||||
newLayout.splice(insertIndex, 0, layoutEntry);
|
||||
|
||||
// Add to expose
|
||||
const newExpose = [...blockValue.expose, { contentKey, culture: null, segment: null }];
|
||||
|
||||
return {
|
||||
updatedValue: {
|
||||
...blockValue,
|
||||
layout: { ...blockValue.layout, [layoutKey]: newLayout },
|
||||
contentData: newContentData,
|
||||
settingsData: newSettingsData,
|
||||
expose: newExpose,
|
||||
},
|
||||
contentKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new block to a specific area of a parent block in a block grid.
|
||||
* Finds the parent block in the layout, matches the area by alias using
|
||||
* the block type config, and inserts the new block into that area's items.
|
||||
* Returns a new BlockValue with the block added (immutable).
|
||||
*/
|
||||
export function addBlockToArea(
|
||||
blockValue: BlockValue,
|
||||
parentBlockKey: string,
|
||||
areaAlias: string,
|
||||
contentTypeKey: string,
|
||||
insertIndex: number,
|
||||
areaConfigs: Array<{ key: string; alias: string }>,
|
||||
): { updatedValue: BlockValue; contentKey: string } {
|
||||
const contentKey = crypto.randomUUID();
|
||||
const layoutKey = Object.keys(blockValue.layout)[0];
|
||||
if (!layoutKey) throw new Error('No layout key found');
|
||||
|
||||
// Find the area config key from the alias
|
||||
const areaConfig = areaConfigs.find((a) => a.alias === areaAlias);
|
||||
if (!areaConfig) throw new Error(`No area config found for alias "${areaAlias}"`);
|
||||
|
||||
// Add to contentData
|
||||
const newContentData = [...blockValue.contentData, { key: contentKey, contentTypeKey, values: [] }];
|
||||
|
||||
// Add to expose
|
||||
const newExpose = [...blockValue.expose, { contentKey, culture: null, segment: null }];
|
||||
|
||||
// Deep-update the layout: find parent block, find area by key, insert item
|
||||
const newLayout = (blockValue.layout[layoutKey] ?? []).map((entry) => {
|
||||
if (entry.contentKey !== parentBlockKey) return entry;
|
||||
|
||||
const areas = ((entry.areas) ?? []).map((area) => {
|
||||
if (area.key !== areaConfig.key) return area;
|
||||
const newItems = [...area.items];
|
||||
const newItem = { contentKey, columnSpan: 12, rowSpan: 1 } as BlockValueLayout & { columnSpan: number; rowSpan: number };
|
||||
newItems.splice(insertIndex, 0, newItem);
|
||||
return { ...area, items: newItems };
|
||||
});
|
||||
|
||||
return { ...entry, areas };
|
||||
});
|
||||
|
||||
return {
|
||||
updatedValue: {
|
||||
...blockValue,
|
||||
layout: { ...blockValue.layout, [layoutKey]: newLayout },
|
||||
contentData: newContentData,
|
||||
expose: newExpose,
|
||||
},
|
||||
contentKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a block from its current position to a new position.
|
||||
* Supports moving between root layout, areas, and across different areas.
|
||||
*
|
||||
* Target is specified by `targetParentBlockKey` + `targetAreaAlias`:
|
||||
* - Both null/undefined → insert into root layout
|
||||
* - Both set → insert into a specific area of a parent block
|
||||
*
|
||||
* @param areaConfigs - Area configs from the block type, needed to map alias → key
|
||||
* when the target is an area. Not needed when moving to root.
|
||||
*/
|
||||
export function moveBlock(
|
||||
blockValue: BlockValue,
|
||||
blockKey: string,
|
||||
targetIndex: number,
|
||||
targetParentBlockKey?: string,
|
||||
targetAreaAlias?: string,
|
||||
areaConfigs?: Array<{ key: string; alias: string }>,
|
||||
): BlockValue {
|
||||
const layoutKey = Object.keys(blockValue.layout)[0];
|
||||
if (!layoutKey) return blockValue;
|
||||
|
||||
const layout = blockValue.layout[layoutKey] ?? [];
|
||||
|
||||
// --- Step 1: Remove the block from its current position ---
|
||||
|
||||
let movedEntry: BlockValueLayout | undefined;
|
||||
|
||||
// Try removing from root layout
|
||||
const rootIndex = layout.findIndex((entry) => entry.contentKey === blockKey);
|
||||
let newLayout: BlockValueLayout[];
|
||||
|
||||
if (rootIndex !== -1) {
|
||||
movedEntry = layout[rootIndex];
|
||||
newLayout = layout.filter((_, i) => i !== rootIndex);
|
||||
} else {
|
||||
// Search in areas of all layout items
|
||||
newLayout = layout.map((entry) => {
|
||||
if (movedEntry) return entry; // Already found
|
||||
const areas = (entry.areas) ?? [];
|
||||
const updatedAreas = areas.map((area) => {
|
||||
if (movedEntry) return area;
|
||||
const idx = area.items.findIndex((item) => item.contentKey === blockKey);
|
||||
if (idx === -1) return area;
|
||||
movedEntry = area.items[idx];
|
||||
return { ...area, items: area.items.filter((_, i) => i !== idx) };
|
||||
});
|
||||
return { ...entry, areas: updatedAreas };
|
||||
});
|
||||
}
|
||||
|
||||
if (!movedEntry) return blockValue; // Block not found
|
||||
|
||||
// --- Step 2: Insert at the target position ---
|
||||
|
||||
if (targetParentBlockKey && targetAreaAlias && areaConfigs) {
|
||||
// Insert into a specific area
|
||||
const areaConfig = areaConfigs.find((a) => a.alias === targetAreaAlias);
|
||||
if (!areaConfig) return blockValue;
|
||||
|
||||
newLayout = newLayout.map((entry) => {
|
||||
if (entry.contentKey !== targetParentBlockKey) return entry;
|
||||
const areas = ((entry.areas) ?? []).map((area) => {
|
||||
if (area.key !== areaConfig.key) return area;
|
||||
const newItems = [...area.items];
|
||||
newItems.splice(targetIndex, 0, movedEntry!);
|
||||
return { ...area, items: newItems };
|
||||
});
|
||||
return { ...entry, areas };
|
||||
});
|
||||
} else {
|
||||
// Insert into root layout
|
||||
newLayout.splice(targetIndex, 0, movedEntry);
|
||||
}
|
||||
|
||||
return {
|
||||
...blockValue,
|
||||
layout: { ...blockValue.layout, [layoutKey]: newLayout },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a pasted block value into an existing block value at the given index.
|
||||
* Appends all contentData, settingsData, and expose entries, and inserts
|
||||
* the pasted layout entries at the specified position.
|
||||
*/
|
||||
export function mergeBlockValueInto(
|
||||
target: BlockValue,
|
||||
pasted: BlockValue,
|
||||
insertIndex: number,
|
||||
): BlockValue {
|
||||
const layoutKey = Object.keys(target.layout)[0] ?? Object.keys(pasted.layout)[0] ?? UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
const existingLayout = target.layout[layoutKey] ?? [];
|
||||
const pastedLayout = pasted.layout[layoutKey] ?? [];
|
||||
|
||||
const newLayout = [...existingLayout];
|
||||
const clampedIndex = Math.min(Math.max(insertIndex, 0), newLayout.length);
|
||||
newLayout.splice(clampedIndex, 0, ...pastedLayout);
|
||||
|
||||
return {
|
||||
layout: { ...target.layout, [layoutKey]: newLayout },
|
||||
contentData: [...target.contentData, ...pasted.contentData],
|
||||
settingsData: [...target.settingsData, ...pasted.settingsData],
|
||||
expose: [...target.expose, ...pasted.expose],
|
||||
};
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
import type { BlockValue } from './visual-editor-block-helper.js';
|
||||
import type {
|
||||
UmbBlockDataModel,
|
||||
UmbBlockLayoutBaseModel,
|
||||
UmbBlockExposeModel,
|
||||
} from '@umbraco-cms/backoffice/block';
|
||||
import type { UmbBlockManagerContext } from '@umbraco-cms/backoffice/block';
|
||||
import { UMB_BLOCK_LIST_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/block-list';
|
||||
import { UmbBlockListManagerContext } from '@umbraco-cms/backoffice/block-list';
|
||||
import { UMB_BLOCK_GRID_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/block-grid';
|
||||
import { UmbBlockGridManagerContext } from '@umbraco-cms/backoffice/block-grid';
|
||||
import { UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS } from '@umbraco-cms/backoffice/block-grid';
|
||||
import type { UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/block-type';
|
||||
import { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';
|
||||
import type { UmbPropertyEditorConfig } from '@umbraco-cms/backoffice/property-editor';
|
||||
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
|
||||
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import {
|
||||
UmbStringState,
|
||||
observeMultiple,
|
||||
} from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
import { debounceTime, firstValueFrom, filter } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
|
||||
export type BlockBridgeValueChangedCallback = (propertyAlias: string, value: BlockValue) => void;
|
||||
|
||||
export interface BlockBridgeConfig {
|
||||
/** Host element — contexts are provided here so the workspace modal can find them. */
|
||||
host: UmbControllerHost;
|
||||
/** Document property alias this bridge manages. */
|
||||
propertyAlias: string;
|
||||
/** Layout schema alias (e.g. "Umbraco.BlockList" or "Umbraco.BlockGrid"). */
|
||||
editorSchemaAlias: string;
|
||||
/** Block type configurations from the property editor config. */
|
||||
blockTypes: Array<UmbBlockTypeBaseModel>;
|
||||
/** Full property editor config (passed to the manager). */
|
||||
config: UmbPropertyEditorConfig;
|
||||
/** Called when the manager state changes after editing. */
|
||||
onValueChanged: BlockBridgeValueChangedCallback;
|
||||
/** Optional variant ID for culture/segment-aware blocks. */
|
||||
variantId?: UmbVariantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin factory wrapper that creates the appropriate block manager (list or grid)
|
||||
* and entries context for the visual editor. Replaces the previous custom
|
||||
* VisualEditorBlockManager/VisualEditorBlockEntries subclasses.
|
||||
*/
|
||||
export class VisualEditorBlockManager extends UmbControllerBase {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly #manager: UmbBlockManagerContext<any, any, any>;
|
||||
readonly #propertyAlias: string;
|
||||
readonly #editorSchemaAlias: string;
|
||||
readonly #onValueChanged: BlockBridgeValueChangedCallback;
|
||||
|
||||
#workspacePath = new UmbStringState(undefined);
|
||||
#initialized = false;
|
||||
|
||||
constructor(config: BlockBridgeConfig) {
|
||||
super(config.host, 'visualEditorBlockManager_' + config.propertyAlias);
|
||||
|
||||
this.#propertyAlias = config.propertyAlias;
|
||||
this.#editorSchemaAlias = config.editorSchemaAlias;
|
||||
this.#onValueChanged = config.onValueChanged;
|
||||
|
||||
const isGrid = config.editorSchemaAlias === UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS;
|
||||
|
||||
// Create the appropriate manager based on the editor schema alias
|
||||
if (isGrid) {
|
||||
this.#manager = new UmbBlockGridManagerContext(config.host);
|
||||
} else {
|
||||
this.#manager = new UmbBlockListManagerContext(config.host);
|
||||
}
|
||||
|
||||
this.#manager.setPropertyAlias(config.propertyAlias);
|
||||
this.#manager.setBlockTypes(config.blockTypes);
|
||||
this.#manager.setEditorConfiguration(new UmbPropertyEditorConfigCollection(config.config));
|
||||
this.#manager.setVariantId(config.variantId ?? UmbVariantId.CreateInvariant());
|
||||
|
||||
// Register workspace modal route
|
||||
const workspaceModal = isGrid ? UMB_BLOCK_GRID_WORKSPACE_MODAL : UMB_BLOCK_LIST_WORKSPACE_MODAL;
|
||||
new UmbModalRouteRegistrationController(this, workspaceModal)
|
||||
.addAdditionalPath('veBlock')
|
||||
.onSetup(() => {
|
||||
return {
|
||||
data: {
|
||||
entityType: 'block',
|
||||
preset: {},
|
||||
baseDataPath: undefined as unknown as string,
|
||||
originData: { index: -1 },
|
||||
},
|
||||
modal: { size: 'medium' },
|
||||
};
|
||||
})
|
||||
.observeRouteBuilder((routeBuilder) => {
|
||||
const path = routeBuilder({});
|
||||
this.#workspacePath.setValue(path);
|
||||
});
|
||||
|
||||
// Observe manager state and sync back on changes
|
||||
this.observe(
|
||||
(
|
||||
observeMultiple([
|
||||
this.#manager.layouts,
|
||||
this.#manager.contents,
|
||||
this.#manager.settings,
|
||||
this.#manager.exposes,
|
||||
]) as Observable<
|
||||
[
|
||||
Array<UmbBlockLayoutBaseModel>,
|
||||
Array<UmbBlockDataModel>,
|
||||
Array<UmbBlockDataModel>,
|
||||
Array<UmbBlockExposeModel>,
|
||||
]
|
||||
>
|
||||
).pipe(debounceTime(60)),
|
||||
([layouts, contents, settings, exposes]) => {
|
||||
if (!this.#initialized) return;
|
||||
|
||||
const value = {
|
||||
layout: { [this.#editorSchemaAlias]: layouts },
|
||||
contentData: contents,
|
||||
settingsData: settings,
|
||||
expose: exposes,
|
||||
} as unknown as BlockValue;
|
||||
|
||||
this.#onValueChanged(this.#propertyAlias, value);
|
||||
},
|
||||
'observeManagerState',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current property value into the manager.
|
||||
* Call this before opening a workspace to ensure the manager has fresh data.
|
||||
*/
|
||||
loadValue(blockValue: BlockValue) {
|
||||
this.#initialized = false;
|
||||
|
||||
const layouts = blockValue.layout[this.#editorSchemaAlias] ?? [];
|
||||
this.#manager.setLayouts(layouts as Array<UmbBlockLayoutBaseModel>);
|
||||
this.#manager.setContents(blockValue.contentData as Array<UmbBlockDataModel>);
|
||||
this.#manager.setSettings(blockValue.settingsData as Array<UmbBlockDataModel>);
|
||||
this.#manager.setExposes(blockValue.expose as Array<UmbBlockExposeModel>);
|
||||
|
||||
// Allow the debounced observer to start forwarding changes
|
||||
// after a short delay so the initial load doesn't trigger a callback.
|
||||
setTimeout(() => {
|
||||
this.#initialized = true;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the variant ID (e.g. when the user switches culture in the workspace).
|
||||
*/
|
||||
setVariantId(variantId: UmbVariantId | undefined) {
|
||||
this.#manager.setVariantId(variantId ?? UmbVariantId.CreateInvariant());
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the workspace path to become available.
|
||||
*/
|
||||
async #waitForWorkspacePath(): Promise<string> {
|
||||
const current = this.#workspacePath.getValue();
|
||||
if (current) return current;
|
||||
return firstValueFrom(
|
||||
(this.#workspacePath.asObservable() as Observable<string | undefined>).pipe(
|
||||
filter((p): p is string => !!p),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the block workspace to edit an existing block.
|
||||
* @returns true if navigation was initiated.
|
||||
*/
|
||||
async openEdit(blockKey: string): Promise<boolean> {
|
||||
const path = await this.#waitForWorkspacePath();
|
||||
const editPath = `${path}edit/${encodeURIComponent(blockKey)}/view/content`;
|
||||
history.pushState({}, '', editPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the block workspace to create a new block.
|
||||
* @returns true if navigation was initiated.
|
||||
*/
|
||||
async openCreate(contentElementTypeKey: string): Promise<boolean> {
|
||||
const path = await this.#waitForWorkspacePath();
|
||||
const createPath = `${path}create/${encodeURIComponent(contentElementTypeKey)}`;
|
||||
history.pushState({}, '', createPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current block value from the manager (synchronous snapshot).
|
||||
*/
|
||||
getValue(): BlockValue {
|
||||
return {
|
||||
layout: { [this.#editorSchemaAlias]: this.#manager.getLayouts() },
|
||||
contentData: this.#manager.getContents(),
|
||||
settingsData: this.#manager.getSettings(),
|
||||
expose: this.#manager.getExposes(),
|
||||
} as unknown as BlockValue;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.#manager.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Map of guest-script message types to their handler signatures.
|
||||
* Mirrors the messages sent by `src/apps/visual-editor/injected.ts`.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- keys are guest-script protocol message types */
|
||||
export type UmbVisualEditorGuestMessageHandlers = {
|
||||
'umb:ve:property-selected': (data: { propertyAlias: string }) => void;
|
||||
'umb:ve:block-selected': (data: { blockKey: string; contentTypeAlias: string }) => void;
|
||||
'umb:ve:block-add': (data: { siblingBlockKey: string; insertIndex?: number }) => void;
|
||||
'umb:ve:block-add-to-property': (data: { propertyAlias: string; insertIndex?: number }) => void;
|
||||
'umb:ve:block-add-to-area': (data: { parentBlockKey: string; areaAlias: string; insertIndex?: number }) => void;
|
||||
'umb:ve:block-move': (data: {
|
||||
blockKey: string;
|
||||
targetIndex?: number;
|
||||
targetParentBlockKey?: string;
|
||||
targetAreaAlias?: string;
|
||||
}) => void;
|
||||
'umb:ve:block-delete': (data: { blockKey: string }) => void;
|
||||
'umb:ve:block-reorder': (data: { blockKey: string; toIndex?: number }) => void;
|
||||
'umb:ve:region-map': (data: { regions?: Array<unknown> }) => void;
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
/**
|
||||
* Routes postMessage events from the visual editor guest script to typed handlers.
|
||||
* Rejects messages that do not originate from the preview iframe (origin + source checked).
|
||||
*/
|
||||
export class UmbVisualEditorMessageRouter {
|
||||
#handlers: Partial<UmbVisualEditorGuestMessageHandlers>;
|
||||
#getExpectedOrigin: () => string | undefined;
|
||||
#getExpectedSource: () => Window | null | undefined;
|
||||
|
||||
constructor(args: {
|
||||
handlers: Partial<UmbVisualEditorGuestMessageHandlers>;
|
||||
getExpectedOrigin: () => string | undefined;
|
||||
getExpectedSource: () => Window | null | undefined;
|
||||
}) {
|
||||
this.#handlers = args.handlers;
|
||||
this.#getExpectedOrigin = args.getExpectedOrigin;
|
||||
this.#getExpectedSource = args.getExpectedSource;
|
||||
}
|
||||
|
||||
readonly onMessage = (event: MessageEvent) => {
|
||||
const data = event.data;
|
||||
if (!data || data.source !== 'umb-visual-editor-guest') return;
|
||||
|
||||
const expectedOrigin = this.#getExpectedOrigin();
|
||||
if (!expectedOrigin || event.origin !== expectedOrigin) return;
|
||||
|
||||
const expectedSource = this.#getExpectedSource();
|
||||
if (!expectedSource || event.source !== expectedSource) return;
|
||||
|
||||
const handler = this.#handlers[data.type as keyof UmbVisualEditorGuestMessageHandlers];
|
||||
handler?.(data);
|
||||
};
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import { UMB_PREVIEW_CONTEXT } from '@umbraco-cms/backoffice/preview';
|
||||
import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
interface UmbPreviewIframeArgs {
|
||||
culture?: string;
|
||||
height?: string;
|
||||
segment?: string;
|
||||
width?: string;
|
||||
wrapperClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight adapter that provides UMB_PREVIEW_CONTEXT for the visual editor.
|
||||
* Delegates iframe management to the VE element's existing DOM and state,
|
||||
* allowing registered previewApp extensions to work inside the visual editor.
|
||||
*/
|
||||
export class UmbVisualEditorPreviewContext extends UmbContextBase {
|
||||
#currentArgs: UmbPreviewIframeArgs = {};
|
||||
#resizeController?: AbortController;
|
||||
|
||||
#serverUrl: string;
|
||||
#getUnique: () => string | null | undefined;
|
||||
#onUrlChange: (url: string) => void;
|
||||
|
||||
#culture = new UmbStringState(undefined);
|
||||
public readonly culture = this.#culture.asObservable();
|
||||
|
||||
#iframeReady = new UmbBooleanState(false);
|
||||
public readonly iframeReady = this.#iframeReady.asObservable();
|
||||
|
||||
#previewUrl = new UmbStringState(undefined);
|
||||
public readonly previewUrl = this.#previewUrl.asObservable();
|
||||
|
||||
#segment = new UmbStringState(undefined);
|
||||
public readonly segment = this.#segment.asObservable();
|
||||
|
||||
#unique = new UmbStringState(undefined);
|
||||
public readonly unique = this.#unique.asObservable();
|
||||
|
||||
constructor(
|
||||
host: UmbControllerHost,
|
||||
config: {
|
||||
serverUrl: string;
|
||||
getUnique: () => string | null | undefined;
|
||||
onUrlChange: (url: string) => void;
|
||||
},
|
||||
) {
|
||||
super(host, UMB_PREVIEW_CONTEXT);
|
||||
this.#serverUrl = config.serverUrl;
|
||||
this.#getUnique = config.getUnique;
|
||||
this.#onUrlChange = config.onUrlChange;
|
||||
this.#unique.setValue(config.getUnique() ?? undefined);
|
||||
}
|
||||
|
||||
override hostDisconnected() {
|
||||
super.hostDisconnected();
|
||||
this.#resizeController?.abort();
|
||||
}
|
||||
|
||||
setIframeReady(ready: boolean) {
|
||||
this.#iframeReady.setValue(ready);
|
||||
if (ready) {
|
||||
this.#setupScaling();
|
||||
}
|
||||
}
|
||||
|
||||
iframeLoaded(iframe: HTMLIFrameElement) {
|
||||
if (!iframe) return;
|
||||
this.setIframeReady(true);
|
||||
}
|
||||
|
||||
getIFrameWrapper(): HTMLElement | undefined {
|
||||
return this.getHostElement().shadowRoot?.querySelector('#wrapper') as HTMLElement;
|
||||
}
|
||||
|
||||
async updateIFrame(args?: UmbPreviewIframeArgs) {
|
||||
const mergedArgs = { ...this.#currentArgs, ...args };
|
||||
const wrapper = this.getIFrameWrapper();
|
||||
if (!wrapper) return;
|
||||
|
||||
const urlWillChange =
|
||||
(args?.culture !== undefined && mergedArgs.culture !== this.#currentArgs.culture) ||
|
||||
(args?.segment !== undefined && mergedArgs.segment !== this.#currentArgs.segment);
|
||||
|
||||
if (urlWillChange) {
|
||||
this.#iframeReady.setValue(false);
|
||||
}
|
||||
|
||||
this.#currentArgs = mergedArgs;
|
||||
|
||||
if (mergedArgs.culture) {
|
||||
this.#culture.setValue(mergedArgs.culture);
|
||||
} else {
|
||||
this.#culture.setValue(undefined);
|
||||
}
|
||||
|
||||
if (mergedArgs.segment) {
|
||||
this.#segment.setValue(mergedArgs.segment);
|
||||
} else {
|
||||
this.#segment.setValue(undefined);
|
||||
}
|
||||
|
||||
if (mergedArgs.wrapperClass) wrapper.className = mergedArgs.wrapperClass;
|
||||
if (mergedArgs.height) wrapper.style.height = mergedArgs.height;
|
||||
if (mergedArgs.width) wrapper.style.width = mergedArgs.width;
|
||||
|
||||
// Rebuild preview URL with culture/segment params and notify the VE element
|
||||
if (urlWillChange) {
|
||||
const unique = this.#getUnique();
|
||||
if (unique && this.#serverUrl) {
|
||||
const url = new URL(unique, this.#serverUrl);
|
||||
url.searchParams.set('rnd', Date.now().toString());
|
||||
if (mergedArgs.culture) url.searchParams.set('culture', mergedArgs.culture);
|
||||
if (mergedArgs.segment) url.searchParams.set('segment', mergedArgs.segment);
|
||||
const urlString = url.toString();
|
||||
this.#previewUrl.setValue(urlString);
|
||||
this.#onUrlChange(urlString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async openWebsite() {
|
||||
const unique = this.#getUnique();
|
||||
if (!unique || !this.#serverUrl) return;
|
||||
const url = new URL(unique, this.#serverUrl);
|
||||
if (this.#culture.getValue()) url.searchParams.set('culture', this.#culture.getValue()!);
|
||||
if (this.#segment.getValue()) url.searchParams.set('segment', this.#segment.getValue()!);
|
||||
window.open(url.toString(), '_blank');
|
||||
}
|
||||
|
||||
async exitPreview() {
|
||||
// No-op in the visual editor — it is not a standalone preview window.
|
||||
// The user exits by navigating away from the visual editor workspace view.
|
||||
}
|
||||
|
||||
reloadIFrame(iframe: HTMLIFrameElement) {
|
||||
iframe.contentDocument?.location.reload();
|
||||
}
|
||||
|
||||
#setupScaling() {
|
||||
this.#resizeController?.abort();
|
||||
this.#resizeController = new AbortController();
|
||||
const signal = this.#resizeController.signal;
|
||||
|
||||
const wrapper = this.getIFrameWrapper();
|
||||
if (!wrapper) return;
|
||||
|
||||
const scaleIFrame = () => {
|
||||
if (wrapper.className === 'fullsize') {
|
||||
wrapper.style.transform = '';
|
||||
} else {
|
||||
const container = wrapper.parentElement;
|
||||
if (!container) return;
|
||||
const wScale = container.offsetWidth / (wrapper.offsetWidth + 30);
|
||||
const hScale = container.offsetHeight / (wrapper.offsetHeight + 30);
|
||||
const scale = Math.min(wScale, hScale, 1);
|
||||
wrapper.style.transform = `scale(${scale})`;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', scaleIFrame, { signal });
|
||||
wrapper.addEventListener('transitionend', scaleIFrame, { signal });
|
||||
scaleIFrame();
|
||||
}
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import type {
|
||||
UmbVisualEditorPropertyGroup,
|
||||
UmbVisualEditorPropertyInfo,
|
||||
UmbVisualEditorPropertyModalData,
|
||||
UmbVisualEditorPropertyModalValue,
|
||||
} from './visual-editor-property-modal.token.js';
|
||||
import { css, customElement, html, ifDefined, nothing, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
|
||||
import { UmbValidationContext } from '@umbraco-cms/backoffice/validation';
|
||||
import type { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
|
||||
|
||||
/**
|
||||
* A sidebar modal that renders one or more property editors using
|
||||
* `<umb-property-dataset>` + `<umb-property>` for the full Umbraco editing experience.
|
||||
* Used for both single document properties and block content properties.
|
||||
* When the block has a settings element type, a separate "Settings" section is rendered.
|
||||
*/
|
||||
@customElement('umb-visual-editor-property-modal')
|
||||
export class UmbVisualEditorPropertyModalElement extends UmbModalBaseElement<
|
||||
UmbVisualEditorPropertyModalData,
|
||||
UmbVisualEditorPropertyModalValue
|
||||
> {
|
||||
@state() private _values: Array<{ alias: string; value: unknown }> = [];
|
||||
@state() private _settingsValues: Array<{ alias: string; value: unknown }> = [];
|
||||
@state() private _activeTab: 'content' | 'settings' = 'content';
|
||||
|
||||
#validationContext = new UmbValidationContext(this);
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.data) {
|
||||
this._values = [...this.data.values];
|
||||
this._settingsValues = [...(this.data.settingsValues ?? [])];
|
||||
}
|
||||
}
|
||||
|
||||
#onDatasetChange(e: UmbChangeEvent) {
|
||||
const dataset = e.target as HTMLElement & { value: Array<{ alias: string; value: unknown }> };
|
||||
this._values = dataset.value ?? [];
|
||||
}
|
||||
|
||||
#onSettingsDatasetChange(e: UmbChangeEvent) {
|
||||
const dataset = e.target as HTMLElement & { value: Array<{ alias: string; value: unknown }> };
|
||||
this._settingsValues = dataset.value ?? [];
|
||||
}
|
||||
|
||||
async #onSubmit() {
|
||||
try {
|
||||
await this.#validationContext.validate();
|
||||
} catch {
|
||||
// Validation failed — messages are shown by the property editors
|
||||
return;
|
||||
}
|
||||
|
||||
this.modalContext?.setValue({ values: this._values, settingsValues: this._settingsValues });
|
||||
this.modalContext?.submit();
|
||||
}
|
||||
|
||||
#renderProperties(properties: UmbVisualEditorPropertyInfo[]) {
|
||||
return properties.map(
|
||||
(prop) => html`
|
||||
<umb-property
|
||||
label=${prop.name}
|
||||
description=${ifDefined(prop.description)}
|
||||
alias=${prop.alias}
|
||||
property-editor-ui-alias=${prop.editorUiAlias}
|
||||
.config=${prop.config}
|
||||
.validation=${prop.validation}>
|
||||
</umb-property>
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
#renderGroupedProperties(
|
||||
properties: UmbVisualEditorPropertyInfo[],
|
||||
groups: UmbVisualEditorPropertyGroup[] | undefined,
|
||||
values: Array<{ alias: string; value: unknown }>,
|
||||
onDatasetChange: (e: UmbChangeEvent) => void,
|
||||
) {
|
||||
// Properties without a container (root-level)
|
||||
const rootProps = properties.filter((p) => !p.containerId);
|
||||
// Group the rest by containerId
|
||||
const grouped = (groups ?? []).map((group) => ({
|
||||
...group,
|
||||
properties: properties.filter((p) => p.containerId === group.id),
|
||||
})).filter((g) => g.properties.length > 0);
|
||||
|
||||
const hasGroups = grouped.length > 0;
|
||||
|
||||
return html`
|
||||
<umb-property-dataset .value=${values} @change=${onDatasetChange}>
|
||||
${rootProps.length > 0
|
||||
? html`<uui-box>${this.#renderProperties(rootProps)}</uui-box>`
|
||||
: nothing}
|
||||
${hasGroups
|
||||
? grouped.map(
|
||||
(group) => html`
|
||||
<uui-box .headline=${group.name}>
|
||||
${this.#renderProperties(group.properties)}
|
||||
</uui-box>
|
||||
`,
|
||||
)
|
||||
: !rootProps.length
|
||||
? html`<uui-box>${this.#renderProperties(properties)}</uui-box>`
|
||||
: nothing}
|
||||
</umb-property-dataset>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderContentTab() {
|
||||
return this.#renderGroupedProperties(
|
||||
this.data!.properties,
|
||||
this.data!.groups,
|
||||
this._values,
|
||||
this.#onDatasetChange.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
#renderSettingsTab() {
|
||||
return this.#renderGroupedProperties(
|
||||
this.data!.settingsProperties!,
|
||||
this.data!.settingsGroups,
|
||||
this._settingsValues,
|
||||
this.#onSettingsDatasetChange.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.data) return html``;
|
||||
|
||||
const hasSettings = this.data.settingsProperties && this.data.settingsProperties.length > 0;
|
||||
|
||||
return html`
|
||||
<umb-body-layout headline=${this.data.headline}>
|
||||
${hasSettings
|
||||
? html`
|
||||
<uui-tab-group slot="navigation">
|
||||
<uui-tab
|
||||
label="Content"
|
||||
.active=${this._activeTab === 'content'}
|
||||
@click=${() => (this._activeTab = 'content')}>
|
||||
<uui-icon slot="icon" name="icon-document"></uui-icon>
|
||||
Content
|
||||
</uui-tab>
|
||||
<uui-tab
|
||||
label="Settings"
|
||||
.active=${this._activeTab === 'settings'}
|
||||
@click=${() => (this._activeTab = 'settings')}>
|
||||
<uui-icon slot="icon" name="icon-settings"></uui-icon>
|
||||
Settings
|
||||
</uui-tab>
|
||||
</uui-tab-group>
|
||||
`
|
||||
: nothing}
|
||||
<div id="editor">
|
||||
${hasSettings
|
||||
? this._activeTab === 'content'
|
||||
? this.#renderContentTab()
|
||||
: this.#renderSettingsTab()
|
||||
: this.#renderContentTab()}
|
||||
</div>
|
||||
<div slot="actions">
|
||||
<uui-button label="Close" @click=${this._rejectModal}></uui-button>
|
||||
<uui-button label="Update" look="primary" color="positive" @click=${this.#onSubmit}></uui-button>
|
||||
</div>
|
||||
</umb-body-layout>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
css`
|
||||
uui-tab-group {
|
||||
--uui-tab-divider: var(--uui-color-border);
|
||||
border-left: 1px solid var(--uui-color-border);
|
||||
border-right: 1px solid var(--uui-color-border);
|
||||
}
|
||||
|
||||
uui-box {
|
||||
--uui-box-default-padding: 0 var(--uui-size-space-5);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
export default UmbVisualEditorPropertyModalElement;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-visual-editor-property-modal': UmbVisualEditorPropertyModalElement;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
|
||||
import type { UmbPropertyEditorConfig } from '@umbraco-cms/backoffice/property-editor';
|
||||
import type { UmbPropertyTypeValidationModel } from '@umbraco-cms/backoffice/content-type';
|
||||
|
||||
export interface UmbVisualEditorPropertyInfo {
|
||||
alias: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
editorUiAlias: string;
|
||||
config?: UmbPropertyEditorConfig;
|
||||
editableInVisualEditor?: boolean;
|
||||
validation?: UmbPropertyTypeValidationModel;
|
||||
containerId?: string | null;
|
||||
}
|
||||
|
||||
export interface UmbVisualEditorPropertyGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface UmbVisualEditorPropertyModalData {
|
||||
headline: string;
|
||||
properties: UmbVisualEditorPropertyInfo[];
|
||||
groups?: UmbVisualEditorPropertyGroup[];
|
||||
values: Array<{ alias: string; value: unknown }>;
|
||||
settingsProperties?: UmbVisualEditorPropertyInfo[];
|
||||
settingsGroups?: UmbVisualEditorPropertyGroup[];
|
||||
settingsValues?: Array<{ alias: string; value: unknown }>;
|
||||
}
|
||||
|
||||
export interface UmbVisualEditorPropertyModalValue {
|
||||
values: Array<{ alias: string; value: unknown }>;
|
||||
settingsValues?: Array<{ alias: string; value: unknown }>;
|
||||
}
|
||||
|
||||
export const UMB_VISUAL_EDITOR_PROPERTY_MODAL = new UmbModalToken<
|
||||
UmbVisualEditorPropertyModalData,
|
||||
UmbVisualEditorPropertyModalValue
|
||||
>('Umb.Modal.VisualEditorProperty', {
|
||||
modal: {
|
||||
type: 'sidebar',
|
||||
size: 'small',
|
||||
},
|
||||
});
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import type {
|
||||
UmbVisualEditorPropertyGroup,
|
||||
UmbVisualEditorPropertyInfo,
|
||||
} from './visual-editor-property-modal.token.js';
|
||||
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { DataTypeService, DocumentTypeService } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import { tryExecute } from '@umbraco-cms/backoffice/resources';
|
||||
import type { UmbPropertyEditorConfig } from '@umbraco-cms/backoffice/property-editor';
|
||||
|
||||
export interface UmbVisualEditorBlockStructure {
|
||||
name: string;
|
||||
properties: UmbVisualEditorPropertyInfo[];
|
||||
groups: UmbVisualEditorPropertyGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and caches property structures for the visual editor:
|
||||
* - Document properties across the full composition chain, indexed by alias.
|
||||
* - Block content/settings element type structures, cached by content type key.
|
||||
*/
|
||||
export class UmbVisualEditorPropertyStructureResolver extends UmbControllerBase {
|
||||
#documentProperties = new Map<string, UmbVisualEditorPropertyInfo>();
|
||||
#blockStructureCache = new Map<string, UmbVisualEditorBlockStructure>();
|
||||
|
||||
get documentProperties(): Array<UmbVisualEditorPropertyInfo> {
|
||||
return [...this.#documentProperties.values()];
|
||||
}
|
||||
|
||||
getDocumentProperty(alias: string): UmbVisualEditorPropertyInfo | undefined {
|
||||
return this.#documentProperties.get(alias);
|
||||
}
|
||||
|
||||
/** Fetch the document type (and its composition chain) and index its properties by alias. */
|
||||
async loadDocumentStructure(contentTypeUnique: string): Promise<void> {
|
||||
const fetchedIds = new Set<string>();
|
||||
const toFetch = [contentTypeUnique];
|
||||
const allProperties: Array<{
|
||||
alias: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
dataType: { id: string };
|
||||
validation: any;
|
||||
appearance?: { editableInVisualEditor?: boolean } | null;
|
||||
}> = [];
|
||||
|
||||
while (toFetch.length > 0) {
|
||||
const batch = [...toFetch];
|
||||
toFetch.length = 0;
|
||||
|
||||
for (const id of batch) {
|
||||
if (fetchedIds.has(id)) continue;
|
||||
fetchedIds.add(id);
|
||||
|
||||
const { data } = await tryExecute(this, DocumentTypeService.getDocumentTypeById({ path: { id } }));
|
||||
if (!data) continue;
|
||||
|
||||
allProperties.push(...(data.properties ?? []));
|
||||
|
||||
for (const comp of data.compositions ?? []) {
|
||||
if (!fetchedIds.has(comp.documentType.id)) {
|
||||
toFetch.push(comp.documentType.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.#documentProperties.clear();
|
||||
for (const prop of allProperties) {
|
||||
const { editorUiAlias, config } = await this.#resolveDataType(prop.dataType.id);
|
||||
|
||||
this.#documentProperties.set(prop.alias, {
|
||||
alias: prop.alias,
|
||||
name: prop.name ?? prop.alias,
|
||||
description: prop.description ?? undefined,
|
||||
editorUiAlias,
|
||||
config,
|
||||
editableInVisualEditor: prop.appearance?.editableInVisualEditor === true,
|
||||
validation: prop.validation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a block element type's properties and groups, cached by content type key. */
|
||||
async resolveBlockPropertyStructures(contentTypeKey: string): Promise<UmbVisualEditorBlockStructure> {
|
||||
const cached = this.#blockStructureCache.get(contentTypeKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const { data } = await tryExecute(this, DocumentTypeService.getDocumentTypeById({ path: { id: contentTypeKey } }));
|
||||
if (!data?.properties) return { name: 'Block', properties: [], groups: [] };
|
||||
|
||||
const groups: UmbVisualEditorPropertyGroup[] = (data.containers ?? [])
|
||||
.filter((c) => c.type === 'Group')
|
||||
.map((c) => ({ id: c.id, name: c.name ?? '', sortOrder: c.sortOrder }))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
const result: UmbVisualEditorPropertyInfo[] = [];
|
||||
for (const prop of data.properties) {
|
||||
const { editorUiAlias, config } = await this.#resolveDataType(prop.dataType.id);
|
||||
|
||||
result.push({
|
||||
alias: prop.alias,
|
||||
name: prop.name ?? prop.alias,
|
||||
description: prop.description ?? undefined,
|
||||
editorUiAlias: editorUiAlias || 'Umb.PropertyEditorUi.TextBox',
|
||||
config,
|
||||
validation: prop.validation,
|
||||
containerId: prop.container?.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = { name: data.name ?? 'Block', properties: result, groups };
|
||||
this.#blockStructureCache.set(contentTypeKey, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async #resolveDataType(
|
||||
dataTypeId: string | undefined,
|
||||
): Promise<{ editorUiAlias: string; config?: UmbPropertyEditorConfig }> {
|
||||
if (!dataTypeId) return { editorUiAlias: '' };
|
||||
|
||||
const { data } = await tryExecute(this, DataTypeService.getDataTypeById({ path: { id: dataTypeId } }));
|
||||
if (!data) return { editorUiAlias: '' };
|
||||
|
||||
return { editorUiAlias: data.editorUiAlias ?? '', config: data.values as UmbPropertyEditorConfig };
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
import { tryExecute } from '@umbraco-cms/backoffice/resources';
|
||||
import { VisualEditorService } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
export interface UmbVisualEditorRenderInput {
|
||||
unique: string;
|
||||
culture?: string;
|
||||
segment?: string;
|
||||
values: Array<{ alias: string; value: unknown; culture?: string; segment?: string }>;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Debounces visual editor edits and requests a server-side partial re-render, posting the resulting
|
||||
* HTML to the guest via the supplied callback. Latest-wins: a newer request aborts the in-flight one,
|
||||
* so stale HTML never overwrites newer DOM. On failure the last good DOM is kept (caller is notified).
|
||||
*/
|
||||
export class UmbVisualEditorRenderController extends UmbControllerBase {
|
||||
#getInput: () => UmbVisualEditorRenderInput | undefined;
|
||||
#postRender: (html: string) => void;
|
||||
#onError?: () => void;
|
||||
|
||||
#timer?: ReturnType<typeof setTimeout>;
|
||||
#abort?: AbortController;
|
||||
|
||||
constructor(
|
||||
host: UmbControllerHost,
|
||||
args: {
|
||||
getInput: () => UmbVisualEditorRenderInput | undefined;
|
||||
postRender: (html: string) => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
) {
|
||||
super(host);
|
||||
this.#getInput = args.getInput;
|
||||
this.#postRender = args.postRender;
|
||||
this.#onError = args.onError;
|
||||
}
|
||||
|
||||
/** Schedule a debounced re-render. Repeated calls within the debounce window collapse to one request. */
|
||||
requestRender() {
|
||||
if (this.#timer) clearTimeout(this.#timer);
|
||||
this.#timer = setTimeout(() => this.#render(), DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async #render() {
|
||||
const input = this.#getInput();
|
||||
if (!input?.unique) return;
|
||||
|
||||
this.#abort?.abort();
|
||||
const abort = new AbortController();
|
||||
this.#abort = abort;
|
||||
|
||||
const { data, error } = await tryExecute(
|
||||
this,
|
||||
VisualEditorService.postVisualEditorRender({
|
||||
body: {
|
||||
unique: input.unique,
|
||||
culture: input.culture,
|
||||
segment: input.segment,
|
||||
values: input.values.map((v) => ({
|
||||
alias: v.alias,
|
||||
value: v.value,
|
||||
culture: v.culture,
|
||||
segment: v.segment,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
{ abortSignal: abort.signal },
|
||||
);
|
||||
|
||||
if (abort.signal.aborted) return; // A newer render superseded this one.
|
||||
|
||||
if (error || !data) {
|
||||
console.error('[VisualEditor] Render failed', error);
|
||||
this.#onError?.();
|
||||
return;
|
||||
}
|
||||
|
||||
this.#postRender(data.html);
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
if (this.#timer) clearTimeout(this.#timer);
|
||||
this.#abort?.abort();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { HubConnectionBuilder } from '@umbraco-cms/backoffice/external/signalr';
|
||||
import type { HubConnection } from '@umbraco-cms/backoffice/external/signalr';
|
||||
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
|
||||
|
||||
/**
|
||||
* Manages the SignalR PreviewHub connection for the visual editor.
|
||||
* Invokes the supplied callback with the refreshed document key whenever the
|
||||
* server signals that preview content has changed.
|
||||
*/
|
||||
export class UmbVisualEditorSignalRController extends UmbControllerBase {
|
||||
#connection?: HubConnection;
|
||||
#onRefreshed: (documentKey: string) => void;
|
||||
#suppressUntil = 0;
|
||||
|
||||
constructor(host: UmbControllerHost, onRefreshed: (documentKey: string) => void) {
|
||||
super(host);
|
||||
this.#onRefreshed = (documentKey) => {
|
||||
if (Date.now() < this.#suppressUntil) return;
|
||||
onRefreshed(documentKey);
|
||||
};
|
||||
}
|
||||
|
||||
/** Ignore `refreshed` events for the given window — used right after a local save so the editor doesn't full-reload its own change. */
|
||||
suppressSelfReload(durationMs = 4000) {
|
||||
this.#suppressUntil = Date.now() + durationMs;
|
||||
}
|
||||
|
||||
async connect(serverUrl: string) {
|
||||
if (!serverUrl) return;
|
||||
await this.disconnect();
|
||||
|
||||
const hubUrl = `${serverUrl}/umbraco/PreviewHub`;
|
||||
this.#connection = new HubConnectionBuilder().withUrl(hubUrl).build();
|
||||
this.#connection.on('refreshed', this.#onRefreshed);
|
||||
|
||||
try {
|
||||
await this.#connection.start();
|
||||
} catch (e) {
|
||||
console.error('[VisualEditor] SignalR connection failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.#connection) {
|
||||
await this.#connection.stop();
|
||||
this.#connection = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.disconnect();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,44 @@
|
||||
import { defineConfig, PluginOption } from 'vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import viteTSConfigPaths from 'vite-tsconfig-paths';
|
||||
import { build } from 'esbuild';
|
||||
|
||||
// Vite plugin that builds the visual editor injected script (IIFE bundle)
|
||||
// using esbuild. In dev, Vite's own file watcher triggers a rebuild on change
|
||||
// (no esbuild --watch needed, avoiding file lock conflicts on Windows).
|
||||
function visualEditorInjectedPlugin(): PluginOption {
|
||||
const entry = 'src/apps/visual-editor/injected.ts';
|
||||
const outfile = '../Umbraco.Cms.StaticAssets/wwwroot/umbraco/backoffice/apps/visual-editor/injected.js';
|
||||
const esbuildOptions = { entryPoints: [entry], bundle: true, format: 'iife' as const, outfile };
|
||||
let isServe = false;
|
||||
|
||||
async function rebuild() {
|
||||
try {
|
||||
await build({ ...esbuildOptions, minify: !isServe });
|
||||
} catch (e) {
|
||||
// Log but don't crash the dev server on build errors
|
||||
console.error('[visual-editor-injected]', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'visual-editor-injected',
|
||||
config(_cfg, { command }) {
|
||||
isServe = command === 'serve';
|
||||
},
|
||||
async buildStart() {
|
||||
await rebuild();
|
||||
},
|
||||
async configureServer(server) {
|
||||
server.watcher.add(entry);
|
||||
server.watcher.on('change', (path) => {
|
||||
if (path.replace(/\\/g, '/').endsWith('apps/visual-editor/injected.ts')) {
|
||||
rebuild();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const plugins: PluginOption[] = [
|
||||
viteStaticCopy({
|
||||
@@ -45,6 +83,7 @@ export const plugins: PluginOption[] = [
|
||||
],
|
||||
}),
|
||||
viteTSConfigPaths(),
|
||||
visualEditorInjectedPlugin(),
|
||||
];
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
Added the following direct dependency due to Microsoft.EntityFrameworkCore.Design having a dependency on an insecure version.
|
||||
Review for removal when Microsoft.EntityFrameworkCore.Design is updated to a newer version.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.Build.Tasks.Core" PrivateAssets="all" Version="17.14.28" />
|
||||
<PackageReference Include="Umbraco.TheStarterKit" Version="18.0.0-rc" />
|
||||
<PackageReference Include="Microsoft.Build.Tasks.Core" PrivateAssets="all" Version="18.4.0" />
|
||||
<!--
|
||||
Added to align Microsoft.CodeAnalysis.* transitive versions brought in by Microsoft.EntityFrameworkCore.Design,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Home>
|
||||
@{
|
||||
Layout = "_Master.cshtml";
|
||||
var backgroundImage = Model.HeroBackgroundImage != null ? Model.HeroBackgroundImage.Url() : String.Empty;
|
||||
}
|
||||
<section class="section section--full-height background-image-full overlay overlay--dark section--content-center section--thick-border"
|
||||
style="background-image: url('@backgroundImage')">
|
||||
<div class="section__hero-content">
|
||||
<h1>@Model.HeroHeader</h1>
|
||||
<p class="section__description">@Model.HeroDescription</p>
|
||||
@if (Model.HeroCtalink != null)
|
||||
{
|
||||
<a class="button button--border--solid" href="@Model.HeroCtalink.Url()">
|
||||
@Model.HeroCtacaption
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section section">
|
||||
@await Html.GetBlockGridHtmlAsync(Model, "bodyText")
|
||||
</section>
|
||||
|
||||
<section class="section section--themed">
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<div class="ta-center">
|
||||
<h2>@Model.FooterHeader</h2>
|
||||
<p class="section__description mw-640 ma-h-auto">@Model.FooterDescription</p>
|
||||
|
||||
<a class="button button--border--light_solid" href="@Model.FooterCtalink.Url()">
|
||||
@Model.FooterCtacaption
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
@@ -1,10 +1,10 @@
|
||||
@using Umbraco.Extensions
|
||||
@using Umbraco.Extensions
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridArea>
|
||||
|
||||
<div class="umb-block-grid__area"
|
||||
data-area-col-span="@Model.ColumnSpan"
|
||||
data-area-row-span="@Model.RowSpan"
|
||||
data-area-alias="@Model.Alias"
|
||||
style="--umb-block-grid--grid-columns: @Model.ColumnSpan;--umb-block-grid--area-column-span: @Model.ColumnSpan; --umb-block-grid--area-row-span: @Model.RowSpan;">
|
||||
style="--umb-block-grid--grid-columns: @Model.ColumnSpan;--umb-block-grid--area-column-span: @Model.ColumnSpan; --umb-block-grid--area-row-span: @Model.RowSpan; flex-basis: @(Model.ColumnSpan / 12)">
|
||||
@await Html.GetBlockGridItemsHtmlAsync(Model)
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using Umbraco.Extensions
|
||||
@using Umbraco.Extensions
|
||||
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
|
||||
@{
|
||||
if (Model?.Any() != true) { return; }
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
if (block?.ContentKey == null) { continue; }
|
||||
var data = block.Content;
|
||||
|
||||
@await Html.PartialAsync("blocklist/Components/" + data.ContentType.Alias, block)
|
||||
<div data-umb-block-key="@block.ContentKey" data-umb-content-type="@data.ContentType.Alias">
|
||||
@await Html.PartialAsync("blocklist/Components/" + data.ContentType.Alias, block)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
using System.Collections;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Builders.Extensions;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
|
||||
using Language = Umbraco.Cms.Core.Models.Language;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PublishedCache;
|
||||
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
|
||||
internal sealed class VisualEditorContentFactoryTests : UmbracoIntegrationTest
|
||||
{
|
||||
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
||||
|
||||
private IContentService ContentService => GetRequiredService<IContentService>();
|
||||
|
||||
private IDataTypeService DataTypeService => GetRequiredService<IDataTypeService>();
|
||||
|
||||
private IConfigurationEditorJsonSerializer ConfigurationEditorJsonSerializer => GetRequiredService<IConfigurationEditorJsonSerializer>();
|
||||
|
||||
private PropertyEditorCollection PropertyEditorCollection => GetRequiredService<PropertyEditorCollection>();
|
||||
|
||||
private ILanguageService LanguageService => GetRequiredService<ILanguageService>();
|
||||
|
||||
private IVisualEditorContentFactory VisualEditorContentFactory => GetRequiredService<IVisualEditorContentFactory>();
|
||||
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateWithOverrides_Returns_Converted_Unsaved_Values()
|
||||
{
|
||||
var elementType = ContentTypeBuilder.CreateAllTypesContentType("spikeElement", "Spike Element");
|
||||
elementType.IsElement = true;
|
||||
await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey);
|
||||
|
||||
var blockListDataType = new DataType(PropertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], ConfigurationEditorJsonSerializer)
|
||||
{
|
||||
ConfigurationData = new Dictionary<string, object>
|
||||
{
|
||||
{
|
||||
"blocks",
|
||||
new BlockListConfiguration.BlockConfiguration[] { new() { ContentElementTypeKey = elementType.Key } }
|
||||
}
|
||||
},
|
||||
Name = "Spike Block List",
|
||||
DatabaseType = ValueStorageType.Ntext,
|
||||
ParentId = Constants.System.Root,
|
||||
CreateDate = DateTime.UtcNow,
|
||||
};
|
||||
await DataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey);
|
||||
|
||||
var contentType = new ContentTypeBuilder()
|
||||
.WithAlias("spikePage")
|
||||
.WithName("Spike Page")
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content")
|
||||
.WithName("Content")
|
||||
.WithSupportsPublishing(true)
|
||||
.AddPropertyType()
|
||||
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox)
|
||||
.WithDataTypeId(Constants.DataTypes.Textbox)
|
||||
.WithValueStorageType(ValueStorageType.Nvarchar)
|
||||
.WithAlias("title").WithName("Title").Done()
|
||||
.AddPropertyType()
|
||||
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.RichText)
|
||||
.WithDataTypeId(Constants.DataTypes.RichtextEditor)
|
||||
.WithValueStorageType(ValueStorageType.Ntext)
|
||||
.WithAlias("rte").WithName("RTE").Done()
|
||||
.AddPropertyType()
|
||||
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.BlockList)
|
||||
.WithDataTypeId(blockListDataType.Id)
|
||||
.WithValueStorageType(ValueStorageType.Ntext)
|
||||
.WithAlias("blocks").WithName("Blocks").Done()
|
||||
.Done()
|
||||
.Build();
|
||||
Assert.IsTrue((await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey)).Success);
|
||||
|
||||
var content = new ContentBuilder()
|
||||
.WithContentType(contentType)
|
||||
.WithName("Spike Doc")
|
||||
.WithPropertyValues(new { title = "Original title" })
|
||||
.Build();
|
||||
Assert.IsTrue(ContentService.Save(content).Success);
|
||||
Assert.IsTrue(ContentService.Publish(content, []).Success);
|
||||
|
||||
var blockContentKey = Guid.NewGuid();
|
||||
var blockListEditorValue = new BlockListValue
|
||||
{
|
||||
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
|
||||
{
|
||||
{ Constants.PropertyEditors.Aliases.BlockList, new IBlockLayoutItem[] { new BlockListLayoutItem { ContentKey = blockContentKey } } }
|
||||
},
|
||||
ContentData =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Key = blockContentKey,
|
||||
ContentTypeAlias = elementType.Alias,
|
||||
ContentTypeKey = elementType.Key,
|
||||
Values = [ new() { Alias = "singleLineText", Value = "Block text value" } ]
|
||||
}
|
||||
],
|
||||
Expose = [ new(blockContentKey, null, null) ]
|
||||
};
|
||||
|
||||
var overrides = new[]
|
||||
{
|
||||
new VisualEditorPropertyOverride("title", "Overridden title", null, null),
|
||||
new VisualEditorPropertyOverride("rte", new RichTextEditorValue { Markup = "<p>Overridden rich text</p>", Blocks = null }, null, null),
|
||||
new VisualEditorPropertyOverride("blocks", blockListEditorValue, null, null),
|
||||
};
|
||||
|
||||
IPublishedContent? result = await VisualEditorContentFactory.CreateWithOverridesAsync(content.Key, overrides);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("Overridden title", result!.Value("title"));
|
||||
StringAssert.Contains("Overridden rich text", result.Value("rte")!.ToString());
|
||||
|
||||
var blockListModel = result.Value("blocks") as BlockListModel;
|
||||
Assert.IsNotNull(blockListModel, "Block List should convert to a BlockListModel");
|
||||
Assert.AreEqual(1, blockListModel!.Count);
|
||||
Assert.AreEqual("Block text value", blockListModel.First().Content.Value("singleLineText"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateWithOverrides_Merges_Variant_Override_Preserving_Other_Cultures()
|
||||
{
|
||||
var daDk = new Language("da-DK", "Danish");
|
||||
await LanguageService.CreateAsync(daDk, Constants.Security.SuperUserKey);
|
||||
|
||||
var contentType = new ContentTypeBuilder()
|
||||
.WithAlias("variantPage")
|
||||
.WithName("Variant Page")
|
||||
.WithContentVariation(ContentVariation.Culture)
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content")
|
||||
.WithName("Content")
|
||||
.WithSupportsPublishing(true)
|
||||
.AddPropertyType()
|
||||
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox)
|
||||
.WithDataTypeId(Constants.DataTypes.Textbox)
|
||||
.WithValueStorageType(ValueStorageType.Nvarchar)
|
||||
.WithAlias("title")
|
||||
.WithName("Title")
|
||||
.WithVariations(ContentVariation.Culture)
|
||||
.Done()
|
||||
.Done()
|
||||
.Build();
|
||||
Assert.IsTrue((await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey)).Success);
|
||||
|
||||
var content = new ContentBuilder()
|
||||
.WithContentType(contentType)
|
||||
.WithCultureName("en-US", "EN")
|
||||
.WithCultureName("da-DK", "DA")
|
||||
.WithName("Variant Doc")
|
||||
.Build();
|
||||
content.SetValue("title", "en value", culture: "en-US");
|
||||
content.SetValue("title", "da value", culture: "da-DK");
|
||||
Assert.IsTrue(ContentService.Save(content).Success);
|
||||
Assert.IsTrue(ContentService.Publish(content, ["en-US", "da-DK"]).Success);
|
||||
|
||||
var overrides = new[]
|
||||
{
|
||||
new VisualEditorPropertyOverride("title", "overridden en", "en-US", null),
|
||||
};
|
||||
|
||||
IPublishedContent? result = await VisualEditorContentFactory.CreateWithOverridesAsync(content.Key, overrides);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual("overridden en", result!.Value("title", culture: "en-US"));
|
||||
Assert.AreEqual("da value", result.Value("title", culture: "da-DK"), "da-DK value must be preserved after en-US override");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateWithOverrides_Returns_Null_For_Unknown_Document_Key()
|
||||
{
|
||||
IPublishedContent? result = await VisualEditorContentFactory.CreateWithOverridesAsync(Guid.NewGuid(), []);
|
||||
|
||||
Assert.IsNull(result);
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
|
||||
internal sealed class VisualEditorContentFactoryModelWrapTests : UmbracoIntegrationTest
|
||||
{
|
||||
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
||||
|
||||
private IContentService ContentService => GetRequiredService<IContentService>();
|
||||
|
||||
private IVisualEditorContentFactory VisualEditorContentFactory => GetRequiredService<IVisualEditorContentFactory>();
|
||||
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
builder.Services.AddUnique<IPublishedModelFactory, MarkerModelFactory>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateWithOverrides_Wraps_Result_In_ModelsBuilder_Model()
|
||||
{
|
||||
var contentType = new ContentTypeBuilder()
|
||||
.WithAlias("markerPage")
|
||||
.WithName("Marker Page")
|
||||
.AddPropertyGroup()
|
||||
.WithAlias("content")
|
||||
.WithName("Content")
|
||||
.WithSupportsPublishing(true)
|
||||
.AddPropertyType()
|
||||
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox)
|
||||
.WithDataTypeId(Constants.DataTypes.Textbox)
|
||||
.WithValueStorageType(ValueStorageType.Nvarchar)
|
||||
.WithAlias("title").WithName("Title").Done()
|
||||
.Done()
|
||||
.Build();
|
||||
Assert.IsTrue((await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey)).Success);
|
||||
|
||||
var content = new ContentBuilder()
|
||||
.WithContentType(contentType)
|
||||
.WithName("Marker Doc")
|
||||
.WithPropertyValues(new { title = "Original title" })
|
||||
.Build();
|
||||
Assert.IsTrue(ContentService.Save(content).Success);
|
||||
Assert.IsTrue(ContentService.Publish(content, []).Success);
|
||||
|
||||
var overrides = new[]
|
||||
{
|
||||
new VisualEditorPropertyOverride("title", "Overridden title", null, null),
|
||||
};
|
||||
|
||||
IPublishedContent? result = await VisualEditorContentFactory.CreateWithOverridesAsync(content.Key, overrides);
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.That(result, Is.InstanceOf<MarkerPublishedContent>(), "Result must be wrapped via IPublishedModelFactory.CreateModel");
|
||||
Assert.AreEqual("Overridden title", result!.Value("title"));
|
||||
}
|
||||
|
||||
private sealed class MarkerPublishedContent : PublishedContentWrapped
|
||||
{
|
||||
public MarkerPublishedContent(IPublishedContent content)
|
||||
: base(content)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MarkerModelFactory : IPublishedModelFactory
|
||||
{
|
||||
private readonly NoopPublishedModelFactory _noop = new();
|
||||
|
||||
public IPublishedElement CreateModel(IPublishedElement element)
|
||||
=> element is IPublishedContent content ? new MarkerPublishedContent(content) : element;
|
||||
|
||||
public IList? CreateModelList(string? alias) => _noop.CreateModelList(alias);
|
||||
|
||||
public Type GetModelType(string? alias) => _noop.GetModelType(alias);
|
||||
|
||||
public Type MapModelType(Type type) => _noop.MapModelType(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Builders.Extensions;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.Common;
|
||||
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
|
||||
internal sealed class VisualEditorRenderServiceTests : UmbracoIntegrationTest
|
||||
{
|
||||
private static readonly CapturingViewEngine Captured = new();
|
||||
|
||||
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
|
||||
|
||||
private IContentService ContentService => GetRequiredService<IContentService>();
|
||||
|
||||
private ITemplateService TemplateService => GetRequiredService<ITemplateService>();
|
||||
|
||||
private IVisualEditorRenderService RenderService => GetRequiredService<IVisualEditorRenderService>();
|
||||
|
||||
protected override void CustomTestSetup(IUmbracoBuilder builder)
|
||||
{
|
||||
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
builder.Services.AddUnique<ICompositeViewEngine>(Captured);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RenderAsync_Passes_Overridden_Content_With_Tracking_Enabled()
|
||||
{
|
||||
Attempt<ITemplate, TemplateOperationStatus> templateAttempt = await TemplateService.CreateAsync(
|
||||
"Spike Template",
|
||||
"spikeTemplate",
|
||||
"@inherit Umbraco.Cms.Web.Common.Views.UmbracoViewPage",
|
||||
Constants.Security.SuperUserKey);
|
||||
Assert.IsTrue(templateAttempt.Success);
|
||||
ITemplate template = templateAttempt.Result;
|
||||
|
||||
var contentType = new ContentTypeBuilder()
|
||||
.WithAlias("spikePage")
|
||||
.WithName("Spike Page")
|
||||
.WithDefaultTemplateId(template.Id)
|
||||
.AddAllowedTemplate().WithId(template.Id).WithAlias(template.Alias).Done()
|
||||
.AddPropertyGroup().WithName("Content").WithSupportsPublishing(true)
|
||||
.AddPropertyType()
|
||||
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox)
|
||||
.WithDataTypeId(Constants.DataTypes.Textbox)
|
||||
.WithValueStorageType(ValueStorageType.Nvarchar)
|
||||
.WithAlias("title").WithName("Title").Done()
|
||||
.Done()
|
||||
.Build();
|
||||
Assert.IsTrue((await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey)).Success);
|
||||
|
||||
var content = new ContentBuilder()
|
||||
.WithContentType(contentType)
|
||||
.WithName("Spike Doc")
|
||||
.WithPropertyValues(new { title = "Original title" })
|
||||
.Build();
|
||||
Assert.AreEqual(template.Id, content.TemplateId, "The seeded document must carry the template id.");
|
||||
Assert.IsTrue(ContentService.Save(content).Success);
|
||||
Assert.IsTrue(ContentService.Publish(content, []).Success);
|
||||
|
||||
HttpContext httpContext = GetRequiredService<IHttpContextAccessor>().HttpContext!;
|
||||
httpContext.Request.Scheme = "https";
|
||||
httpContext.Request.Host = new HostString("localhost");
|
||||
httpContext.Request.Path = "/";
|
||||
httpContext.Request.QueryString = new QueryString(string.Empty);
|
||||
httpContext.RequestServices = Services;
|
||||
|
||||
var overrides = new[] { new VisualEditorPropertyOverride("title", "Overridden title", null, null) };
|
||||
|
||||
await RenderService.RenderAsync(content.Key, null, null, overrides);
|
||||
|
||||
Assert.IsNotNull(Captured.LastModel, "The render service did not pass a model to the view engine.");
|
||||
var model = Captured.LastModel as IPublishedContent;
|
||||
Assert.IsNotNull(model);
|
||||
Assert.AreEqual("Overridden title", model!.Value("title"));
|
||||
Assert.IsTrue(Captured.TrackerEnabledDuringRender, "VisualEditorPropertyTracker must be enabled while rendering.");
|
||||
}
|
||||
|
||||
private sealed class CapturingViewEngine : ICompositeViewEngine
|
||||
{
|
||||
public object? LastModel { get; private set; }
|
||||
|
||||
public bool TrackerEnabledDuringRender { get; private set; }
|
||||
|
||||
public IReadOnlyList<IViewEngine> ViewEngines => [];
|
||||
|
||||
public ViewEngineResult FindView(Microsoft.AspNetCore.Mvc.ActionContext context, string viewName, bool isMainPage)
|
||||
=> ViewEngineResult.Found(viewName, new CapturingView(this));
|
||||
|
||||
public ViewEngineResult GetView(string? executingFilePath, string viewPath, bool isMainPage)
|
||||
=> ViewEngineResult.Found(viewPath, new CapturingView(this));
|
||||
|
||||
private void Capture(ViewContext viewContext)
|
||||
{
|
||||
LastModel = viewContext.ViewData.Model;
|
||||
TrackerEnabledDuringRender = VisualEditorPropertyTracker.IsEnabled;
|
||||
}
|
||||
|
||||
private sealed class CapturingView : IView
|
||||
{
|
||||
private readonly CapturingViewEngine _owner;
|
||||
|
||||
public CapturingView(CapturingViewEngine owner) => _owner = owner;
|
||||
|
||||
public string Path => "captured";
|
||||
|
||||
public Task RenderAsync(ViewContext context)
|
||||
{
|
||||
_owner.Capture(context);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using NUnit.Framework;
|
||||
using System.Text.Encodings.Web;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockEmptyStateTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Annotated_Container_When_Enabled_And_Editable()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Tracker_Disabled()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Not_Editable()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-grid", "bodyText", editableInVisualEditor: false);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Returns_Empty_When_Alias_Missing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", string.Empty, editableInVisualEditor: true);
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Encodes_Alias_And_Class()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "a\"b", editableInVisualEditor: true);
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Not.Contain("a\"b"));
|
||||
Assert.That(html, Does.Contain("a"b").Or.Contain("a"b"));
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockGridTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var emptyGrid = new BlockGridModel(new List<BlockGridItem>(), null);
|
||||
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns(emptyGrid);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-grid\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Property_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class BlockListTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new System.IO.StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns(BlockListModel.Empty);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Property_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class SingleBlockTemplateExtensionsTests
|
||||
{
|
||||
[TearDown]
|
||||
public void TearDown() => VisualEditorPropertyTracker.Disable();
|
||||
|
||||
private static string Render(IHtmlContent content)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
content.WriteTo(writer, HtmlEncoder.Default);
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private static IPublishedProperty NullEditableProperty(string alias, bool editable)
|
||||
{
|
||||
var propertyType = new Mock<IPublishedPropertyType>();
|
||||
propertyType.SetupGet(x => x.Alias).Returns(alias);
|
||||
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
|
||||
|
||||
var property = new Mock<IPublishedProperty>();
|
||||
property.SetupGet(x => x.Alias).Returns(alias);
|
||||
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
|
||||
property.Setup(x => x.GetValue(null, null)).Returns((object?)null);
|
||||
return property.Object;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Editable_Single_Block_In_VisualEditor_Emits_Annotated_Container()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
|
||||
var html = Render(result);
|
||||
Assert.That(html, Does.Contain("class=\"umb-single-block\""));
|
||||
Assert.That(html, Does.Contain("data-umb-block-property=\"hero\""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_NonEditable_Single_Block_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: false));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Single_Block_Outside_VisualEditor_Emits_Nothing()
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
|
||||
Assert.That(Render(result), Is.Empty);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user