Compare commits

..
Author SHA1 Message Date
Laura Neto 71cc6ee6d1 Add upgrade migration for umbExternalBlockElement relation type 2026-06-19 16:13:15 +02:00
Laura Neto af04ff3803 Provision umbExternalBlockElement relation type on fresh install 2026-06-19 16:06:13 +02:00
Laura Neto d2fa6eeaef Add umbExternalBlockElement relation type constants 2026-06-19 15:58:13 +02:00
Laura Neto fffe68543d Minor cleanup in block value index factory and references 2026-06-19 14:22:01 +02:00
Laura Neto add9878f3e Restore early-return pattern in Handle() to align with v18 2026-06-19 14:02:27 +02:00
Laura Neto 5e1d197356 Remove element key/UDI from search index
After discussion, decided not to store the element key or UDI in the index. External element content is still flattened into the block resume when IndexExternalElements is enabled, but no reference field is emitted.

- Remove ElementKeyFieldName constant from UmbracoExamineFieldNames
- Remove ExternalElementUdi emission from Handle() in BlockValuePropertyIndexValueFactoryBase
- Remove ExternalElementUdi property from RawDataItem
- Remove GetLayouts() abstract method (no longer needed after refactor)
- Refactor GetDataItems to iterate in layout order, fetching external elements via IElementService when IndexExternalElements is enabled
- Update tests: remove assertions about element UDI/key in index, remove Indexes_Shared_Element_Reference_When_ExplicitlyIndexEachNestedProperty_Is_Enabled test
2026-06-19 13:43:29 +02:00
Laura Neto 28aea9fcf7 Rename IndexSharedElements to IndexExternalElements
Aligns the config setting name with the IsExternalContent property rename.
2026-06-18 15:34:00 +02:00
Laura Neto 9f0f0fc951 Apply dotnet format fixes to changed files
Import ordering, static local function, XML doc indentation, null-coalescing
modernisations, and whitespace in test assertions.
2026-06-18 15:22:50 +02:00
Laura Neto 1dcd0a316e Add ElementIndexingNotificationHandler to queue document reindex on element change
When an element is saved or published, documents that reference it via umbElement
relations need their search index entries refreshed. The new handler listens for
ElementSavedNotification and ElementPublishedNotification and queues the element IDs
via IDeferredSearchReindexService.QueueElementReindex, which triggers the BFS
traversal in DeferredSearchReindexService to find and reindex affected documents.

ElementSavedNotification updates the internal index with draft element content
(EditedValue), keeping backoffice search current with in-progress edits.
ElementPublishedNotification updates both internal and external indexes with the
newly published element content (PublishedValue).
2026-06-18 15:02:07 +02:00
Laura Neto d38f2f2eaa Emit umbElement references and preserve external content keys in block editor
BlockValuePropertyValueEditorBase now emits UmbracoEntityReference entries for
layout items with IsExternalContent=true, creating umbElement relations when
ContentRelationsUpdate processes a save/publish notification.

BlockEditorPropertyNotificationHandlerBase.ParseKeys checked isSharedContent to
avoid replacing external element GUIDs, but the rename commit (06bf1a6a13)
updated the JSON property to isExternalContent on both server and client without
updating this guard. Replaced the stale isSharedContent check with isExternalContent.
Updated BlockEditorComponentTests to match.

Integration test DeferredSearchReindexServiceElementTests registers
ContentRelationsUpdate for ContentSaved/Published notifications and verifies
FindDocumentIdsReferencingElements returns the document referencing the element.
2026-06-18 14:27:19 +02:00
Laura Neto 0cbc32ed43 Re-index documents transitively referencing changed elements 2026-06-18 11:21:23 +02:00
Laura Neto ac728286e4 Add element reindex queue to deferred search reindex service 2026-06-18 10:40:22 +02:00
Laura Neto 4423cf2f07 Gate shared-element content indexing behind IndexSharedElements 2026-06-18 10:25:18 +02:00
Laura Neto 687674b532 Always index a positional shared-element reference field 2026-06-18 10:17:50 +02:00
Laura Neto f02432a3e6 Add IndexSharedElements indexing setting 2026-06-17 16:33:01 +02:00
kjac 9bc1ae530c Added comments to clarify retries in tests 2026-06-17 08:49:42 +02:00
leekelleher 757dea630f Rename IsSharedContent (client-side)
to `IsExternalContent`
2026-06-15 12:54:41 +01:00
kjac 06bf1a6a13 Rename IsSharedContent (server-side) 2026-06-15 12:50:28 +02:00
kjac 3774b23516 Clarify why top-level aggregation works in effect 2026-06-15 08:31:56 +02:00
kjac a7b25899a3 Fix low-hanging PR review comments 2026-06-15 07:21:50 +02:00
leekelleher 3007efc358 fix(block): address PR review feedback on client-side files
- block-catalogue-modal: add UmbDeselectedEvent import; correctly type
  #onLibraryElementDeselected parameter (was UmbSelectedEvent)
- block-catalogue-modal: fix #librarySelectableFilter to handle
  undefined documentType.unique via nullish coalesce
- block-manager: setLayouts no longer mutates incoming layout objects
  in-place; uses map+spread to ensure backwards-compat key backfill
  without side effects on the caller's array
- block-grid-to-block-copy-translator: clipboard layout key now uses
  gridLayout.key (layout identity) rather than gridLayout.contentKey,
  which would break when the same shared-content element appears in
  multiple layout entries
2026-06-02 09:15:43 +01:00
Kenn JacobsenandGitHub 1629ab2072 Merge branch 'v19/dev' into v18/feature/reusable-block-content 2026-06-02 07:32:05 +02:00
leekelleher 40b45be952 Merge branch 'main' into v18/feature/reusable-block-content 2026-06-01 18:00:38 +01:00
leekelleher d487afe748 refactor(block): rename LibraryElement to SharedContent for naming consistency
Aligns block symbols that describe a block's content being shared/referenced
with the existing 'isSharedContent' layout flag and 'sharedContentVariantState',
retiring the inconsistent 'LibraryElement' naming for that concept.

- Entry state: isLibraryElement -> isSharedContent; #libraryElementWorkspacePath
  -> #sharedContentWorkspacePath; the three entry elements' _isLibraryElement
  -> _isSharedContent.
- Manager: insertLibraryElement -> insertSharedContent; #fetchLibraryElement
  -> #fetchSharedContent; #resolvedLibraryElements(Variants)
  -> #resolvedSharedContent(Variants).
- Condition: UmbBlockEntryIsLibraryElementCondition
  -> UmbBlockEntryHasSharedContentCondition (alias 'Umb.Condition.BlockEntryHasSharedContent').
- Route segment 'library-element' -> 'library'.

Genuine Element Library feature references are intentionally kept: the
transfer/disconnect actions and modals, and the catalogue picker UI
(#hasLibraryElements, #renderLibrary, blockEditor_tabLibrary, the
{ library: { elementKey } } modal value, libraryAllowedElementTypeKeys).

Pure rename, no behaviour change.
2026-06-01 17:59:28 +01:00
leekelleher 74fe96b1cb fix(block): correct workspace tabs and submit label for library elements
When a block references a Library Element (isSharedContent: true), opening
the block workspace via the "Edit Settings" action now shows only the
"Settings" tab. The "Content" tab is hidden because the content is owned
by the shared element and is not editable in the local block workspace.

Surfaces `hasContent` on the block workspace context, and gates
the Content workspace view on a new `Umb.Condition.BlockWorkspaceHasContent`
condition — mirroring the existing `Umb.Condition.BlockWorkspaceHasSettings`
pattern. Also removes the dead `TODO_conditions` block from the Content
view manifest.
2026-06-01 16:50:27 +01:00
leekelleher 3dcf1aa028 fix(block): refresh expose observer after disconnect from element library
After "Disconnect from Element Library" the block's layout.contentKey
changes from the shared element's UUID to a fresh local content key. The
expose observer ('observeExpose' in #gotVariantId) was bound to the old
key and never re-bound because #gotVariantId only runs when variantId
changes, not when contentKey changes — leaving _hasExpose false and the
block showing a stale "Draft"/unpublished badge.

Re-running #gotVariantId alongside #observeContentData in the contentKey
observer ensures the expose subscription always targets the current key,
mirroring the existing pattern already applied for the content observer.
2026-06-01 11:52:24 +01:00
leekelleher efb20e93db fix(block): make block action href and validation data path reactive
`umb-block-action.element.ts` previously resolved `getHref()` and
`getValidationDataPath()` once in the `api` setter via `.then()`,
freezing the values for the lifetime of the action component. When a
block's `contentKey` changes at runtime (e.g. after disconnecting from
the Element Library), the edit button kept navigating to the stale path.

Add optional `hrefObservable` and `validationDataPathObservable` to
`UmbBlockAction`. When an action provides these observables the element
subscribes to them reactively; otherwise it falls back to the existing
one-shot promise path (non-breaking for third-party actions).

`UmbEditContentBlockAction` now observes `workspaceEditContentPath` and
`contentKey` from the block entry context and pushes updates into states,
resolving the stale-href bug on disconnect.

Resolves the [LK] TODO in block-action.element.ts.
2026-06-01 11:51:42 +01:00
leekelleher e0d97b498b requestTransferToElementLibrary removed the name parameter
as can be retrieved from the context itself.
2026-06-01 11:23:59 +01:00
leekelleher 1c75897f1f Backoffice: Show link icon in block entry tabs for library elements
When a block is transferred to the Element Library (a shared element), add a
<uui-icon name="link"> to the entity-frame tab to make the library/shared
status visually clearer alongside the existing purple colour theme.

Applies to block-list, block-grid, and block-single entry components.
The icon is shown conditionally when _isLibraryElement is true.
2026-06-01 10:54:01 +01:00
leekelleher 7e06c59c62 Merge branch 'main' into v18/feature/reusable-block-content 2026-06-01 08:44:01 +01:00
leekelleher e3265dbb9f Block Single: CSS selector fix 2026-05-21 11:44:40 +01:00
leekelleher a8f588d8d8 Backoffice: Fix block showing as unsupported after Transfer to Element Library
After a transfer the manager assigns a new UUID (created.unique) to the
layout's contentKey. The entry context was not re-observing content for
the new key, leaving it permanently watching the old (now-gone) content.

Two interacting issues:

1. #observeContentData() was never re-called when layout.contentKey
   changed — only when the layout key itself changed or the manager
   first connected.  A new observer on this.contentKey now re-calls it
   on every contentKey change, with this.#contentKey synced first
   (because #observeLayout() assigns it AFTER _layout.setValue() emits,
   so downstream callbacks would otherwise read the stale value).

2. The guard 'if (unsupported !== true)' permanently locked the flag once
   it was set by the transient {content:undefined, isLibrary:false}
   emission during the transfer.  Replaced with #structurallyUnsupported
   — only set by #getContentStructure / #observeBlockType when the block
   type or element type is genuinely absent — so the content observer can
   freely reset the flag for all other transitions including transfer.
2026-05-21 11:44:22 +01:00
leekelleher 5fc8031052 Merge branch 'main' into v18/feature/reusable-block-content
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/core/components/entity-frame/entity-frame.element.ts
2026-05-21 07:24:32 +01:00
leekelleher 29913bbab5 Checks published visibility for Block entry items
+ markup tweaks
2026-05-20 17:53:15 +01:00
leekelleher b8fdc1a7ad Backoffice: Rename disconnect-from-library to disconnect-from-element-library 2026-05-20 17:16:51 +01:00
leekelleher 258377dcc0 "Transfer to Library" modal updates
Pre-populates the name field.
2026-05-20 17:10:27 +01:00
leekelleher efa627e3a6 Sets the Entity Frame color for non-references 2026-05-20 16:03:21 +01:00
leekelleher c00cbca152 Backoffice: Rename transfer-to-library to transfer-to-element-library 2026-05-20 15:17:42 +01:00
leekelleher 55f9c3b795 Backoffice: Simplify insertLibraryElement in block-manager.context
Library elements do not need an expose entry — exposure is derived from
the element's own variant state. Remove the redundant fetchLibraryElement
call and expose-setting logic; the layout observer already handles the
fetch automatically when the layout is appended.
2026-05-19 14:59:29 +01:00
leekelleher 4a22245d2c Wire Library tab search in block-catalogue-modal
- Route tree selection through pickerContext.selection (unified path with
  search-result selections; removes direct writes to this.value from tree handlers)
- Observe pickerContext.selection.selection to drive this.value
- Observe pickerContext.search.query to hide tree while a search is active
- Configure selection as single-select (setMultiple(false))
- Pass selectionManager to tree props for visual selection state
- Add Umb.PickerSearchResultItem.Element manifest and element under
  src/packages/elements/picker/ so search results render correctly
2026-05-19 11:56:26 +01:00
leekelleher 0c9f19af5a Refine block-catalogue-modal Library tab
- Convert _hasLibraryElements from @state() to native private field
  (set once in connectedCallback before first render; no reactivity needed)
- Promote inline .props object to #libraryTreeProps class field
  (stable reference avoids re-setting umb-tree props on every render)
- Remove self-documenting comment from #librarySelectableFilter
- Remove stale TODO comment
2026-05-19 10:10:14 +01:00
kjac 8706f68720 Fix build errors after merges 2026-05-19 07:30:21 +02:00
leekelleher d5c06f9eec Block entries: collapse _isReferenceAttr into _isLibraryElement
The two fields were always set together to the same value across all
three entry elements. `_isReferenceAttr` existed only because `@state`
doesn't reflect to an HTML attribute. Decorating the existing
`_isLibraryElement` field with `@property({ attribute: 'is-reference',
reflect: true })` covers both jobs — it reflects to the attribute (for
the existing `:host([is-reference])` CSS) and is still read from JS by
`#updateExposedState()`.
2026-05-18 18:17:59 +01:00
leekelleher 2438ae54f2 Block Single: derive _exposed from library element variant state
Aligns block-single-entry with block-list-entry and block-grid-entry:
library-element references now compute their unpublished/draft state
from the shared element's variant state instead of the (always-missing)
expose entry. Without this, inserted Library Elements always appeared
as Draft in single-block editors.

Also sets the `is-reference` attribute when the block is a library
reference, which activates the existing `:host([is-reference])` styles.
2026-05-18 17:55:44 +01:00
leekelleher a28ca1dfbb 🧹 Linting 2026-05-18 17:34:36 +01:00
leekelleher b847bb9d37 Added umb-entity-frame to Block editor entry UI
Adds `--umb-color-reference` and `--umb-color-reference-contrast` CSS variables
2026-05-18 17:32:27 +01:00
leekelleher aaf558e4bb Fixed block delete passing contentKey where layout key is required
`UmbBlockEntriesContext.delete()` was changed earlier on this branch to
take the layout `key` (so that multiple layouts referencing one shared
contentKey can be deleted independently). Two callers still passed
`contentKey`, which made `delete` throw "Cannot delete block, missing
layout for X" the moment a user tried to remove a block:

- `UmbBlockEntryContext.delete()` — fires on user delete from the UI.
- `block-workspace.context.ts` modal-rejected handler — fires when
  cancelling a brand-new block in live-editing mode.

Both now pass the layout key.
2026-05-18 17:18:12 +01:00
leekelleher 3f9fbc6f5e feat(components): adds umb-entity-frame component + Storybook stories
Cherry picked from PR https://github.com/umbraco/Umbraco-CMS/pull/22844
2026-05-18 15:35:39 +01:00
leekelleher 981b758d37 Removed @property decorator from layout setter
The setter had no matching getter, which Lit warns about (and will error
on in a future version) for reactive properties. Since no render template
reads `this.layout` and the setter's effects flow through the entry
context's own observables, the reactive tracking is unused — dropping the
decorator silences the warning without behaviour change.

Consumers using `.layout=${x}` in Lit templates are unaffected; that's
property assignment, not attribute reflection, and doesn't require the
property to be reactive.
2026-05-18 15:25:56 +01:00
leekelleher a5947c4a35 Lifted library-allowed element-type fetch to base entries context
All four block variants (list, grid, rte, single) had the same six-line
block fetching the element-type uniques that overlap with the block
types. Moved into a protected helper `_getLibraryAllowedElementTypeKeys`
on UmbBlockEntriesContext so each variant just calls it.
2026-05-18 15:14:03 +01:00
leekelleher 655c66b8e7 Inlined transferToLibrary and disconnectFromLibrary
Both were single-use imperative helpers called only by their respective
`request*` counterparts in the same file, with no external callers. The
"request" / "do" split was speculative; folding them in reduces surface
area and matches the recent `ensureContentResolved` cleanup.
2026-05-18 15:07:21 +01:00
leekelleher e41709504f Removed ensureContentResolved
turns out it was redundant.
2026-05-18 13:55:18 +01:00
leekelleher 4d78274f43 TODO comments and prettify 2026-05-18 13:55:02 +01:00
leekelleher 5b4d9f087a Renamed sharedContentVariantStateOf to elementStateOf 2026-05-18 13:54:36 +01:00
leekelleher 4855ea884e Merge branch 'main' into v18/feature/reusable-block-content 2026-05-18 10:58:23 +01:00
leekelleher c716d27b1b feat(block): migrate library transfer/disconnect to blockAction extensions
The `#renderTransferToLibraryAction()` / `#renderDisconnectFromLibraryAction()`
render methods (and their handlers) were commented out when `main` was merged
in, leaving these flows unrendered. Migrate them to the new `blockAction`
extension type (PR #22459) so they:

- Render through `<umb-block-action-list>` like the other common actions.
- Reuse automatically across Block List, Block Grid, Single Block, and RTE
  Block editors — no per-editor code.
- Honour visibility via manifest conditions, not inline state branches.

Additions:
- `UMB_BLOCK_ENTRY_IS_LIBRARY_ELEMENT_CONDITION` — boolean-match condition
  observing the existing `context.isLibraryElement` observable. Used inverted
  by the two new actions.
- `Umb.BlockAction.TransferToLibrary` (weight 250, `icon-link`) — visible when
  `isLibraryElement` is false and the entry is not read-only.
- `Umb.BlockAction.DisconnectFromLibrary` (weight 250, `icon-unlink`) — visible
  when `isLibraryElement` is true and the entry is not read-only. The two
  actions are mutually exclusive so sharing a weight is safe.
- Two thin proxy methods on `UmbBlockEntryContext`
  (`requestTransferToLibrary()` / `requestDisconnectFromLibrary()`) mirroring
  the established `requestDelete()` pattern — actions consume only the entry
  context and call into the manager via these proxies.

Removals:
- The commented-out `#renderTransferToLibraryAction` /
  `#renderDisconnectFromLibraryAction` blocks and their handlers in
  `block-list-entry.element.ts` and `block-grid-entry.element.ts`.
2026-05-13 10:59:10 +01:00
leekelleher 63ad13d615 Merge branch 'main' into v18/feature/reusable-block-content
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/elements/publishing/publish/modal/element-publish-modal.element.ts
2026-05-13 09:18:39 +01:00
Kenn JacobsenandGitHub 0189c02d49 Elements: Contextualize variant blocks rendering for invariant content (#22790)
* Contextualize variant blocks rendering for invariant content

* Initialize local language variables in a more readable way

* Also filter out whitespace cultures

* Add XML docs.
2026-05-12 10:23:11 +02:00
leekelleher 5fa6f22457 fix(block): break circular dep between manager context and modals barrel
`UmbBlockManagerContext` imported `UMB_BLOCK_TRANSFER_TO_LIBRARY_MODAL` from
`../modals/index.js` (the barrel). That barrel transitively pulled in the
catalogue modal element, which sits downstream of the manager — creating:

  context/index → block-manager.context → modals/index
    → modals/block-catalogue/index → block-catalogue-modal.element

Import the token directly from `transfer-to-library-modal.token.ts` instead.
2026-04-29 17:44:28 +01:00
leekelleher c7eaf54e10 Merge branch 'v18/dev' into v18/feature/reusable-block-content 2026-04-29 16:53:37 +01:00
leekelleher 7eda9fd825 docs(block): add follow-up TODOs from review
- Mark `#fetchLibraryElement` for `@madsrasmussen` to replace with a batching
  manager that bundles multiple element requests into a single round-trip.
  Today's per-key fetch becomes N+1 on pages with many shared blocks.
- Update the catalogue modal TODO to reflect that the catalogue is conceptually
  a Modal/Flow extension point — not a Workspace as the previous comment
  implied. Captures the open question about an extensible "Library tab"
  surface for other content sources.
2026-04-29 16:38:39 +01:00
leekelleher 1e79a245af refactor(block): centralise library element resolution in the Block Manager
Per Niels' review: the entry context shouldn't be the place where the safety
fetch for library element content lives — there could be other call sites,
and the manager already owns the resolved-elements state.

Add a layouts observer in `UmbBlockManagerContext` that watches `_layouts`
and, for any layout where `isSharedContent` is set, kicks off
`#fetchLibraryElement(contentKey)`. The fetch already dedupes, so this is
safe to call repeatedly.

In return, drop both `_manager.ensureContentResolved(contentKey)` calls from
`UmbBlockEntryContext` (`setContentKey` and `#observeContentData`). Also
derive `#contentKey` from the observed layout so internal flows have access
to it without callers having to push it through the deprecated setter.
2026-04-29 16:38:05 +01:00
leekelleher c4d604f82f fix(block): preserve isSharedContent through clipboard copy/paste
When copying a block that references a library Element, we were stripping
`isSharedContent` from the cloned layout so that pasting always produced a
local copy. Per Niels' review feedback, the expected behaviour is the inverse:
a copied library-referencing block should paste as a reference. If the user
wants a local copy after paste, they explicitly disconnect from the library.

- Remove the `delete clonedLayout.isSharedContent` in `#copyToClipboard` for
  both block-list and block-grid.
- Branch in `_insertBlockFromPropertyValue` so layouts with `isSharedContent`
  route through the manager's `insertLibraryElement(contentKey, originData)`
  flow rather than expecting matching `contentData` (which the clipboard
  payload deliberately doesn't carry for references).
2026-04-29 16:35:52 +01:00
leekelleher d2fe0654b8 refactor(block): deprecation hygiene around contentKey setters
- Stop calling the (deprecated) `setContentKey()` from `set layout` on the
  entry elements. The layout already carries the contentKey, so internal flows
  no longer need the fallback path.
- Add `UmbDeprecation` runtime warnings to all four `set contentKey` element
  setters (list/grid/single/rte) and to `UmbBlockEntryContext.setContentKey`.
  JSDoc `@deprecated` alone is not enough — runtime warnings are required per
  the Web.UI.Client deprecation policy.
2026-04-29 16:33:54 +01:00
leekelleher 46aaad46bb refactor(block): move library transfer/disconnect handlers to Block Manager
The block entry elements (`umb-block-list-entry`, `umb-block-grid-entry`) each
duplicated the orchestration for transferring a local block's content to the
Element Library and disconnecting a referenced library element back to local
content. The handlers opened modals, scaffolded element data, called the
element repository, and finally mutated manager state — all from the UI element.

Move that logic to the manager as `requestTransferToLibrary(key)` and
`requestDisconnectFromLibrary(key)`. The "request" prefix marks the
user-confirmed flows; the bare `transferToLibrary` / `disconnectFromLibrary`
methods remain as the pure state mutations.

Entry elements now delegate to the manager, which also lets us drop the
per-element `UmbElementDetailRepository` field and the modal-related imports
from the elements.

Confirm modal headlines/labels are now passed as localization keys, letting
the modal handle its own string resolution (per Niels' review feedback).
2026-04-29 16:30:59 +01:00
leekelleher 786fb35e97 refactor(block): convert catalogue modal value to discriminated union
`UmbBlockCatalogueModalValue` was a single object with optional `create`,
`clipboard`, and `library` fields, which let invalid combinations type-check.
Convert it to a true discriminated union so consumers must narrow with `'in'`
before accessing the variant payload.

Update all four entries contexts (block-list, block-grid, block-rte, block-single)
to use `value && 'create' in value` style narrowing in their `onSubmit` handlers.
2026-04-29 16:28:21 +01:00
leekelleher 72d68acff4 refactor(block): rename layout/library APIs for consistency
- `setLayoutKey`/`getLayoutKey` → `setKey`/`getKey` on entry context
- `layoutByKey`/`getLayoutByKey` → `byKey`/`getByKey` on entries context
- `layoutKey` property → `key` on block entry elements (list/grid/single/rte)
- `data-layout-key` attribute → `data-key` on `umb-rte-block`
- `insertLibraryElementReference` → `insertLibraryElement` on manager
- `transferToLibrary`/`disconnectFromLibrary` `layoutKey` param → `key`
- `delete(layoutKey)` param → `delete(key)` on entries context
- `allowedLibraryElementTypeKeys` → `libraryAllowedElementTypeKeys` on catalogue modal data
2026-04-29 16:26:40 +01:00
leekelleher 5128568016 i18n: capitalize Element/Library in disconnect-from-library strings
Per Niels' feedback on PR #22448 — Element and Library are product nouns
and should be capitalized to distinguish from generic uses.
2026-04-29 16:12:48 +01:00
leekelleher e0ab9afc68 Merge branch 'v18/dev' of https://github.com/umbraco/Umbraco-CMS into v18/feature/reusable-block-content
# Conflicts:
#	src/Umbraco.Web.UI.Client/mocks/msw-handlers/element/block-reference.handlers.ts
#	src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.context.ts
#	src/Umbraco.Web.UI.Client/src/packages/block/block-list/context/block-list-entries.context.ts
#	src/Umbraco.Web.UI.Client/src/packages/block/block-single/context/block-single-entries.context.ts
2026-04-29 14:14:49 +01:00
kjac 979f21c476 Add tests proving that reusable content can work with RTEs too 2026-04-23 19:01:01 +02:00
leekelleher dc82922139 Merge branch 'v18/dev' into v18/feature/reusable-block-content 2026-04-15 18:35:18 +01:00
leekelleher 254aac615c feat(block): add layout key and migrate identity from contentKey to key
BREAKING: UmbBlockLayoutBaseModel now requires a `key: string` property.
Plugin code that creates layout objects without `key` will get a compile
error.

- Change UmbArrayState identity functions to use `(x) => x.key`
- Add `layout` setter on entry elements (list, grid, single, rte) that
  extracts both layoutKey and contentKey from the layout object
- Deprecate `contentKey` setter on entry elements (use `layout` instead)
- Add `layoutKey` read-only getter for sorter identity
- Add `setLayoutKey()` / `layoutByKey()` / `getLayoutByKey()` methods
- Update `transferToLibrary` and `disconnectFromLibrary` to take layoutKey
- Update delete operations to find by layout key, only remove shared
  content/settings/exposes if no other layout references the same contentKey
- Migrate grid recursive area operations to use key for identity
- Update `unique` observable on entry context to derive from layout key
- Generate new key on property value clone
- Backwards compat: `setLayouts` assigns `key ??= contentKey` for
  persisted data without key
- Strip `isSharedContent` from clipboard layout clone
- Update sorter configs and repeat key functions
2026-04-15 18:29:02 +01:00
leekelleher 450af3fd13 fix(block): address review findings for reusable block content
- Use DocumentVariantStateModel.DRAFT enum instead of magic string
  in both block-list and block-grid entry elements
- Strip isSharedContent from layout during clipboard write to prevent
  pasted blocks from incorrectly appearing as library references
- Guard #setInitialBlockExpose in disconnectFromLibrary against missing
  content type structure
- Store all element variants and resolve against active variantId for
  correct multi-culture state display
- Add already-resolved guard to #fetchLibraryElement
- Add JSDoc on isLibraryElement and sharedContentVariantState observables
- Add .trim() to transfer modal name validation
2026-04-15 14:25:07 +01:00
kjac 91f6f8a0b7 Add keys to block layout items 2026-04-14 16:19:18 +02:00
leekelleher a901550bca Merge branch 'v18/dev' into v18/feature/reusable-block-content
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/block/block/context/block-entry.context.ts
2026-04-14 08:48:19 +01:00
kjac 44fb63394e Update cache levels for property value converters. 2026-04-14 09:15:14 +02:00
kjac cfdfda6a97 Enable reusable elements in block editors, including indexing for search and output rendering 2026-04-13 12:50:06 +02:00
kjac 1f19bcbfe5 Merge branch 'v18/dev' into v18/feature/reusable-block-content 2026-04-12 11:23:40 +02:00
leekelleher ec410fabe1 fix(block): address code review findings
Critical:
- disconnectFromLibrary now sets initial expose for new local content
  and cleans up resolved variant state entry
- Extract #updateExposedState() in entry elements, called from all three
  observers (hasExpose, isLibraryElement, sharedContentVariantState) to
  prevent stale unpublished state on library blocks

Important:
- Guard #fetchLibraryElement against already-resolved elements to prevent
  redundant server requests
- Hoist UmbElementDetailRepository to class field in entry elements to
  avoid accumulating dead controllers

Suggestion:
- Fix umb-localize key attributes to use literal keys instead of
  resolved strings from localize.term()
2026-04-10 20:37:48 +01:00
leekelleher 520e4b7014 [WIP] Slice 6: Publish Awareness 2026-04-10 18:00:52 +01:00
leekelleher 35174f6a2e Slice 5: Inline Element Editing from Block Context 2026-04-10 17:56:48 +01:00
leekelleher e15cf06470 Slice 4: Disconnect from Library 2026-04-10 17:56:11 +01:00
leekelleher b07596c8cc Slice 3: Transfer to Library 2026-04-10 17:54:39 +01:00
leekelleher a443a26203 Slice 2: Insert Block from Library 2026-04-10 17:47:48 +01:00
leekelleher 3bb4d7ec5f Slice 1: Reference Model
A block layout item's contentKey can point to either local inline
content or a library element. For shared content, the `isSharedContent`
flag is set to `true`.
2026-04-10 17:39:49 +01:00
32 changed files with 770 additions and 196 deletions
@@ -9,6 +9,7 @@ namespace Umbraco.Cms.Core.Configuration.Models;
public class IndexingSettings
{
private const bool StaticExplicitlyIndexEachNestedProperty = false;
private const bool StaticIndexExternalElements = false;
private const int StaticBatchSize = 10000;
/// <summary>
@@ -17,6 +18,12 @@ public class IndexingSettings
[DefaultValue(StaticExplicitlyIndexEachNestedProperty)]
public bool ExplicitlyIndexEachNestedProperty { get; set; } = StaticExplicitlyIndexEachNestedProperty;
/// <summary>
/// Gets or sets a value indicating whether the content of external elements referenced by block editors is flattened into the index entry of referencing documents. Requires a rebuild of indexes when changed.
/// </summary>
[DefaultValue(StaticIndexExternalElements)]
public bool IndexExternalElements { get; set; } = StaticIndexExternalElements;
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
+11 -1
View File
@@ -357,6 +357,16 @@ public static partial class Constants
/// </summary>
public const string RelatedElementAlias = "umbElement";
/// <summary>
/// Name for default relation type "External Block Element".
/// </summary>
public const string RelatedExternalBlockElementName = "External Block Element";
/// <summary>
/// Alias for default relation type "External Block Element".
/// </summary>
public const string RelatedExternalBlockElementAlias = "umbExternalBlockElement";
/// <summary>
/// Name for default relation type "Relate Document On Copy".
/// </summary>
@@ -414,7 +424,7 @@ public static partial class Constants
/// Developers should not manually use these relation types since they will all be cleared whenever an entity
/// (content, media, member or element) is saved since they are auto-populated based on property values.
/// </remarks>
public static string[] AutomaticRelationTypes { get; } = { RelatedMediaAlias, RelatedMemberAlias, RelatedDocumentAlias, RelatedElementAlias };
public static string[] AutomaticRelationTypes { get; } = { RelatedMediaAlias, RelatedMemberAlias, RelatedDocumentAlias, RelatedElementAlias, RelatedExternalBlockElementAlias };
// TODO: return a list of built in types so we can use that to prevent deletion in the UI
}
@@ -22,4 +22,11 @@ public interface IDeferredSearchReindexService
/// </summary>
/// <param name="memberTypeIds">The member type IDs to reindex.</param>
void QueueMemberTypeReindex(IReadOnlyCollection<int> memberTypeIds);
/// <summary>
/// Queues a set of element node ids whose change requires re-indexing the documents that
/// (transitively) embed them via block editors.
/// </summary>
/// <param name="elementIds">The element node ids that changed.</param>
void QueueElementReindex(IReadOnlyCollection<int> elementIds);
}
@@ -5,9 +5,9 @@ using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.Examine;
@@ -86,6 +86,8 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationHandler<ContentCacheRefresherNotification, DeliveryApiContentIndexingNotificationHandler>();
builder.AddNotificationHandler<ContentTypeCacheRefresherNotification, DeliveryApiContentIndexingNotificationHandler>();
builder.AddNotificationHandler<PublicAccessCacheRefresherNotification, DeliveryApiContentIndexingNotificationHandler>();
builder.AddNotificationHandler<ElementSavedNotification, ElementIndexingNotificationHandler>();
builder.AddNotificationHandler<ElementPublishedNotification, ElementIndexingNotificationHandler>();
builder.AddNotificationHandler<MediaCacheRefresherNotification, MediaIndexingNotificationHandler>();
builder.AddNotificationHandler<MemberCacheRefresherNotification, MemberIndexingNotificationHandler>();
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
@@ -2834,6 +2834,14 @@ internal sealed class DatabaseDataCreator
Constants.ObjectTypes.ElementContainer,
false,
false);
CreateRelationTypeData(
10,
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias,
Constants.Conventions.RelationTypes.RelatedExternalBlockElementName,
null,
null,
false,
true);
}
private void CreateRelationTypeData(
@@ -103,6 +103,9 @@ public partial class UmbracoPlan : MigrationPlan
To<V_18_0_0.AddElementContainerPermissions>("{D00BB11A-DDF8-47C4-B58E-150C123BB3BB}");
To<V_18_0_0.MigrateSingleBlockList>("{74332C49-B279-4945-8943-F8F00B1F5949}");
To<V_18_0_0.AddElementSectionForAdmins>("{6FE4656E-8B8D-452F-AE2A-438A615B61BC}");
// To 19.0.0
To<V_19_0_0.AddExternalBlockElementRelationType>("{2D8F1B6E-4C3A-4E7D-9A1B-5F0C7E2D8A93}");
}
/// <summary>
@@ -0,0 +1,56 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Migrations.Install;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_19_0_0;
/// <summary>
/// Adds the "External Block Element" relation type used to track elements that are
/// embedded as external (reusable) block content, so that only documents whose index
/// includes the element's content are reindexed when the element changes.
/// </summary>
public class AddExternalBlockElementRelationType : AsyncMigrationBase
{
private readonly IRelationService _relationService;
/// <summary>
/// Initializes a new instance of the <see cref="AddExternalBlockElementRelationType"/> class.
/// </summary>
/// <param name="context">The migration context.</param>
/// <param name="relationService">The relation service used to create the relation type.</param>
public AddExternalBlockElementRelationType(IMigrationContext context, IRelationService relationService)
: base(context)
=> _relationService = relationService;
/// <inheritdoc />
protected override Task MigrateAsync()
{
IRelationType? relationType = _relationService.GetRelationTypeByAlias(
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias);
if (relationType != null)
{
return Task.CompletedTask;
}
// Generate the same unique key a fresh install would produce.
Guid key = DatabaseDataCreator.CreateUniqueRelationTypeId(
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias,
Constants.Conventions.RelationTypes.RelatedExternalBlockElementName);
// Save via the service so the repository cache is updated as well.
relationType = new RelationType(
Constants.Conventions.RelationTypes.RelatedExternalBlockElementName,
Constants.Conventions.RelationTypes.RelatedExternalBlockElementAlias,
false,
parentObjectType: null,
childObjectType: null,
isDependency: true)
{
Key = key
};
_relationService.Save(relationType);
return Task.CompletedTask;
}
}
@@ -126,7 +126,7 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
private void ParseKeys(JsonArray contentData, JsonArray settingsData, JsonObject layoutData)
{
// recurse a JSON object to find all contained block editor layouts
List<JsonObject> GetLayoutItemsRecursively(JsonObject jsonObject)
static List<JsonObject> GetLayoutItemsRecursively(JsonObject jsonObject)
{
var layoutItems = new List<JsonObject>();
if (jsonObject.ContainsKey("key") && jsonObject.ContainsKey("contentKey"))
@@ -151,7 +151,7 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
// grab keys applicable for replacement from all the layouts - that is:
// - the key of the layout itself ("key").
// - the key of the content item ("contentKey").
// - ONLY for local content; do NOT replace content item keys for shared content.
// - ONLY for local content; do NOT replace content item keys for external content.
// - the key of the settings item ("settingsKey") if present.
List<JsonObject> layoutItems = GetLayoutItemsRecursively(layoutData);
var keys = layoutItems.SelectMany(layoutItem => new[]
@@ -14,6 +14,7 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
{
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IElementService _elementService;
private readonly IOptionsMonitor<IndexingSettings> _indexingSettings;
protected BlockValuePropertyIndexValueFactoryBase(
PropertyEditorCollection propertyEditorCollection,
@@ -24,8 +25,10 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
{
_propertyEditorCollection = propertyEditorCollection;
_elementService = elementService;
_indexingSettings = indexingSettings;
}
/// <inheritdoc/>
protected override IEnumerable<IndexValue> Handle(
TSerialized deserializedPropertyValue,
IProperty property,
@@ -108,60 +111,76 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
protected abstract IEnumerable<RawDataItem> GetDataItems(TSerialized input, bool published);
/// <summary>
/// Unwraps block item data as data items.
/// Unwraps block item data as data items, in layout order.
/// </summary>
protected IEnumerable<RawDataItem> GetDataItems(IEnumerable<IBlockLayoutItem> layouts, IList<BlockItemData> contentData, IList<BlockItemVariation> expose, bool published)
{
List<RawDataItem> indexData;
if (published is false)
{
indexData = contentData.Select(ToRawData).ToList();
}
else
{
indexData = new();
foreach (BlockItemData blockItemData in contentData)
{
var exposedCultures = expose
.Where(e => e.ContentKey == blockItemData.Key)
.Select(e => e.Culture)
.ToArray();
if (exposedCultures.Any() is false)
{
continue;
}
if (exposedCultures.Contains(null)
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
{
indexData.Add(ToRawData(blockItemData));
continue;
}
indexData.Add(
ToRawData(
blockItemData.ContentTypeKey,
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture))));
}
}
IBlockLayoutItem[] layoutsAsArray = layouts as IBlockLayoutItem[] ?? layouts.ToArray();
// Get the shared element keys from all layouts.
// NOTE: While the Grid areas are modeled to contain areas within areas, in reality it cannot be configured as
// such, so this "top-level aggregation" of shared content keys works in effect.
Guid[] sharedElementKeys = layoutsAsArray
// such, so this "top-level aggregation" of layout items works in effect.
IBlockLayoutItem[] allLayouts = layoutsAsArray
.Union(layoutsAsArray.SelectMany(l => l.GetContainedLayouts()))
.Where(l => l.IsExternalContent)
.Select(l => l.ContentKey)
.ToArray();
if (sharedElementKeys.Length > 0)
var contentDataByKey = contentData.ToDictionary(d => d.Key);
Dictionary<Guid, RawDataItem>? externalDataByKey = _indexingSettings.CurrentValue.IndexExternalElements
? GetExternalElementDataItems(allLayouts, published)
: null;
foreach (IBlockLayoutItem layout in allLayouts)
{
IEnumerable<IElement> elements = _elementService.GetByIds(sharedElementKeys);
indexData.AddRange(
elements.Select(element => new RawDataItem
if (layout.IsExternalContent)
{
if (externalDataByKey?.TryGetValue(layout.ContentKey, out RawDataItem? elementData) == true)
{
yield return elementData;
}
continue;
}
if (!contentDataByKey.TryGetValue(layout.ContentKey, out BlockItemData? blockItemData))
{
continue;
}
if (published is false)
{
yield return ToRawData(blockItemData);
continue;
}
var exposedCultures = expose
.Where(e => e.ContentKey == blockItemData.Key)
.Select(e => e.Culture)
.ToArray();
if (exposedCultures.Any() is false)
{
continue;
}
if (exposedCultures.Contains(null)
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
{
yield return ToRawData(blockItemData);
continue;
}
yield return ToRawData(
blockItemData.ContentTypeKey,
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture)));
}
}
private Dictionary<Guid, RawDataItem> GetExternalElementDataItems(IBlockLayoutItem[] allLayouts, bool published)
{
Guid[] externalKeys = allLayouts.Where(l => l.IsExternalContent).Select(l => l.ContentKey).ToArray();
return _elementService.GetByIds(externalKeys)
.ToDictionary(
element => element.Key,
element => new RawDataItem
{
ContentTypeKey = element.ContentType.Key,
Properties = element
@@ -177,10 +196,7 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
: value.EditedValue,
}))
.ToArray(),
}));
}
return indexData;
});
}
/// <summary>
@@ -1,3 +1,4 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
@@ -23,7 +24,6 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
private readonly IJsonSerializer _jsonSerializer;
private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactoryCollection;
private readonly BlockEditorVarianceHandler _blockEditorVarianceHandler;
private BlockEditorValues<TValue, TLayout>? _blockEditorValues;
private readonly ILanguageService _languageService;
protected BlockValuePropertyValueEditorBase(
@@ -46,15 +46,15 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
_languageService = languageService;
}
/// <summary>
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
/// optimising subsequent retrieval of entities when parsing and converting property values.
/// </summary>
/// <remarks>
/// This method iterates through all property values associated with data editors in the provided
/// block editor data and invokes caching for referenced entities where supported by the property editor.
/// </remarks>
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
/// <summary>
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
/// optimising subsequent retrieval of entities when parsing and converting property values.
/// </summary>
/// <remarks>
/// This method iterates through all property values associated with data editors in the provided
/// block editor data and invokes caching for referenced entities where supported by the property editor.
/// </remarks>
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
"Scheduled for removal in Umbraco 19.")]
@@ -92,8 +92,8 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
protected BlockEditorValues<TValue, TLayout> BlockEditorValues
{
get => _blockEditorValues ?? throw new NullReferenceException($"The property {nameof(BlockEditorValues)} must be initialized at value editor construction");
set => _blockEditorValues = value;
get => field ?? throw new NullReferenceException($"The property {nameof(BlockEditorValues)} must be initialized at value editor construction");
set;
}
protected IEnumerable<UmbracoEntityReference> GetBlockValueReferences(TValue blockValue)
@@ -131,6 +131,16 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
}
}
IEnumerable<IBlockLayoutItem> allExternalLayoutItems = blockValue.Layout.Values
.SelectMany(layouts => layouts)
.Union(blockValue.Layout.Values.SelectMany(layouts => layouts.SelectMany(l => l.GetContainedLayouts())))
.Where(l => l.IsExternalContent);
foreach (IBlockLayoutItem layout in allExternalLayoutItems)
{
result.Add(new UmbracoEntityReference(new GuidUdi(Constants.UdiEntityType.Element, layout.ContentKey)));
}
return result;
}
@@ -225,10 +235,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
var newValue = valueEditor.FromEditor(propertyData, currentValue?.Value);
// Update the raw value since this is what will get serialized out.
if (editedValue != null)
{
editedValue.Value = newValue;
}
editedValue?.Value = newValue;
}
}
}
@@ -333,11 +340,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
item.Values = _blockEditorVarianceHandler.AlignPropertyVarianceAsync(item.Values, culture).GetAwaiter().GetResult();
foreach (BlockPropertyValue blockPropertyValue in item.Values)
{
IPropertyType? propertyType = blockPropertyValue.PropertyType;
if (propertyType is null)
{
throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(items));
}
IPropertyType? propertyType = blockPropertyValue.PropertyType ?? throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(items));
IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias];
if (propertyEditor is null)
@@ -422,7 +425,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
bool canUpdateInvariantData,
HashSet<string> allowedCultures)
{
var mergedInvariant = UpdateSourceInvariantData(source, target, canUpdateInvariantData);
BlockEditorData<TValue, TLayout>? mergedInvariant = UpdateSourceInvariantData(source, target, canUpdateInvariantData);
// if the structure (invariant) is not defined after merger, the target content does not matter
if (mergedInvariant?.Layout is null)
@@ -432,10 +435,7 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
// since we merged the invariant data (layout) before we get to this point
// we just need an empty valid object to run comparisons at this point
if (source is null)
{
source = new BlockEditorData<TValue, TLayout>([], new TValue());
}
source ??= new BlockEditorData<TValue, TLayout>([], new TValue());
// update the target with the merged invariant
target!.BlockValue.Layout = mergedInvariant.BlockValue.Layout;
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Examine;
@@ -5,11 +5,13 @@ using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.Search;
@@ -34,9 +36,11 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
private readonly ICoreScopeProvider _scopeProvider;
private readonly ILogger<DeferredSearchReindexService> _logger;
private readonly CancellationTokenSource _shutdownCts;
private readonly IRelationService _relationService;
private readonly ConcurrentDictionary<int, byte> _pendingContentTypeIds = new();
private readonly ConcurrentDictionary<int, byte> _pendingMediaTypeIds = new();
private readonly ConcurrentDictionary<int, byte> _pendingMemberTypeIds = new();
private readonly ConcurrentDictionary<int, byte> _pendingElementIds = new();
private int _processing; // 0 = idle, 1 = active
/// <summary>
@@ -51,6 +55,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
/// <param name="scopeProvider">The scope provider, used to create scopes for repository access.</param>
/// <param name="logger">The logger.</param>
/// <param name="hostApplicationLifetime">The application lifetime, used to cancel in-flight reindexing on shutdown.</param>
/// <param name="relationService">The relation service, used to traverse element-to-document relations.</param>
public DeferredSearchReindexService(
IDocumentRepository documentRepository,
IMediaRepository mediaRepository,
@@ -60,7 +65,8 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
IOptionsMonitor<IndexingSettings> indexingSettings,
ICoreScopeProvider scopeProvider,
ILogger<DeferredSearchReindexService> logger,
IHostApplicationLifetime hostApplicationLifetime)
IHostApplicationLifetime hostApplicationLifetime,
IRelationService relationService)
{
_documentRepository = documentRepository;
_mediaRepository = mediaRepository;
@@ -71,6 +77,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
_scopeProvider = scopeProvider;
_logger = logger;
_shutdownCts = CancellationTokenSource.CreateLinkedTokenSource(hostApplicationLifetime.ApplicationStopping);
_relationService = relationService;
}
/// <inheritdoc />
@@ -106,6 +113,17 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
ScheduleProcessing();
}
/// <inheritdoc />
public void QueueElementReindex(IReadOnlyCollection<int> elementIds)
{
foreach (var id in elementIds)
{
_pendingElementIds.TryAdd(id, 0);
}
ScheduleProcessing();
}
private void ScheduleProcessing()
{
if (_shutdownCts.IsCancellationRequested)
@@ -140,6 +158,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
var contentTypeIds = DrainIds(_pendingContentTypeIds);
var mediaTypeIds = DrainIds(_pendingMediaTypeIds);
var memberTypeIds = DrainIds(_pendingMemberTypeIds);
var elementIds = DrainIds(_pendingElementIds);
try
{
@@ -164,6 +183,13 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
_logger.LogInformation("Deferred reindex completed for member type IDs: {MemberTypeIds}", memberTypeIds);
}
if (elementIds.Length > 0)
{
_logger.LogInformation("Deferred reindex starting for documents referencing element IDs: {ElementIds}", elementIds);
ReindexDocumentsReferencingElements(elementIds);
_logger.LogInformation("Deferred reindex completed for documents referencing element IDs: {ElementIds}", elementIds);
}
consecutiveFailures = 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -171,6 +197,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
RequeueIds(_pendingContentTypeIds, contentTypeIds);
RequeueIds(_pendingMediaTypeIds, mediaTypeIds);
RequeueIds(_pendingMemberTypeIds, memberTypeIds);
RequeueIds(_pendingElementIds, elementIds);
throw;
}
catch (Exception ex)
@@ -180,6 +207,7 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
RequeueIds(_pendingContentTypeIds, contentTypeIds);
RequeueIds(_pendingMediaTypeIds, mediaTypeIds);
RequeueIds(_pendingMemberTypeIds, memberTypeIds);
RequeueIds(_pendingElementIds, elementIds);
if (consecutiveFailures >= MaxConsecutiveFailures)
{
@@ -263,6 +291,86 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
c => _umbracoIndexingHandler.ReIndexForMember(c));
}
private void ReindexDocumentsReferencingElements(int[] elementIds)
{
IReadOnlyCollection<int> documentIds = FindDocumentIdsReferencingElements(elementIds);
if (documentIds.Count == 0)
{
return;
}
var publishChecked = new Dictionary<int, bool>();
foreach (IEnumerable<int> batch in documentIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
var batchIds = batch.ToArray();
IContent[] documents;
using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true))
{
documents = _documentRepository.GetMany(batchIds).ToArray();
}
foreach (IContent document in documents)
{
var isPublished = false;
if (document.Published && publishChecked.TryGetValue(document.Id, out isPublished) is false)
{
isPublished = _publishStatusQueryService.HasPublishedAncestorPath(document.Key);
publishChecked[document.Id] = isPublished;
}
_umbracoIndexingHandler.ReIndexForContent(document, isPublished);
}
}
}
internal IReadOnlyCollection<int> FindDocumentIdsReferencingElements(IEnumerable<int> elementIds)
{
var visitedElementIds = new HashSet<int>();
var documentIds = new HashSet<int>();
var queue = new Queue<int>(elementIds);
while (queue.Count > 0)
{
var elementId = queue.Dequeue();
if (visitedElementIds.Add(elementId) is false)
{
continue;
}
foreach (IUmbracoEntity documentParent in GetParentEntities(elementId, UmbracoObjectTypes.Document))
{
documentIds.Add(documentParent.Id);
}
foreach (IUmbracoEntity elementParent in GetParentEntities(elementId, UmbracoObjectTypes.Element))
{
if (visitedElementIds.Contains(elementParent.Id) is false)
{
queue.Enqueue(elementParent.Id);
}
}
}
return documentIds;
}
private IEnumerable<IUmbracoEntity> GetParentEntities(int childId, UmbracoObjectTypes objectType)
{
var results = new List<IUmbracoEntity>();
var pageSize = _indexingSettings.CurrentValue.BatchSize;
long page = 0;
var total = long.MaxValue;
while (page * pageSize < total)
{
IUmbracoEntity[] items = _relationService
.GetPagedParentEntitiesByChildId(childId, page++, pageSize, out total, objectType)
.ToArray();
results.AddRange(items);
}
return results;
}
/// <summary>
/// Pages through a repository without acquiring distributed locks and invokes an action for each item.
/// </summary>
@@ -328,7 +436,8 @@ internal sealed class DeferredSearchReindexService : IDeferredSearchReindexServi
private bool HasPendingIds() =>
_pendingContentTypeIds.IsEmpty is false ||
_pendingMediaTypeIds.IsEmpty is false ||
_pendingMemberTypeIds.IsEmpty is false;
_pendingMemberTypeIds.IsEmpty is false ||
_pendingElementIds.IsEmpty is false;
private static int[] DrainIds(ConcurrentDictionary<int, byte> dictionary)
{
@@ -0,0 +1,34 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.Search;
/// <summary>
/// Queues reindexing of documents that reference elements when those elements are saved or published.
/// </summary>
internal sealed class ElementIndexingNotificationHandler :
INotificationHandler<ElementSavedNotification>,
INotificationHandler<ElementPublishedNotification>
{
private readonly IDeferredSearchReindexService _deferredSearchReindexService;
public ElementIndexingNotificationHandler(IDeferredSearchReindexService deferredSearchReindexService)
=> _deferredSearchReindexService = deferredSearchReindexService;
public void Handle(ElementSavedNotification notification)
=> QueueElementIds(notification.SavedEntities);
public void Handle(ElementPublishedNotification notification)
=> QueueElementIds(notification.PublishedEntities);
private void QueueElementIds(IEnumerable<IElement> elements)
{
var ids = elements.Select(e => e.Id).ToArray();
if (ids.Length > 0)
{
_deferredSearchReindexService.QueueElementReindex(ids);
}
}
}
@@ -135,7 +135,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
@state()
private _exposed?: boolean;
private _localExpose?: boolean;
private _hasExpose?: boolean;
// Unsupported is triggered if the Block Type is not recognized, it can also be triggered by the Content Element Type not existing any longer. [NL]
@state()
@@ -243,7 +243,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
this.observe(
this.#context.hasExpose,
(exposed) => {
this._localExpose = exposed;
this._hasExpose = exposed;
this.#updateExposedState();
},
null,
@@ -450,7 +450,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper
const isExposed = this._isExternalContent
? this._externalContentVariantState === UmbElementVariantState.PUBLISHED ||
this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES
: this._localExpose;
: this._hasExpose;
this.#updateBlockViewProps({ unpublished: !isExposed });
this._exposed = isExposed;
}
@@ -118,7 +118,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
@state()
private _exposed?: boolean;
private _localExpose?: boolean;
private _hasExpose?: boolean;
@state()
private _unsupported?: boolean;
@@ -203,7 +203,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
this.observe(
this.#context.hasExpose,
(exposed) => {
this._localExpose = exposed;
this._hasExpose = exposed;
this.#updateExposedState();
},
null,
@@ -353,7 +353,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper
const isExposed = this._isExternalContent
? this._externalContentVariantState === UmbElementVariantState.PUBLISHED ||
this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES
: this._localExpose;
: this._hasExpose;
this.#updateBlockViewProps({ unpublished: !isExposed });
this._exposed = isExposed;
}
@@ -119,7 +119,7 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp
@state()
private _exposed?: boolean;
private _localExpose?: boolean;
private _hasExpose?: boolean;
@state()
private _unsupported?: boolean;
@@ -201,7 +201,7 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp
this.observe(
this.#context.hasExpose,
(exposed) => {
this._localExpose = exposed;
this._hasExpose = exposed;
this.#updateExposedState();
},
null,
@@ -320,7 +320,7 @@ export class UmbBlockSingleEntryElement extends UmbLitElement implements UmbProp
const isExposed = this._isExternalContent
? this._externalContentVariantState === UmbElementVariantState.PUBLISHED ||
this._externalContentVariantState === UmbElementVariantState.PUBLISHED_PENDING_CHANGES
: this._localExpose;
: this._hasExpose;
this.#updateBlockViewProps({ unpublished: !isExposed });
this._exposed = isExposed;
}
@@ -15,7 +15,7 @@ export interface UmbBlockAction<ArgsMetaType> extends UmbAction<UmbBlockActionAr
* When provided, the default kind element subscribes to it and updates the link reactively,
* rather than resolving `getHref()` once at initialisation time.
*/
href?: Observable<string | undefined>;
hrefObservable?: Observable<string | undefined>;
/**
* The `execute` method, the action will act as a button.
@@ -37,5 +37,5 @@ export interface UmbBlockAction<ArgsMetaType> extends UmbAction<UmbBlockActionAr
* state controller reactively, rather than resolving `getValidationDataPath()` once at
* initialisation time.
*/
validationDataPath?: Observable<string | undefined>;
validationDataPathObservable?: Observable<string | undefined>;
}
@@ -5,7 +5,7 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
export class UmbDisconnectFromElementLibraryBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
override async execute() {
const context = await this.getContext(UMB_BLOCK_ENTRY_CONTEXT);
await context?.requestDisconnectFromExternalContent();
await context?.requestDisconnectFromElementLibrary();
}
}
@@ -6,16 +6,23 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
/** Block action that navigates to the block's content editor workspace. */
/**
* Block action that navigates to the block's content editor workspace.
* Exposes the workspace edit path via `getHref()` / `hrefObservable` and the content validation
* data path via `getValidationDataPath()` / `validationDataPathObservable`.
* The observable variants update reactively (e.g. after disconnect from Element Library),
* whereas the promise variants resolve once for consumers that call them imperatively.
*/
export class UmbEditContentBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
#context?: typeof UMB_BLOCK_ENTRY_CONTEXT.TYPE;
#contextReady: Promise<void>;
#resolveContext!: () => void;
readonly #href = new UmbStringState(undefined);
readonly href = this.#href.asObservable();
readonly hrefObservable = this.#href.asObservable();
readonly #validationDataPath = new UmbStringState(undefined);
readonly validationDataPath = this.#validationDataPath.asObservable();
readonly validationDataPathObservable = this.#validationDataPath.asObservable();
constructor(host: UmbControllerHost, args: UmbBlockActionArgs<MetaBlockActionDefaultKind>) {
super(host, args);
@@ -25,16 +32,23 @@ export class UmbEditContentBlockAction extends UmbBlockActionBase<MetaBlockActio
});
this.consumeContext(UMB_BLOCK_ENTRY_CONTEXT, (context) => {
this.#context = context;
if (!context) return;
this.#resolveContext();
this.observe(context.workspaceEditContentPath, (path) => this.#href.setValue(path || undefined), 'observeHref');
this.observe(
context.workspaceEditContentPath,
(path) => this.#href.setValue(path || undefined),
'observeHref',
);
this.observe(
context.contentKey,
(contentKey) => {
this.#validationDataPath.setValue(
contentKey ? `$.contentData[${UmbDataPathBlockElementDataQuery({ key: contentKey })}]` : undefined,
contentKey
? `$.contentData[${UmbDataPathBlockElementDataQuery({ key: contentKey })}]`
: undefined,
);
},
'observeValidationDataPath',
@@ -44,12 +58,15 @@ export class UmbEditContentBlockAction extends UmbBlockActionBase<MetaBlockActio
override async getHref() {
await this.#contextReady;
return (await this.observe(this.href)?.asPromise()) || undefined;
const path = await this.observe(this.#context?.workspaceEditContentPath)?.asPromise();
return path || undefined;
}
override async getValidationDataPath() {
await this.#contextReady;
return await this.observe(this.validationDataPath)?.asPromise();
const contentKey = await this.observe(this.#context?.contentKey)?.asPromise();
if (!contentKey) return undefined;
return `$.contentData[${UmbDataPathBlockElementDataQuery({ key: contentKey })}]`;
}
}
@@ -6,16 +6,23 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
import { UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/** Block action that navigates to the block's settings editor workspace. */
/**
* Block action that navigates to the block's settings editor workspace.
* Exposes the workspace edit path via `getHref()` / `hrefObservable` and the settings validation
* data path via `getValidationDataPath()` / `validationDataPathObservable`.
* The observable variants update reactively (e.g. after transfer to Element Library),
* whereas the promise variants resolve once for consumers that call them imperatively.
*/
export class UmbEditSettingsBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
#context?: typeof UMB_BLOCK_ENTRY_CONTEXT.TYPE;
#contextReady: Promise<void>;
#resolveContext!: () => void;
readonly #href = new UmbStringState(undefined);
readonly href = this.#href.asObservable();
readonly hrefObservable = this.#href.asObservable();
readonly #validationDataPath = new UmbStringState(undefined);
readonly validationDataPath = this.#validationDataPath.asObservable();
readonly validationDataPathObservable = this.#validationDataPath.asObservable();
constructor(host: UmbControllerHost, args: UmbBlockActionArgs<MetaBlockActionDefaultKind>) {
super(host, args);
@@ -25,6 +32,7 @@ export class UmbEditSettingsBlockAction extends UmbBlockActionBase<MetaBlockActi
});
this.consumeContext(UMB_BLOCK_ENTRY_CONTEXT, (context) => {
this.#context = context;
if (!context) return;
this.#resolveContext();
@@ -44,12 +52,15 @@ export class UmbEditSettingsBlockAction extends UmbBlockActionBase<MetaBlockActi
override async getHref() {
await this.#contextReady;
return (await this.observe(this.href)?.asPromise()) || undefined;
const path = await this.observe(this.#context?.workspaceEditSettingsPath)?.asPromise();
return path || undefined;
}
override async getValidationDataPath() {
await this.#contextReady;
return await this.observe(this.validationDataPath)?.asPromise();
const settingsKey = await this.observe(this.#context?.settingsKey)?.asPromise();
if (!settingsKey) return undefined;
return `$.settingsData[${UmbDataPathBlockElementDataQuery({ key: settingsKey })}]`;
}
}
@@ -5,7 +5,7 @@ import { UMB_BLOCK_ENTRY_CONTEXT } from '../../../context/block-entry.context-to
export class UmbTransferToElementLibraryBlockAction extends UmbBlockActionBase<MetaBlockActionDefaultKind> {
override async execute() {
const context = await this.getContext(UMB_BLOCK_ENTRY_CONTEXT);
await context?.requestTransferToExternalContent();
await context?.requestTransferToElementLibrary();
}
}
@@ -29,8 +29,8 @@ export class UmbBlockActionDefaultElement<
this.#api = api;
this._href = undefined;
if (api?.href) {
this.observe(api.href, (href) => (this._href = href), 'observeHref');
if (api?.hrefObservable) {
this.observe(api.hrefObservable, (href) => (this._href = href), 'observeHref');
} else {
this.removeUmbControllerByAlias('observeHref');
api?.getHref?.().then((href) => {
@@ -38,9 +38,9 @@ export class UmbBlockActionDefaultElement<
});
}
if (api?.validationDataPath) {
if (api?.validationDataPathObservable) {
this.observe(
api.validationDataPath,
api.validationDataPathObservable,
(path) => {
this.removeUmbControllerByAlias('observeValidation');
if (path) {
@@ -4,8 +4,9 @@ import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-a
export type BlockWorkspaceHasSettingsConditionConfig =
UmbConditionConfigBase<'Umb.Condition.BlockWorkspaceHasSettings'>;
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-object-type
export interface BlockWorkspaceHasContentConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockWorkspaceHasContent'> {}
// eslint-disable-next-line @typescript-eslint/naming-convention
export type BlockWorkspaceHasContentConditionConfig =
UmbConditionConfigBase<'Umb.Condition.BlockWorkspaceHasContent'>;
// eslint-disable-next-line @typescript-eslint/naming-convention
export interface BlockEntryShowContentEditConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockEntryShowContentEdit'> {
@@ -22,8 +23,8 @@ export interface BlockWorkspaceIsReadOnlyConditionConfig extends UmbConditionCon
match?: boolean;
}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-object-type
export interface BlockEntryHasSettingsConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockEntryHasSettings'> {}
// eslint-disable-next-line @typescript-eslint/naming-convention
export type BlockEntryHasSettingsConditionConfig = UmbConditionConfigBase<'Umb.Condition.BlockEntryHasSettings'>;
// eslint-disable-next-line @typescript-eslint/naming-convention
export interface BlockEntryHasExternalContentConditionConfig extends UmbConditionConfigBase<'Umb.Condition.BlockEntryHasExternalContent'> {
@@ -58,14 +58,17 @@ export abstract class UmbBlockEntryContext<
#contentKey?: string;
#unsupported = new UmbBooleanState(undefined);
readonly unsupported = this.#unsupported.asObservable();
/** True when unsupported was set by a structural fault (missing block type or element type structure). Prevents the content observer from resetting the flag. */
#structurallyUnsupported = false;
protected readonly localize = new UmbLocalizationController(this);
#isExternalContent = new UmbBooleanState(false);
/** Observable that emits true when this block's content is shared (referenced from the Element Library) rather than local. */
readonly isExternalContent = this.#isExternalContent.asObservable();
#externalContentVariantState = new UmbStringState(undefined);
/** Observable of the shared element's variant state (e.g. 'Published', 'Draft'), resolved for the active culture/segment. */
readonly externalContentVariantState = this.#externalContentVariantState.asObservable();
#pathAddendum = new UmbRoutePathAddendumContext(this);
@@ -379,6 +382,10 @@ export abstract class UmbBlockEntryContext<
null,
);
// Re-observe content and expose when the layout's contentKey changes (e.g., after Transfer to
// Element Library assigns a new element UUID, or Disconnect assigns a fresh local content key).
// NOTE: this.#contentKey is updated by #observeLayout() AFTER _layout.setValue() emits, so we
// must sync it here before calling the observe methods which read it.
this.observe(
this.contentKey,
(contentKey) => {
@@ -581,7 +588,7 @@ export abstract class UmbBlockEntryContext<
);
new UmbModalRouteRegistrationController(this, UMB_WORKSPACE_MODAL)
.addAdditionalPath('element')
.addAdditionalPath('library')
.addUniquePaths(['unique'])
.onSetup(() => {
return {
@@ -620,7 +627,7 @@ export abstract class UmbBlockEntryContext<
// Observe the variant state of external content (published, draft, etc.)
this.observe(
this._manager.externalContentStateOf(contentKey),
this._manager.elementStateOf(contentKey),
(state) => {
this.#externalContentVariantState.setValue(state ?? undefined);
},
@@ -834,15 +841,15 @@ export abstract class UmbBlockEntryContext<
this.delete();
}
async requestTransferToExternalContent() {
async requestTransferToElementLibrary() {
if (!this.#key) return;
const name = this.getName();
await this._manager?.requestTransferToExternalContent(this.#key, name);
await this._manager?.requestTransferToElementLibrary(this.#key, name);
}
async requestDisconnectFromExternalContent() {
async requestDisconnectFromElementLibrary() {
if (!this.#key) return;
await this._manager?.requestDisconnectFromExternalContent(this.#key);
await this._manager?.requestDisconnectFromElementLibrary(this.#key);
}
public delete() {
@@ -83,8 +83,8 @@ export abstract class UmbBlockManagerContext<
readonly #contents = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
public readonly contents = this.#contents.asObservable();
readonly #externalContentValues = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
readonly #externalContentVariants = new UmbArrayState(
readonly #resolvedExternalContent = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);
readonly #resolvedExternalContentVariants = new UmbArrayState(
<
Array<{ key: string; variants: Array<{ culture: string | null; segment: string | null; state: string | null }> }>
>[],
@@ -201,14 +201,15 @@ export abstract class UmbBlockManagerContext<
null,
);
// Auto-resolve content for any layout marked as external, whenever layouts change.
// Auto-resolve content for any layout marked as shared, whenever layouts change.
this.observe(
this._layouts.asObservable(),
(layouts) => {
const keys = layouts
.filter((layout) => layout.isExternalContent && layout.contentKey)
.map((layout) => layout.contentKey as string);
if (keys.length) this.#fetchExternalContent(keys);
layouts.forEach((layout) => {
if (layout.isExternalContent && layout.contentKey) {
this.#fetchExternalContent(layout.contentKey);
}
});
},
null,
);
@@ -327,12 +328,13 @@ export abstract class UmbBlockManagerContext<
return mergeObservables(
[
this.#contents.asObservablePart((source) => source.find((x) => x.key === key)),
this.#externalContentValues.asObservablePart((source) => source.find((x) => x.key === key)),
this.#resolvedExternalContent.asObservablePart((source) => source.find((x) => x.key === key)),
],
([localContent, externalContent]) => localContent ?? externalContent ?? undefined,
);
}
// TODO: [LK] Review the naming of this property, align with with team.
/**
* Returns an observable that emits true when the layout for the given contentKey
* has `isExternalContent` set (i.e., the block references external content).
@@ -344,13 +346,14 @@ export abstract class UmbBlockManagerContext<
}
/**
* Returns an observable of the external content variant state.
* Returns an observable of the variant state for external content,
* resolved against the manager's active variantId (culture/segment).
* Emits the state string (e.g., 'Published', 'Draft') or null if not resolved yet.
*/
externalContentStateOf(key: string) {
elementStateOf(key: string) {
return mergeObservables(
[
this.#externalContentVariants.asObservablePart((source) => source.find((x) => x.key === key)),
this.#resolvedExternalContentVariants.asObservablePart((source) => source.find((x) => x.key === key)),
this.variantId,
],
([entry, variantId]) => {
@@ -362,41 +365,41 @@ export abstract class UmbBlockManagerContext<
);
}
// TODO: migrate to UmbRepositoryDetailsManager for true batched fetches [LK]
async #fetchExternalContent(keys: Array<string>) {
for (const key of keys) {
if (this.#pendingElementFetches.has(key)) continue;
if (this.#externalContentValues.getValue().some((x) => x.key === key)) continue;
this.#pendingElementFetches.add(key);
try {
const { data } = await this.#elementRepository.requestByUnique(key);
if (data) {
const blockData: UmbBlockDataModel = {
key: data.unique,
contentTypeKey: data.documentType.unique,
values: data.values.map(
(v): UmbBlockDataValueModel => ({
alias: v.alias,
editorAlias: v.editorAlias,
culture: v.culture,
segment: v.segment,
value: v.value,
}),
),
};
this.#externalContentValues.appendOne(blockData);
this.#externalContentVariants.appendOne({
key: data.unique,
variants: data.variants.map((v) => ({
culture: v.culture ?? null,
segment: v.segment ?? null,
state: v.state ?? null,
})),
});
}
} finally {
this.#pendingElementFetches.delete(key);
// TODO: [@madsrasmussen] Replace per-key fetches here with a batching manager that bundles multiple
// element requests into a single round-trip. Today this issues one request per shared block, which
// can become N+1 on pages with many references. The batching manager should also cache and dedupe.
async #fetchExternalContent(key: string) {
if (this.#pendingElementFetches.has(key)) return;
if (this.#resolvedExternalContent.getValue().some((x) => x.key === key)) return;
this.#pendingElementFetches.add(key);
try {
const { data } = await this.#elementRepository.requestByUnique(key);
if (data) {
const blockData: UmbBlockDataModel = {
key: data.unique,
contentTypeKey: data.documentType.unique,
values: data.values.map(
(v): UmbBlockDataValueModel => ({
alias: v.alias,
editorAlias: v.editorAlias,
culture: v.culture,
segment: v.segment,
value: v.value,
}),
),
};
this.#resolvedExternalContent.appendOne(blockData);
this.#resolvedExternalContentVariants.appendOne({
key: data.unique,
variants: data.variants.map((v) => ({
culture: v.culture ?? null,
segment: v.segment ?? null,
state: v.state ?? null,
})),
});
}
} finally {
this.#pendingElementFetches.delete(key);
}
}
@@ -462,7 +465,7 @@ export abstract class UmbBlockManagerContext<
getContentOf(contentKey: string) {
return (
this.#contents.value.find((x) => x.key === contentKey) ??
this.#externalContentValues.value.find((x) => x.key === contentKey)
this.#resolvedExternalContent.value.find((x) => x.key === contentKey)
);
}
getSettingsOf(settingsKey: string) {
@@ -518,10 +521,12 @@ export abstract class UmbBlockManagerContext<
}
/**
* Request to transfer a local block's content to external content (Element Library).
* Request to transfer a local block's content to the Element Library.
* Opens the transfer modal for the user to name the new Element and pick a location,
* then creates the Element and updates the block to reference it.
* @param {string} key the block layout key.
*/
async requestTransferToExternalContent(key: string, name?: string) {
async requestTransferToElementLibrary(key: string, name?: string) {
const layout = this._layouts.getValue().find((x) => x.key === key);
if (!layout) return;
const contentKey = layout.contentKey;
@@ -536,7 +541,8 @@ export abstract class UmbBlockManagerContext<
.catch(() => undefined);
if (!result) return;
const { data: scaffold } = await this.#elementRepository.createScaffold({
const elementRepository = new UmbElementDetailRepository(this);
const { data: scaffold } = await elementRepository.createScaffold({
documentType: { unique: content.contentTypeKey, collection: null },
values: content.values,
variants: [
@@ -553,7 +559,7 @@ export abstract class UmbBlockManagerContext<
});
if (!scaffold) return;
const { data: created } = await this.#elementRepository.create(scaffold, result.parentUnique);
const { data: created } = await elementRepository.create(scaffold, result.parentUnique);
if (!created) return;
this.#contents.removeOne(contentKey);
@@ -565,10 +571,11 @@ export abstract class UmbBlockManagerContext<
}
/**
* Request to disconnect a block from external content (Element Library).
* Request to disconnect a block from the Element Library.
* Asks for user confirmation, then copies the element content into local contentData.
* @param {string} key the block layout key.
*/
async requestDisconnectFromExternalContent(key: string) {
async requestDisconnectFromElementLibrary(key: string) {
const layout = this._layouts.getValue().find((x) => x.key === key);
if (!layout) return;
const elementKey = layout.contentKey;
@@ -584,7 +591,8 @@ export abstract class UmbBlockManagerContext<
return; // user cancelled
}
const { data: element } = await this.#elementRepository.requestByUnique(elementKey);
const elementRepository = new UmbElementDetailRepository(this);
const { data: element } = await elementRepository.requestByUnique(elementKey);
if (!element) return;
const contentTypeKey = element.documentType.unique;
@@ -606,8 +614,8 @@ export abstract class UmbBlockManagerContext<
contentKey: newContent.key,
isExternalContent: undefined,
} as Partial<BlockLayoutType>);
this.#externalContentValues.removeOne(elementKey);
this.#externalContentVariants.removeOne(elementKey);
this.#resolvedExternalContent.removeOne(elementKey);
this.#resolvedExternalContentVariants.removeOne(elementKey);
// Only set expose if the content type structure is loaded (it may not be for external content
// whose type was not in the block type list)
if (this.getStructure(contentTypeKey)) {
@@ -7,7 +7,7 @@ export interface UmbBlockLayoutBaseModel {
key: string;
contentKey: string;
settingsKey?: string | null;
isExternalContent?: boolean;
isExternalContent?: boolean; // TODO: [LK] Review the naming of this property, align with with team.
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
@@ -247,7 +247,9 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
manager.isExternalContentOf(contentKey),
(isExternalContent) => {
if (isExternalContent) {
// External content does not keep an exposed state, so we default to true.
// A library element references pre-existing external content. It is never exposed
// per-variant the way local content is, so the expose lookup would always report
// false. The block already exists, so it is established — submit reads as "Update".
this.#exposed.setValue(true);
return;
}
@@ -17,6 +17,10 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
=> builder.Services.Configure<ContentSettings>(config =>
config.AllowEditInvariantFromNonDefault = true);
public static void ConfigureIndexExternalElementsTrue(IUmbracoBuilder builder)
=> builder.Services.Configure<IndexingSettings>(config =>
config.IndexExternalElements = true);
[Test]
public async Task Can_Handle_Reusable_Element()
{
@@ -788,6 +792,7 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
[TestCase(true)]
[TestCase(false)]
[ConfigureBuilder(ActionName = nameof(ConfigureIndexExternalElementsTrue))]
public async Task Can_Include_Invariant_Reusable_Elements_In_Search_Indexing(bool published)
{
var elementType = await CreateElementType(ContentVariation.Nothing);
@@ -832,18 +837,16 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
contentTypeDictionary: new Dictionary<Guid, IContentType>
{
{ elementType.Key, elementType }, { contentType.Key, contentType },
});
}).ToList();
Assert.AreEqual(1, indexValues.Count());
var indexValue = indexValues.FirstOrDefault(v => v.Culture is null);
var indexValue = indexValues.FirstOrDefault(v => v.Culture is null && v.FieldName == "blocks");
Assert.IsNotNull(indexValue);
Assert.AreEqual(1, indexValue.Values.Count());
Assert.AreEqual(1, indexValue!.Values.Count());
var indexedValue = indexValue.Values.First() as string;
Assert.IsNotNull(indexedValue);
var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
var values = indexedValue!.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.AreEqual(2, values.Length);
Assert.Contains("The reusable invariant text", values);
Assert.Contains("The reusable variant text", values);
@@ -851,6 +854,7 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
[TestCase(true)]
[TestCase(false)]
[ConfigureBuilder(ActionName = nameof(ConfigureIndexExternalElementsTrue))]
public async Task Can_Include_Variant_Reusable_Elements_In_Search_Indexing(bool published)
{
var elementType = await CreateElementType(ContentVariation.Culture);
@@ -895,27 +899,75 @@ internal class BlockListWithReusableContentTest : BlockEditorWithReusableContent
contentTypeDictionary: new Dictionary<Guid, IContentType>
{
{ elementType.Key, elementType }, { contentType.Key, contentType },
});
Assert.AreEqual(2, indexValues.Count());
}).ToList();
AssertIndexValues("en-US", "The reusable English text");
AssertIndexValues("da-DK", "The reusable Danish text");
void AssertIndexValues(string culture, string variantText)
{
var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture);
var indexValue = indexValues.FirstOrDefault(v => v.Culture == culture && v.FieldName == "blocks");
Assert.IsNotNull(indexValue);
Assert.AreEqual(1, indexValue.Values.Count());
Assert.AreEqual(1, indexValue!.Values.Count());
var indexedValue = indexValue.Values.First() as string;
Assert.IsNotNull(indexedValue);
var values = indexedValue.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
var values = indexedValue!.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.AreEqual(2, values.Length);
Assert.Contains(variantText, values);
Assert.Contains("The reusable invariant text", values);
}
}
[Test]
public async Task Does_Not_Index_Shared_Element_Content_When_Opt_In_Disabled()
{
var elementType = await CreateElementType(ContentVariation.Nothing);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = await CreateContentType(ContentVariation.Nothing, blockListDataType);
var reusableElementKey = await CreateAndPublishInvariantReusableElement(elementType.Key);
var blockListValue = new BlockListValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.BlockList,
[
new BlockListLayoutItem { ContentKey = reusableElementKey, IsExternalContent = true }
]
},
},
ContentData = [],
SettingsData = [],
Expose = [],
};
var content = new ContentBuilder().WithContentType(contentType).WithName("Page").Build();
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
ContentService.Save(content);
PublishContent(content, ["*"]);
var editor = blockListDataType.Editor!;
var indexValues = editor.PropertyIndexValueFactory.GetIndexValues(
content.Properties["blocks"]!,
culture: null,
segment: null,
published: true,
availableCultures: ["en-US"],
contentTypeDictionary: new Dictionary<Guid, IContentType>
{
{ elementType.Key, elementType }, { contentType.Key, contentType },
}).ToList();
// Element content must NOT be indexed when opt-in is disabled.
var allText = string.Join(
Environment.NewLine,
indexValues.SelectMany(v => v.Values).OfType<string>());
Assert.IsFalse(allText.Contains("The reusable invariant text"), "Shared-element content must not be indexed when the opt-in is disabled.");
Assert.IsFalse(allText.Contains("The reusable variant text"), "Shared-element content must not be indexed when the opt-in is disabled.");
}
private async Task<IDataType> CreateBlockListDataType(IContentType elementType)
=> await CreateBlockEditorDataType(
Constants.PropertyEditors.Aliases.BlockList,
@@ -0,0 +1,79 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Relations;
using Umbraco.Cms.Infrastructure.Search;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.PropertyEditors;
using IUmbracoBuilder = Umbraco.Cms.Core.DependencyInjection.IUmbracoBuilder;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Search;
internal sealed class DeferredSearchReindexServiceElementTests : BlockEditorWithReusableContentTestBase
{
private DeferredSearchReindexService Service
=> (DeferredSearchReindexService)GetRequiredService<IDeferredSearchReindexService>();
private int ElementId(Guid key) => IdKeyMap.GetIdForKey(key, UmbracoObjectTypes.Element).Result;
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
base.CustomTestSetup(builder);
builder
.AddNotificationHandler<ContentSavedNotification, ContentRelationsUpdate>()
.AddNotificationHandler<ContentPublishedNotification, ContentRelationsUpdate>();
}
[Test]
public async Task Finds_Document_Directly_Referencing_Element()
{
var elementType = await CreateElementType(ContentVariation.Nothing);
var blockListDataType = await CreateBlockListDataType(elementType);
var contentType = await CreateContentType(ContentVariation.Nothing, blockListDataType);
var elementKey = await CreateAndPublishInvariantReusableElement(elementType.Key);
var content = CreateDocumentEmbeddingElement(contentType, elementKey);
var elementId = ElementId(elementKey);
var documentIds = Service.FindDocumentIdsReferencingElements([elementId]);
Assert.Contains(content.Id, documentIds.ToArray());
}
private IContent CreateDocumentEmbeddingElement(IContentType contentType, Guid sharedElementKey)
{
var blockListValue = new BlockListValue
{
Layout = new Dictionary<string, IEnumerable<IBlockLayoutItem>>
{
{
Constants.PropertyEditors.Aliases.BlockList,
[new BlockListLayoutItem { ContentKey = sharedElementKey, IsExternalContent = true }]
},
},
ContentData = [],
SettingsData = [],
Expose = [],
};
var content = new ContentBuilder().WithContentType(contentType).WithName("Page").Build();
content.Properties["blocks"]!.SetValue(JsonSerializer.Serialize(blockListValue));
ContentService.Save(content);
PublishContent(content, ["*"]);
return content;
}
private async Task<IDataType> CreateBlockListDataType(IContentType elementType)
=> await CreateBlockEditorDataType(
Constants.PropertyEditors.Aliases.BlockList,
new BlockListConfiguration.BlockConfiguration[]
{
new() { ContentElementTypeKey = elementType.Key, SettingsElementTypeKey = elementType.Key }
});
}
@@ -45,7 +45,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(5, guidMap.Count); // 5 keys from Block List (with no sub features)
var expected = ReplaceGuids(json, guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -77,7 +77,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(10, guidMap.Count); // 5 keys from each Block List
var expected = ReplaceGuids(GetBlockListJson(innerJsonEscaped), guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -104,7 +104,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(10, guidMap.Count); // 5 keys from each Block List
var expected = ReplaceGuids(GetBlockListJson(innerJson), guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -138,7 +138,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(10, guidMap.Count); // 5 keys from each Block List
var expected = ReplaceGuids(GetBlockListJson(GetGridJson(innerJsonEscaped)), guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockListValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockListValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -169,7 +169,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(21, guidMap.Count); // 16 keys from Block Grid + 5 keys from Block List
var expected = ReplaceGuids(GetBlockGridJson(innerJsonEscaped), guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockGridValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -196,7 +196,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(21, guidMap.Count); // 16 keys from Block Grid + 5 keys from Block List
var expected = ReplaceGuids(GetBlockGridJson(innerJson), guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockGridValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -229,7 +229,7 @@ public class BlockEditorComponentTests
Assert.AreEqual(16, guidMap.Count); // 16 keys from Block Grid (with no sub features applicable for replacement)
var expected = ReplaceGuids(GetBlockGridJson(innerJson), guidMap);
var expectedJson = _jsonSerializer.Serialize( _jsonSerializer.Deserialize<BlockGridValue>(expected));
var expectedJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(expected));
var resultJson = _jsonSerializer.Serialize(_jsonSerializer.Deserialize<BlockGridValue>(result));
Assert.IsNotEmpty(resultJson);
Assert.AreEqual(expectedJson, resultJson);
@@ -10,6 +10,7 @@ using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
@@ -30,6 +31,7 @@ public class DeferredSearchReindexServiceTests
private Mock<IUmbracoIndexingHandler> _umbracoIndexingHandler = null!;
private Mock<IPublishStatusQueryService> _publishStatusQueryService = null!;
private Mock<ICoreScopeProvider> _scopeProvider = null!;
private Mock<IRelationService> _relationService = null!;
private DeferredSearchReindexService _service = null!;
[SetUp]
@@ -41,6 +43,7 @@ public class DeferredSearchReindexServiceTests
_umbracoIndexingHandler = new Mock<IUmbracoIndexingHandler>();
_publishStatusQueryService = new Mock<IPublishStatusQueryService>();
_scopeProvider = new Mock<ICoreScopeProvider>();
_relationService = new Mock<IRelationService>();
_scopeProvider
.Setup(x => x.CreateCoreScope(
It.IsAny<System.Data.IsolationLevel>(),
@@ -83,7 +86,8 @@ public class DeferredSearchReindexServiceTests
indexingSettings.Object,
_scopeProvider.Object,
Mock.Of<ILogger<DeferredSearchReindexService>>(),
lifetime.Object);
lifetime.Object,
_relationService.Object);
}
/// <summary>
@@ -434,6 +438,68 @@ public class DeferredSearchReindexServiceTests
Times.Once);
}
/// <summary>
/// Verifies that a document transitively embedding an element (via an intermediate element) is included in the
/// reindex set.
/// </summary>
[Test]
public void Finds_Document_Transitively_Referencing_Element()
{
// Document 100 embeds element 1; element 1 embeds element 2 (the one that changes).
SetupRelationGraph(new Dictionary<(int childId, UmbracoObjectTypes type), int[]>
{
{ (2, UmbracoObjectTypes.Document), [] },
{ (2, UmbracoObjectTypes.Element), [1] },
{ (1, UmbracoObjectTypes.Document), [100] },
{ (1, UmbracoObjectTypes.Element), [] },
});
var documentIds = _service.FindDocumentIdsReferencingElements([2]);
CollectionAssert.AreEquivalent(new[] { 100 }, documentIds);
}
/// <summary>
/// Verifies that the BFS terminates and does not loop when element references are cyclic.
/// </summary>
[Test]
public void Terminates_On_Cyclic_Element_References()
{
// Element 1 <-> element 2 (cycle); document 100 embeds element 1. Element 2 changes.
SetupRelationGraph(new Dictionary<(int childId, UmbracoObjectTypes type), int[]>
{
{ (2, UmbracoObjectTypes.Document), [] },
{ (2, UmbracoObjectTypes.Element), [1] },
{ (1, UmbracoObjectTypes.Document), [100] },
{ (1, UmbracoObjectTypes.Element), [2] },
});
var documentIds = _service.FindDocumentIdsReferencingElements([2]);
CollectionAssert.AreEquivalent(new[] { 100 }, documentIds);
}
private void SetupRelationGraph(Dictionary<(int childId, UmbracoObjectTypes type), int[]> graph)
{
_relationService
.Setup(r => r.GetPagedParentEntitiesByChildId(
It.IsAny<int>(),
It.IsAny<long>(),
It.IsAny<int>(),
out It.Ref<long>.IsAny,
It.IsAny<UmbracoObjectTypes[]>()))
.Returns((int id, long pageIndex, int pageSize, out long total, UmbracoObjectTypes[] types) =>
{
UmbracoObjectTypes type = types.Length > 0 ? types[0] : UmbracoObjectTypes.Unknown;
int[] parents = graph.TryGetValue((id, type), out int[]? ids) ? ids : [];
total = parents.Length;
return pageIndex == 0 ? parents.Select(CreateEntity).ToArray() : [];
});
}
private static IUmbracoEntity CreateEntity(int id)
=> Mock.Of<IUmbracoEntity>(e => e.Id == id);
private static IContent CreateContent(int id, bool published)
{
var content = new Mock<IContent>();
@@ -0,0 +1,78 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Search;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.Search;
[TestFixture]
public class ElementIndexingNotificationHandlerTests
{
private Mock<IDeferredSearchReindexService> _mockReindexService = null!;
private ElementIndexingNotificationHandler _sut = null!;
[SetUp]
public void SetUp()
{
_mockReindexService = new Mock<IDeferredSearchReindexService>();
_sut = new ElementIndexingNotificationHandler(_mockReindexService.Object);
}
[Test]
public void Handle_ElementSavedNotification_QueuesElementIds()
{
var element1 = ElementWithId(10);
var element2 = ElementWithId(20);
var notification = new ElementSavedNotification([element1, element2], new EventMessages());
_sut.Handle(notification);
_mockReindexService.Verify(
s => s.QueueElementReindex(It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 10, 20 }))),
Times.Once);
}
[Test]
public void Handle_ElementPublishedNotification_QueuesElementIds()
{
var element1 = ElementWithId(30);
var element2 = ElementWithId(40);
var notification = new ElementPublishedNotification([element1, element2], new EventMessages());
_sut.Handle(notification);
_mockReindexService.Verify(
s => s.QueueElementReindex(It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 30, 40 }))),
Times.Once);
}
[Test]
public void Handle_ElementSavedNotification_WhenEmpty_DoesNotQueue()
{
var notification = new ElementSavedNotification([], new EventMessages());
_sut.Handle(notification);
_mockReindexService.Verify(s => s.QueueElementReindex(It.IsAny<IReadOnlyCollection<int>>()), Times.Never);
}
[Test]
public void Handle_ElementPublishedNotification_WhenEmpty_DoesNotQueue()
{
var notification = new ElementPublishedNotification([], new EventMessages());
_sut.Handle(notification);
_mockReindexService.Verify(s => s.QueueElementReindex(It.IsAny<IReadOnlyCollection<int>>()), Times.Never);
}
private static IElement ElementWithId(int id)
{
var mock = new Mock<IElement>();
mock.Setup(e => e.Id).Returns(id);
return mock.Object;
}
}