Compare commits

...
Author SHA1 Message Date
Laura NetoandGitHub df16c114b6 OpenApi: Lowercase document name on registration to match AddOpenApi internal behaviour (closes #23210) (#23212)
* Lowercase OpenAPI document name on registration to match AddOpenApi internal behaviour

AddOpenApi lowercases the document name when registering its keyed services, so
ReplaceOpenApiSchemaService must receive the same lowercased key or the lookup
throws. BackOfficeOpenApiDocumentBuilder now computes a normalised registration
name and uses it for all DI calls, while keeping DocumentName in its original
casing. ShouldInclude matches [MapToApi] case-insensitively to align with how
documents are registered, and the UI dropdown label falls back to DocumentName
(original casing) rather than the lowercased registration key.
AddUmbracoOpenApiDocument applies the same normalisation for its apiName parameter.

* Add regression tests for mixed-case OpenAPI document name registration

Covers the bug scenario where AddBackOfficeOpenApiDocument with a mixed-case
name and WithJsonOptions threw InvalidOperationException at startup, and verifies
that ShouldInclude matches [MapToApi] case-insensitively.
2026-06-26 13:59:27 +02:00
fb1e16ff36 Table dates and User dates (#22169)
User-collection-table didn´t format and if you have da backoffice the time is still Am/pm

Co-authored-by: Lucas Bach Bisgaard <lucas.bisgaard@kraftvaerk.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-06-26 09:23:21 +02:00
Niels Lyngsø d889de6af0 Merge branch 'v17/dev' 2026-06-26 09:19:08 +02:00
d8f4342a86 Fix detail data request manager failing when items hit 40, making document unusable (#23164)
Fix detail data request manager failing as soon as the number of items requested hits the UmbItemDataApiGetRequestController batch limit (40)

Co-authored-by: Paul Woodland <paul.woodland@pwnewmedia.com>
2026-06-26 09:09:11 +02:00
774b2d4822 Fix detail data request manager failing when items hit 40, making document unusable (#23164)
Fix detail data request manager failing as soon as the number of items requested hits the UmbItemDataApiGetRequestController batch limit (40)

Co-authored-by: Paul Woodland <paul.woodland@pwnewmedia.com>
2026-06-26 09:08:14 +02:00
Niels LyngsøandGitHub 022439065f Block Workspace: avoid JS error if destroyed (#23200)
avoid JS error if Block Workspace Context was destroyed while awaiting a frame
2026-06-25 15:59:33 +02:00
Laura NetoandGitHub ba6ec7abcf Re-enable package validation (#23197) 2026-06-25 08:43:56 +00:00
Laura Neto 5d4d3a51ea Merge branch 'release/18.0' 2026-06-25 07:16:30 +01:00
Jacob Overgaard c6c5a9e7e6 Merge remote-tracking branch 'origin/v17/dev' 2026-06-24 11:51:40 +02:00
065e567f11 Media: Add umb-media-thumbnail with configurable checkerboard background (closes #23177) (#23178)
* feat(media): add umb-thumbnail and configurable checkerboard background

Adds `umb-thumbnail` as the recommended alias of `umb-imaging-thumbnail`
(the original tag stays registered for backwards compatibility), and makes
the checkerboard background opt-out via the `--umb-thumbnail-background` CSS
custom property plus an `img` part for full styling control. Also fixes an
action-event listener leak in the thumbnail element, and adds a Storybook
story, an MDX guide, and component tests.

Closes #23177

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): address PR review on umb-thumbnail

- Rephrase the imaging-thumbnail JSDoc to a neutral alias statement instead of
  a "prefer" wording that read like an undeclared deprecation.
- Guard the thumbnail tests so a renamed private field fails loudly rather than
  producing vacuous assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): make umb-thumbnail canonical, deprecate umb-imaging-thumbnail

Invert the inheritance so the implementation lives on `UmbThumbnailElement`
(`umb-thumbnail`) and `UmbImagingThumbnailElement` (`umb-imaging-thumbnail`)
is the thin subclass. Removing the old tag is now just deleting one file.

The deprecated subclass emits a one-time `UmbDeprecation` warning (a
module-level guard avoids per-instance console spam) and carries a
`@deprecated` JSDoc, scheduled for removal in Umbraco 19.

Migrate the four internal consumers to `umb-thumbnail` so the deprecation
warning targets external code only, not our own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(media): trim deprecated-alias thumbnail tests to a registration guard

The img part, checkerboard default and --umb-thumbnail-background override are
covered by thumbnail.element.test.ts and inherited from UmbThumbnailElement, so
re-asserting them on the umb-imaging-thumbnail subclass only tested inheritance.
Keep a single backwards-compat guard that the deprecated alias stays registered
and on the inheritance chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(media): rename canonical thumbnail to umb-media-thumbnail; alias keeps @deprecated, no runtime warning

Per review (Niels): the forward-looking name is `umb-media-thumbnail`
(`UmbMediaThumbnailElement`), leaving room for non-media thumbnails later. The
implementation, CSS custom property (`--umb-media-thumbnail-background`), story,
guide and internal consumers all use the new name.

`umb-imaging-thumbnail` stays registered as a thin alias and keeps its
`@deprecated` JSDoc (IDE signal) but no longer emits a runtime UmbDeprecation
warning — both tags fly for now. Docs and comments lead with umb-media-thumbnail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:37:53 +00:00
58a1c15626 Deprecations: Annotate warnings with caller origin and suppress core noise in production (#23188)
* feat(core): annotate deprecation warnings with caller origin, suppress core noise in production

Deprecation warnings now state where the call most likely came from — Umbraco
core, an /App_Plugins package, or other custom code — by classifying the call
stack (first frame not under /umbraco/backoffice/ is the caller). This answers
the Codegarden feedback that you can't tell whose code triggered a warning.

In production builds, core-origin warnings are suppressed (a consumer can't act
on Umbraco's own code); package/external/unknown origins are always shown. The
production signal is the client build, not the server runtime mode — the latter
is unreliable since Umbraco Cloud defaults to BackofficeDevelopment. The new
umbIsProductionBuild() reads Vite's import.meta.env.PROD (substituted to true in
the shipped core bundle) and falls back to false outside a Vite build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): cleaner deprecation output and drop the throw for stack capture

Read new Error().stack directly instead of throwing and catching — the stack is
populated on construction. Annotate the warning with the resolved origin on its
own line rather than a bracketed prefix, and rely on the browser's native
expandable stack on console.warn for the full clickable trace instead of
printing one ourselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(core): trim inline comments in deprecation utils

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): address PR review on deprecation origin

- Clarify umbIsProductionBuild docs: in Vite dev import.meta.env is defined
  (PROD false); the guard is for non-Vite contexts (tsc pass, web-test-runner).
- Strip query/fragment from parsed frame URLs so the external-origin label
  can't carry ?/# noise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:29:21 +00:00
Laura Neto c087ce9fb5 Bump the version of the Umbraco.TheStarterKit to 18.0.0 in the UmbracoProject template 2026-06-24 11:23:37 +02:00
Jacob OvergaardandClaude Opus 4.8 cda880bb62 test(hybridcache): use v18 IDatabaseCacheRepository method names in stale-set race test
DocumentHybridCacheStaleSetRaceTests came from the v17 PR #23169 and merged
forward into main (v18) unchanged, but on v18 IDatabaseCacheRepository renamed
GetContentSourceAsync -> GetDocumentSourceAsync and
GetContentSourceForPublishStatesAsync -> GetDocumentSourceForPublishStatesAsync.
The mock setups still referenced the v17 names, failing the build with CS1061.

Rename the two Moq setups to the v18 method names so the test compiles and the
mocks actually intercept the calls DocumentCacheService makes. v17 keeps the
Content* names and is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:06:16 +02:00
Jacob Overgaard b3e6477df9 Merge remote-tracking branch 'origin/v17/dev' 2026-06-23 14:33:28 +02:00
Jacob Overgaard 8f3ff26005 Merge remote-tracking branch 'origin/release/18.0' 2026-06-23 14:32:19 +02:00
Jacob Overgaard 05f8158e4a Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-23 14:29:24 +02:00
Jacob Overgaard 28b849f2d3 build(deps): bumps @umbraco-ui/uui to 1.18.1 2026-06-23 11:54:32 +02:00
Jacob Overgaard 8dd8820fa3 build(deps): bumps @umbraco-ui/uui to 2.0.0 2026-06-23 11:54:00 +02:00
ca7dcd5150 Published Cache: Guard against cache poisoning from a render-vs-publish race (#23169)
* Guard against cache poisoning from concurrency

* Resolve code review comments relating to tests.

* Avoid unnecessary second invalidation of memory cache generatio.

* Tighten the cache-generation guard.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-23 10:14:46 +02:00
Laura Neto 6b76230da5 Bump version to 18.0.0. 2026-06-23 09:38:59 +02:00
Kenn JacobsenandGitHub ceef53d624 Examine: Queue cold boot Examine reindexing at start-up, not at first request (Closes #22883) (#23181)
* Queue cold boot Examine reindexing at start-up, not at first request

* Review comments from Claude
2026-06-23 08:40:24 +02:00
Andy ButlandandGitHub 5bb53172aa ModelsBuilder: Avoid ObjectDisposedException in InMemoryModelFactory during shutdown (#23171)
* Guard EnsureModels against disposed lock on shutdown.

* Addressed code review comment.
2026-06-23 07:13:49 +02:00
Andy ButlandandGitHub aa9473131b Content Types: Show the correct type name (Media/Member Type) in the Compositions dialog (closes #23102) (#23118)
* Display appropriate content type name in compositions dialog localised texts.

* Fix composition dialog translation typos and link references to the matching workspace

- fr: "sililaire" -> "similaire"
- it: "utlizzato" -> "utilizzato"
- es: remove duplicated "no puede no puede"

The reference list now builds its workspace edit href from the modal's
entityType instead of hardcoding document-type, so links resolve correctly
when the dialog is used for Media Types and Member Types.
2026-06-22 16:28:59 +01:00
Mads RasmussenandGitHub 35a3a2455c Entity Data Picker: Implement interaction memory + sync picker memory across all picker inputs (#23172)
* Integrate interaction memories into entity data picker

* Skip resetting unchanged data source API

Add an early-return guard in setDataSourceApi to avoid re-setting the same UmbPickerDataSource instance. Prevents rebuilding the modal token/route (which would close and reopen an open picker modal) on every re-render by only updating when the API actually changes.

* Add UmbEntityInputInteractionMemoryManager + implement across current inputs with memory

* clean up comment
2026-06-22 14:12:55 +00:00
Andy Butland 2e3628dad3 Bump version to 18.0.0-rc4. 2026-06-19 18:50:40 +02:00
Andy Butland 5a20452e7a Bump version to 17.5.0-rc3. 2026-06-19 18:44:26 +02:00
0e14ace89a Element Picker: Adds valueSummary display and value resolver (#23154)
* feat(elements): add value summary for Element Picker property editor

Adds a valueSummary extension so picked element names appear in collection
view columns. Includes a value-type constant, batch resolver, variant-aware
element, and a resolver unit test (11 cases).

* fix(elements): address PR review feedback on element picker value summary

- Call removeUmbControllerByAlias when removing a stale resolver so the
  named observer controller is released from the element's controller list
- Call setData on existing resolvers when _value refreshes so renames are
  reflected without recreating the resolver
- Remove redundant valueResolver re-export from resolver file (barrel handles it)


---------

Co-authored-by: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
2026-06-19 14:11:12 +00:00
Andy Butland adeddeb148 Complete bump version to 17.5.0-rc2. 2026-06-19 15:51:10 +02:00
c9059c7b07 UFM: Add umbElementName component (#23162)
* feat(ufm): add umbElementName UFM component

Adds a new UFM component that resolves Element display names from element
keys, mirroring the umbContentName component pattern. Uses the variant-aware
UmbElementItemDataResolver (via UmbElementItemRepository) for proper
culture/variant handling and (Untitled) fallback.

Also exports UmbElementItemDataResolver from the public
@umbraco-cms/backoffice/element entry point, matching the pattern used by
the documents package.

* fix(ufm): clear stale value and destroy resolvers in element-name element

Clear this.value when the render context produces no usable input, preventing
stale names from lingering when the context changes. Also destroy each
UmbElementItemDataResolver after getName() to avoid accumulating controller
registrations on the host element.

* Update src/Umbraco.Web.UI.Client/src/packages/ufm/components/element-name/element-name.element.ts

Co-authored-by: Andy Butland <abutland73@gmail.com>

* test(ufm): add umbElementName parsing test to marked-ufm.test.ts

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-19 13:26:20 +00:00
Andy Butland 1ee24595d0 Fixed build error with integration tests. 2026-06-19 14:24:41 +02:00
Lee KelleherandGitHub ab8b8b48d4 Block RTE: Implement unsupported block rendering (closes #23071) (#23126)
* Block RTE: Implement unsupported block rendering

* Fixes `.ProseMirror-selectednode` focus ring

* Markup tidy-up

* Adds test for `umb-unsupported-rte-block`

* Block RTE: Reflect unsupported state as a host attribute

Replaces @state() + toggleAttribute() with @property({ reflect: true })
so Lit manages the 'unsupported' attribute sync during the update cycle,
avoiding constructor-time attribute access flagged by the linter.

Also removes the now-inert uui-text/uui-font classes from the block
wrapper div (backing styles were removed with UmbTextStyles).

* Adds JSDoc comment to `unsupported` property

* Block RTE: Extract #observeBlockViewProps() to reduce constructor size

Moves the block-view-props observer setup out of the constructor into a
dedicated #observeBlockViewProps() method, following the same pattern as
#observeData(). Reduces constructor from 123 to 64 lines (threshold: 70).
2026-06-19 13:08:00 +01:00
Jacob Overgaard 4ac8a397d0 Merge branch 'v17/dev'
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/content/content/types.ts
2026-06-19 13:58:25 +02:00
Jacob Overgaard feb1689848 Merge branch 'release/17.5.0' into v17/dev 2026-06-19 13:56:04 +02:00
Jacob OvergaardandClaude Opus 4.8 a14b908574 test(backoffice): fix host type in extension-initializer-base test
The `loaded`-signal test (added in #23167) built its host with
`UmbControllerHostElementMixin(HTMLElement)`, mirroring the older
`UmbBaseExtensionInitializer` tests. But `UmbExtensionInitializerBase`
requires a full `UmbElement` host, so the test failed `tsc` (TS2345)
under the root tsconfig. The product build excludes `*.test.ts`, so it
slipped through CI but breaks `npm run compile`/the editor.

Use `UmbElementMixin(HTMLElement)`, matching what production callers pass
(app/backoffice/preview elements are all UmbElements).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:56 +02:00
Andy Butland 0f4b784c23 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-06-19 13:40:22 +02:00
Andy Butland ebc0ef36f5 Merge branch 'v17/dev' 2026-06-19 13:40:09 +02:00
Jacob Overgaard 925d6bc430 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-06-19 13:37:44 +02:00
Jacob OvergaardandClaude Opus 4.8 acfaf23e43 Merge external-login entrypoint-race fix into v17/dev
Reconciles app.element.ts with #23020 (parallelized public extensions).
Kept the boot gate (await the app-entry-point initializer before routing)
and restored a blocking inline `await registerPublicExtensions()` instead
of the parallelized deferred form — a marginally slower but more robust
boot, identical to the release/17.5.0 fix (no empty-first-pass timing
reliance). extension-initializer-base.ts, the unit test, the acceptance
test and playwright config merge cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:35:21 +02:00
Jacob OvergaardandClaude Opus 4.8 cb23c84c3d External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:28:23 +02:00
Niels LyngsøandGitHub 7c1b907410 Block Workspace: Support variant properties and make '$settings' a key-value-object (Closes #23095) (#23123)
* map settings to become a key-value-object

* implement type safety for block label ufm values

* added TODOs

* support variant value in Block Workspace Label
2026-06-19 11:26:18 +00:00
Niels Lyngsø 564ca0384b Squashed commit of the following:
commit cd132f44b1
Author: Niels Lyngsø <niels.lyngso@gmail.com>
Date:   Fri Jun 19 13:18:07 2026 +0200

    correct to use display: block;

commit a9ffa9f90b
Author: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com>
Date:   Mon Jun 15 20:56:51 2026 +0200

    Added css styling to block list and single to respect custom width
2026-06-19 13:25:17 +02:00
Niels Lyngsø fc3b13c85c Squashed commit of the following:
commit cd132f44b1
Author: Niels Lyngsø <niels.lyngso@gmail.com>
Date:   Fri Jun 19 13:18:07 2026 +0200

    correct to use display: block;

commit a9ffa9f90b
Author: Andreas Lykke Borg <72602768+andreaslborg@users.noreply.github.com>
Date:   Mon Jun 15 20:56:51 2026 +0200

    Added css styling to block list and single to respect custom width
2026-06-19 13:24:56 +02:00
9f633416b1 External login: wait for app-entry-points before the login provider decision (#23167)
* External login: wait for app-entry-points before the login provider decision

The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(backoffice): guard the loaded-gate timing for permission loading

Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backoffice): harden loaded signal + narrow acceptance test glob (review)

Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:11:28 +01:00
Lee KelleherandGitHub 91381604dd UFM: Add umbMemberName component (closes #22147) (#23165)
* Adds UFM Member Name component

This is to support the standalone Member Picker values.

* fix(ufm): clear stale value on empty member picker; validate UDI-extracted GUIDs

* test(ufm): add umbMemberName parsing tests to marked-ufm.test.ts
2026-06-19 09:48:51 +00:00
Jacob OvergaardandClaude Opus 4.8 c566dd0a71 fix(backoffice): harden loaded signal + narrow acceptance test glob (review)
Address PR review feedback:
- extension-initializer-base: only the latest processing pass settles `loaded`
  (monotonic pass id), so a slow earlier pass can't unblock waiters early when
  the async observer overlaps passes; and use `Promise.allSettled` so a throwing
  `instantiateExtension` can't leave `loaded` stuck at `undefined` (hanging the
  boot gate) — failures are logged rather than swallowed.
- playwright.config: narrow the project glob to `**/*.spec.ts` so Playwright
  doesn't try to load the App_Plugins `entry-point.js` ESM fixture as a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:35:26 +02:00
Jacob OvergaardandClaude Opus 4.8 ea78147657 test(backoffice): guard the loaded-gate timing for permission loading
Add a test asserting the collection initializer's `loaded` does not open the
gate (`#loadedGuard` awaits it via `.asPromise()`, fronting private-extension
and user-permission loading) until the initially-registered extensions have
instantiated. Addresses the #22522 "user permissions resolved too late" concern
in writing; user-permission condition resolution itself lives in
UmbBaseExtensionInitializer (covered by base-extension-initializer.race.test.ts)
and is untouched by this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:19:39 +02:00
Andy ButlandandGitHub 0a5189e54a Content & Media: Enforce content type filters and allowed children/root rules when validating a create (#23163)
* Validate for content type filters on create document and media validation.

* Addressed code review feedback on tests.
2026-06-19 11:16:47 +02:00
Jacob OvergaardandClaude Opus 4.8 102e4aa80b External login: wait for app-entry-points before the login provider decision
The backoffice boot stopped waiting for app-entry-point extensions to settle
before deciding which auth provider to use (regression introduced in #22522).
On a slow connection an externally registered authProvider (e.g. Umbraco ID)
is not registered yet when the login screen renders, so the user is dropped on
the local login instead of being redirected to the external provider.

- extension-initializer-base: `loaded` re-arms to `undefined` while a pass is in
  flight and resolves to `true` unconditionally (including zero extensions), so
  `.asPromise()` gates correctly and never hangs on a default install (which has
  no app-entry-points) — the reason the await was removed in the first place.
- app.element: restore the awaited boot gate before routing.

Tests:
- Unit test for the `loaded` signal contract (zero extensions resolves; a late,
  slow extension is awaited).
- Playwright acceptance test that deploys an app-entry-point registering an
  authProvider after a delay and asserts it is offered on the login screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:10:01 +02:00
Andy ButlandandGitHub 0bec947b8b Examine: Stream value sets to a single index during rebuild to reduce reindex peak memory (#23150)
* Stream value sets to a single index during rebuild to reduce reindex peak memory.

* Applied code review feedback.
2026-06-19 06:32:18 +02:00
Jesper MadsenandJacob Overgaard 1dfcd9332a Let the external login button show "sign in with {providername}" in languages (#23135) 2026-06-17 15:57:40 +02:00
137 changed files with 3384 additions and 582 deletions
+1 -1
View File
@@ -40,7 +40,7 @@
<!-- Package Validation -->
<PropertyGroup>
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
@@ -59,6 +59,7 @@ public static class UmbracoBuilderApiExtensions
string? jsonOptionsName = null)
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
{
apiName = apiName.ToLowerInvariant();
builder.Services.AddOpenApi(apiName);
builder.Services.ConfigureOptions<TConfigureOptions>();
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
@@ -116,12 +116,20 @@ public sealed class BackOfficeOpenApiDocumentBuilder
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
builder.Services.AddOpenApi(
DocumentName,
lowercasedDocumentName,
options =>
{
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
apiDescription.ActionDescriptor.EndpointMetadata
?.OfType<MapToApiAttribute>()
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
?? false;
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
@@ -158,12 +166,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
private int? _skipver;
private RoslynCompiler? _roslynCompiler;
private ModelsBuilderSettings _config;
private bool _disposedValue;
private volatile bool _disposedValue;
public InMemoryModelFactory(
Lazy<UmbracoServices> umbracoServices,
@@ -280,25 +280,34 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
}
}
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
// requests can still reach this point. Bail out with the current models rather than touching
// the disposed lock. The catch below covers the small window where disposal happens after this
// check but before (or while) the lock is acquired.
if (_disposedValue)
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
return _infos;
}
try
{
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
_locker.EnterUpgradeableReadLock();
if (_hasModels)
@@ -359,6 +368,12 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
return _infos;
}
catch (ObjectDisposedException ex)
{
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
return _infos;
}
finally
{
if (_locker.IsWriteLockHeld)
@@ -20,5 +20,12 @@ public class IndexingSettings
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
/// <remarks>
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
/// during a rebuild.
/// </remarks>
[DefaultValue(StaticBatchSize)]
public int BatchSize { get; set; } = StaticBatchSize;
}
@@ -103,11 +103,19 @@ internal sealed class ContentEditingService
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
ContentCreateModel createModel,
Guid userKey)
=> await ValidateCulturesAndPropertiesAsync(
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(
createModel,
createModel.ContentTypeKey,
createModel.Variants.Select(variant => variant.Culture),
userKey);
}
/// <inheritdoc />
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
@@ -665,6 +665,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return filteredContentTypes.Any();
}
/// <summary>
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
/// actually created. This allows the validation endpoints to be consistent with creation.
/// </summary>
/// <param name="createModel">The content creation model.</param>
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
{
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
if (contentType is null)
{
return ContentEditingOperationStatus.ContentTypeNotFound;
}
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
return operationStatus;
}
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
{
if (contentType.VariesByCulture())
@@ -87,7 +87,15 @@ internal sealed class MediaEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
}
/// <inheritdoc />
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
@@ -91,7 +91,7 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
return builder;
}
@@ -170,13 +170,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
pageIndex++;
}
@@ -216,12 +210,7 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
}
}
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
pageIndex++;
}
@@ -49,13 +49,7 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
Constants.System.Root,
descendants =>
{
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
});
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
}
public override bool IsRegistered(IIndex index)
@@ -107,11 +107,7 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
}
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
pageIndex++;
}
@@ -41,11 +41,7 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
{
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_valueSetBuilder.GetValueSets(members));
}
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
pageIndex++;
}
@@ -0,0 +1,66 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Handles how the indexes are rebuilt after startup.
/// </summary>
/// <remarks>
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
{
// The notification is published again on restart, but the indexes only need to be
// considered for rebuilding once per application lifetime.
private static int _hasRun;
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
private readonly IIndexRebuilder _indexRebuilder;
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
/// </summary>
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
public RebuildOnStartedHandler(
ISyncBootStateAccessor syncBootStateAccessor,
IIndexRebuilder indexRebuilder,
IRuntimeState runtimeState)
{
_syncBootStateAccessor = syncBootStateAccessor;
_indexRebuilder = indexRebuilder;
_runtimeState = runtimeState;
}
/// <summary>
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
return;
}
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
{
return;
}
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
// if it's not a cold boot, only rebuild empty ones
await _indexRebuilder.RebuildIndexesAsync(
bootState != SyncBootState.ColdBoot,
TimeSpan.FromMinutes(1));
}
}
@@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.Examine;
/// On the first HTTP request this will rebuild the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
[Obsolete("Superseded by RebuildOnStartedHandler. Scheduled for removal in Umbraco 19.")]
public sealed class RebuildOnStartupHandler : INotificationHandler<UmbracoRequestBeginNotification>
{
// These must be static because notification handlers are transient.
@@ -0,0 +1,35 @@
using Examine;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Writes a batch of <see cref="ValueSet" />s to one or more indexes.
/// </summary>
/// <remarks>
/// When a single index is registered the value sets are streamed straight through, so a lazily-built
/// sequence is enumerated once and never fully materialised in memory — keeping the common single-index
/// rebuild's peak memory down. When multiple indexes are registered the sequence is materialised once and
/// reused, so the (potentially expensive) value sets are not rebuilt per index.
/// </remarks>
internal static class ValueSetIndexer
{
public static void IndexItems(IReadOnlyList<IIndex> indexes, IEnumerable<ValueSet> valueSets)
{
switch (indexes.Count)
{
case 0:
return;
case 1:
indexes[0].IndexItems(valueSets);
return;
default:
ValueSet[] materialized = valueSets as ValueSet[] ?? valueSets.ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(materialized);
}
return;
}
}
}
@@ -38,6 +38,20 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private readonly ConcurrentDictionary<string, IPublishedContent> _publishedContentCache = [];
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
// back, so a snapshot read before a concurrent publish/refresh is never written over the
// refreshed entry — preventing the stale-set clobber that otherwise persists until a full clear.
//
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
// read-through. The only cost is an occasional skipped cache population when a read-through for one
// key overlaps an unrelated publish — a re-miss on the next request, never stale data. A per-key
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
// publishing ever shows up in profiling.
private long _cacheGeneration;
private HashSet<Guid> SeedKeys
{
get
@@ -129,15 +143,28 @@ internal sealed class DocumentCacheService : IDocumentCacheService
}
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
// A value found in the backing store is already current, so it can always populate the caches
// below; only a value built from the read-through DB fetch needs the generation guard.
bool snapshotIsCurrent = true;
if (exists is false)
{
// Capture the cache generation before reading the backing store. If a concurrent publish or
// invalidation bumps the generation while we read and build below, the snapshot we hold is
// stale and must not be written back over the refreshed entries (the clobber that leaves
// memory permanently stale until a full clear).
long generation = Interlocked.Read(ref _cacheGeneration);
bool ancestorCheckFailed;
(contentCacheNode, ancestorCheckFailed) = await GetContentCacheNodeFromRepo();
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
// Only cache the result if the ancestor check didn't fail.
// When content exists in DB but the ancestor check fails, this could be a transient
// race condition during cache rebuild. Caching null would poison the distributed cache.
if (ancestorCheckFailed is false)
// Skip the write when the generation moved — a refresh has superseded this snapshot.
if (ancestorCheckFailed is false && snapshotIsCurrent)
{
await _hybridCache.SetAsync(
cacheKey,
@@ -153,7 +180,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
}
IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory);
if (result is not null)
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
// refresh has already written fresher content and we must not overwrite it with this one.
if (result is not null && snapshotIsCurrent)
{
_publishedContentCache[cacheKey] = result;
}
@@ -185,6 +215,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
private bool GetPreview() => _previewService.IsInPreview();
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
private bool IsCacheGenerationCurrent(long capturedGeneration)
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
@@ -198,6 +235,10 @@ internal sealed class DocumentCacheService : IDocumentCacheService
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
{
// Bump first so any read-through that read the backing store before this clear is rejected
// when it tries to write back, even while the reseed below is still running.
InvalidateMemoryCacheGeneration();
_publishedContentCache.Clear();
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Content, cancellationToken);
@@ -227,11 +268,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(publishedNode.Key, false);
await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(publishedNode));
_publishedContentCache.Remove(cacheKey, out _);
InvalidateMemoryCacheGeneration();
}
else
{
// Either no published node in the database cache, or the ancestor path is no longer published —
// remove any stale published entry from the local memory cache.
// remove any stale published entry from the local memory cache. ClearPublishedCacheAsync
// bumps the generation itself, so this path is already covered.
await ClearPublishedCacheAsync(key);
}
@@ -425,12 +468,17 @@ internal sealed class DocumentCacheService : IDocumentCacheService
ClearConvertedContentCache(contentTypeIdsAsArray);
}
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
public void ClearConvertedContentCache()
{
_publishedContentCache.Clear();
InvalidateMemoryCacheGeneration();
}
public void ClearConvertedContentCache(IReadOnlyCollection<int> contentTypeIds)
{
var ids = contentTypeIds as int[] ?? contentTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
InvalidateMemoryCacheGeneration();
}
private async Task ClearPublishedCacheAsync(Guid key)
@@ -438,6 +486,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(key, false);
await _hybridCache.RemoveAsync(cacheKey);
_publishedContentCache.Remove(cacheKey, out _);
InvalidateMemoryCacheGeneration();
}
private static string ContentTypeIdTag(int contentTypeId)
@@ -34,6 +34,20 @@ internal sealed class MediaCacheService : IMediaCacheService
private readonly ConcurrentDictionary<Guid, IPublishedContent> _publishedContentCache = [];
// Monotonic counter bumped whenever the in-memory cache (L0/L1) is invalidated or refreshed.
// GetNodeAsync captures it before reading the backing store and re-checks it before writing
// back, so a snapshot read before a concurrent refresh is never written over the refreshed
// entry — preventing the stale-set clobber that otherwise persists until a full clear.
//
// Deliberately a single global counter, not per-key: any invalidation invalidates every in-flight
// read-through. The only cost is an occasional skipped cache population when a read-through for one
// key overlaps an unrelated refresh — a re-miss on the next request, never stale data. A per-key
// scheme would avoid that but needs a global epoch for bulk clears plus an exact per-key bump on
// every mutated cache key, which is easy to get wrong and would silently reintroduce the clobber.
// Global is correctness-robust; only revisit if read-through churn under heavy concurrent
// refreshing ever shows up in profiling.
private long _cacheGeneration;
private HashSet<Guid>? _seedKeys;
private HashSet<Guid> SeedKeys
{
@@ -124,11 +138,24 @@ internal sealed class MediaCacheService : IMediaCacheService
string cacheKey = GetCacheKey(key);
(bool exists, ContentCacheNode? contentCacheNode) = await _hybridCache.TryGetValueAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
// A value found in the backing store is already current, so it can always populate the caches
// below; only a value built from the read-through DB fetch needs the generation guard.
bool snapshotIsCurrent = true;
if (exists is false)
{
// Capture the cache generation before reading the backing store. If a concurrent refresh or
// invalidation bumps the generation while we read and build below, the snapshot we hold is
// stale and must not be written back over the refreshed entries (the clobber that leaves
// memory permanently stale until a full clear).
long generation = Interlocked.Read(ref _cacheGeneration);
contentCacheNode = await GetContentCacheNodeFromRepo();
snapshotIsCurrent = IsCacheGenerationCurrent(generation);
// We don't want to cache removed items, this may cause issues if the L2 serializer changes.
if (contentCacheNode is not null)
// Skip the write when the generation moved — a refresh has superseded this snapshot.
if (contentCacheNode is not null && snapshotIsCurrent)
{
await _hybridCache.SetAsync(
cacheKey,
@@ -144,7 +171,10 @@ internal sealed class MediaCacheService : IMediaCacheService
}
IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory);
if (result is not null)
// Only populate the L0 cache when our snapshot is still current; otherwise a concurrent
// refresh has already written fresher content and we must not overwrite it with this one.
if (result is not null && snapshotIsCurrent)
{
_publishedContentCache[key] = result;
}
@@ -160,6 +190,13 @@ internal sealed class MediaCacheService : IMediaCacheService
}
}
// Bumped after every in-memory cache invalidation/refresh so in-flight read-through snapshots
// (see GetNodeAsync) can detect they have been superseded and skip writing back stale content.
private void InvalidateMemoryCacheGeneration() => Interlocked.Increment(ref _cacheGeneration);
private bool IsCacheGenerationCurrent(long capturedGeneration)
=> Interlocked.Read(ref _cacheGeneration) == capturedGeneration;
public async Task<bool> HasContentByIdAsync(int id)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
@@ -186,6 +223,7 @@ internal sealed class MediaCacheService : IMediaCacheService
var cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
_publishedContentCache.Remove(media.Key, out _);
InvalidateMemoryCacheGeneration();
scope.Complete();
}
@@ -263,9 +301,12 @@ internal sealed class MediaCacheService : IMediaCacheService
{
await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key), publishedNode, GetEntryOptions(publishedNode.Key));
_publishedContentCache.Remove(key, out _);
InvalidateMemoryCacheGeneration();
}
else
{
// RemoveFromMemoryCacheAsync → ClearPublishedCacheAsync bumps the generation itself,
// so this path is already covered.
await RemoveFromMemoryCacheAsync(key);
}
@@ -274,6 +315,10 @@ internal sealed class MediaCacheService : IMediaCacheService
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken)
{
// Bump first so any read-through that read the backing store before this clear is rejected
// when it tries to write back, even while the reseed below is still running.
InvalidateMemoryCacheGeneration();
_publishedContentCache.Clear();
await _hybridCache.RemoveByTagAsync(Constants.Cache.Tags.Media, cancellationToken);
@@ -295,12 +340,17 @@ internal sealed class MediaCacheService : IMediaCacheService
ClearConvertedContentCache(mediaTypeIdsAsArray);
}
public void ClearConvertedContentCache() => _publishedContentCache.Clear();
public void ClearConvertedContentCache()
{
_publishedContentCache.Clear();
InvalidateMemoryCacheGeneration();
}
public void ClearConvertedContentCache(IReadOnlyCollection<int> mediaTypeIds)
{
var ids = mediaTypeIds as int[] ?? mediaTypeIds.ToArray();
_publishedContentCache.RemoveAll(content => ids.Contains(content.Value.ContentType.Id));
InvalidateMemoryCacheGeneration();
}
public void Rebuild(IReadOnlyCollection<int> contentTypeIds)
@@ -358,6 +408,7 @@ internal sealed class MediaCacheService : IMediaCacheService
{
await _hybridCache.RemoveAsync(GetCacheKey(key));
_publishedContentCache.Remove(key, out _);
InvalidateMemoryCacheGeneration();
}
private static string MediaTypeIdTag(int mediaTypeId)
+44 -36
View File
@@ -5,6 +5,7 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
## Documentation Structure
### Architecture & Design
- **[Architecture](./docs/architecture.md)** - Technology stack, design philosophy, developer roles, package system, import map pipeline, design patterns
- **[Manifests & Aliases](./docs/manifests.md)** - Manifest shape, alias conventions, alias constants, how aliases connect extensions, registration, registry operations, kind merging
- **[Entities](./docs/entities.md)** - Entity types, entity context, how entityType connects workspaces/trees/actions/routing
@@ -18,9 +19,11 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
- **[Value Summary](./docs/value-summary.md)** - `valueSummary` extension type; rendering compact values in collection views, batch resolver pattern, coordinator
### Development
- **[Commands](./docs/commands.md)** - Build, test, and development commands
### Code Quality
- **[Style Guide](./docs/style-guide.md)** - Naming and formatting conventions
- **[Design Choices](./docs/design-choices.md)** - Visual restraint: icons, colours, buttons, and UX copy
- **[Clean Code](./docs/clean-code.md)** - Best practices and SOLID principles
@@ -28,10 +31,12 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
- **[Testing](./docs/testing.md)** - Testing strategy, priority by code area, MSW mocking, test patterns
### Troubleshooting
- **[Error Handling](./docs/error-handling.md)** - Error patterns and debugging
- **[Edge Cases](./docs/edge-cases.md)** - Common pitfalls and gotchas
### Security & AI
- **[Security](./docs/security.md)** - XSS prevention, authentication, input validation
- **[Agentic Workflow](./docs/agentic-workflow.md)** - Three-phase AI development process
@@ -41,17 +46,17 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
**Before performing any of these actions, you MUST read the linked doc first:**
| Before you... | Read |
|----------------|------|
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
| Before you... | Read |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Deprecate or remove a public API | [docs/deprecation.md](./docs/deprecation.md) — requires **both** `@deprecated` JSDoc **and** runtime `UmbDeprecation` warning |
| Create a new element or component | [docs/style-guide.md](./docs/style-guide.md) |
| Build, style, or write copy for any UI | [docs/design-choices.md](./docs/design-choices.md) — default to no icon, no colour, terse contextual copy |
| Create a repository or data source | [docs/repositories.md](./docs/repositories.md) + [docs/data-flow.md](./docs/data-flow.md) |
| Add error handling or debugging | [docs/error-handling.md](./docs/error-handling.md) |
| Write or modify tests | [docs/testing.md](./docs/testing.md) |
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
This is not optional. Skipping these leads to convention violations that are caught in review.
@@ -79,24 +84,24 @@ cd src/Umbraco.Web.UI.Client && npm install && npm run dev
See **[Commands](./docs/commands.md)** for all available commands.
| Task | Command |
|------|---------|
| Development | `npm run dev` |
| Testing (all) | `npm test` |
| Task | Command |
| ----------------------- | --------------------------------------------------------- |
| Development | `npm run dev` |
| Testing (all) | `npm test` |
| Testing (specific file) | `npm test -- --files "src/packages/path/to/file.test.ts"` |
| Build | `npm run build` |
| Lint | `npm run lint:fix` |
| Circular dep check | `npm run check:circular` |
| Build | `npm run build` |
| Lint | `npm run lint:fix` |
| Circular dep check | `npm run check:circular` |
---
## Quick Reference
| Item | Details |
|------|---------|
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
| Item | Details |
| ----------------------- | ----------------------------------------------------------------- |
| **Config** | `package.json`, `vite.config.ts`, `.env` (create `.env.local`) |
| **Element naming** | `umb-{feature}-{component}` for core; package devs use own prefix |
| **Directory structure** | See [Architecture](./docs/architecture.md#architecture-pattern) |
---
@@ -117,6 +122,7 @@ The `npm pack` process (prepack hook) runs `devops/publish/cleanse-pkg.js` which
Uses the `semver` package (npm's own semver library) for robust parsing:
**Pre-release packages (0.x.y)**
```
Input: ^0.85.0 or 0.85.0
Output: >=0.85.0 <1.0.0
@@ -126,6 +132,7 @@ Why: Pre-release caret (^0.85.0) only allows patch updates (0.85.x).
```
**Stable packages with caret (major ≥ 1)**
```
Input: ^3.3.1
Output: ^3.3.1 (kept as-is)
@@ -134,6 +141,7 @@ Why: Caret already implements the correct range: >=3.3.1 <4.0.0
```
**Stable exact versions (major ≥ 1)**
```
Input: 3.16.0 (from @tiptap/*)
Output: ^3.16.0
@@ -145,14 +153,14 @@ Why: Normalizes to conventional semver format
```json
{
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^2.0.0-alpha.1",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
"peerDependencies": {
"lit": "^3.3.1",
"rxjs": "^7.8.2",
"@umbraco-ui/uui": "^2.0.0",
"monaco-editor": "^0.55.1",
"@tiptap/core": "^3.16.0",
"@hey-api/openapi-ts": ">=0.85.0 <1.0.0"
}
}
```
@@ -168,9 +176,9 @@ When using `@umbraco-cms/backoffice`:
### Key Files
| File | Purpose |
|------|---------|
| `package.json` | Root package with exports and workspace references |
| File | Purpose |
| ------------------------------- | ---------------------------------------------------------------- |
| `package.json` | Root package with exports and workspace references |
| `devops/publish/cleanse-pkg.js` | Script that runs during `npm pack` to hoist and convert versions |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
| `src/external/*` | Dependency wrapper packages |
| `src/packages/core` | Contains `@hey-api/openapi-ts` and other utilities |
+4 -4
View File
@@ -3963,9 +3963,9 @@
"link": true
},
"node_modules/@umbraco-ui/uui": {
"version": "2.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.2.tgz",
"integrity": "sha512-/eRw8byM1zysUF61VrM3MQzZnvuZHuerhiW13eR5vPAxvWT1I5n/jeh+qTKKd3mgZi/rvAV1fnRS05ipqu2hMQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0.tgz",
"integrity": "sha512-snH3u1C4gpvO/bxTfTJV6lF3HVpru5v0ssMbz0aESOt66gRyGf//fshgUHgvYa5gc3wE2Fr4uGx8mKgUC/GrXQ==",
"license": "MIT",
"dependencies": {
"culori": "^4.0.2",
@@ -16447,7 +16447,7 @@
"src/external/uui": {
"name": "@umbraco-backoffice/uui",
"dependencies": {
"@umbraco-ui/uui": "^2.0.0-rc.2"
"@umbraco-ui/uui": "^2.0.0"
}
},
"src/libs/class-api": {
+1 -1
View File
@@ -298,4 +298,4 @@
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
}
@@ -260,12 +260,9 @@ export class UmbAppElement extends UmbLitElement {
// Register Core extensions (this is specifically done here because we need these extensions to be registered before the application is initialized)
onInit(this, umbExtensionsRegistry);
// Register public extensions (login extensions) in parallel with the auth flow below.
const registerPublicExtensions = new UmbServerExtensionRegistrator(
this,
umbExtensionsRegistry,
).registerPublicExtensions();
new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Register public extensions (login extensions)
await new UmbServerExtensionRegistrator(this, umbExtensionsRegistry).registerPublicExtensions();
const entryPointInitializer = new UmbAppEntryPointExtensionInitializer(this, umbExtensionsRegistry);
// Try to initialise the auth flow and get the runtime status
try {
@@ -281,8 +278,11 @@ export class UmbAppElement extends UmbLitElement {
await this.#setAuthStatus();
}
// The login screen needs the public extensions before routing.
await registerPublicExtensions;
// The login screen decides which auth provider to use from the registered
// `authProvider` extensions. App-entry-points may register or unregister those during
// their async onInit, so wait for them to settle before routing — otherwise on a slow
// connection the decision races and falls back to the local login.
await this.observe(entryPointInitializer.loaded).asPromise();
// Initialise the router
this.#redirect();
@@ -1557,8 +1557,16 @@ export default {
chooseChildNode: 'اختر العقدة الفرعية',
compositionsDescription:
'ارث التبويبات والخصائص من نوع مستند موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوثيقة الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionsDescriptionMediaType:
'ارث التبويبات والخصائص من نوع وسائط موجود. سيتم إضافة التبويبات الجديدة إلى نوع الوسائط الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionsDescriptionMemberType:
'ارث التبويبات والخصائص من نوع عضو موجود. سيتم إضافة التبويبات الجديدة إلى نوع العضو الحالي أو دمجها إذا كان هناك تبويب بنفس الاسم.',
compositionInUse: 'هذا النوع من المحتوى قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
compositionInUseMediaType: 'هذا النوع من الوسائط قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
compositionInUseMemberType: 'هذا النوع من الأعضاء قيد الاستخدام في تركيب، وبالتالي لا يمكن تركيبه بنفسه.\n ',
noAvailableCompositions: 'لا توجد أنواع محتوى متاحة لاستخدامها كتركيب.',
noAvailableCompositionsMediaType: 'لا توجد أنواع وسائط متاحة لاستخدامها كتركيب.',
noAvailableCompositionsMemberType: 'لا توجد أنواع أعضاء متاحة لاستخدامها كتركيب.',
compositionRemoveWarning:
'إزالة التركيب ستؤدي إلى حذف جميع بيانات الخصائص المرتبطة. بمجرد حفظ نوع الوثيقة لا يوجد طريق للعودة.',
availableEditors: 'إنشاء جديد',
@@ -1592,6 +1600,8 @@ export default {
tabHasNoSortOrder: 'التبويب ليس له ترتيب فرز',
compositionUsageHeading: 'أين يتم استخدام هذا التركيب؟',
compositionUsageSpecification: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع المحتوى التالية:\n ',
compositionUsageSpecificationMediaType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الوسائط التالية:\n ',
compositionUsageSpecificationMemberType: 'يتم استخدام هذا التركيب حاليًا في تركيب أنواع الأعضاء التالية:\n ',
variantsHeading: 'السماح بالاختلافات',
cultureVariantHeading: 'السماح بالاختلاف حسب الثقافة',
segmentVariantHeading: 'السماح بالتجزئة',
@@ -1469,8 +1469,16 @@ export default {
chooseChildNode: 'Odaberite podređeni čvor',
compositionsDescription:
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice će biti\n dodano trenutnoj vrsti dokumenta ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMediaType:
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice će biti\n dodano trenutnoj vrsti medija ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMemberType:
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice će biti\n dodano trenutnoj vrsti člana ili spojeno ako postoji kartica s identičnim imenom.\n ',
compositionInUse: 'Ovaj tip sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMediaType: 'Ovaj tip medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMemberType: 'Ovaj tip člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
noAvailableCompositions: 'Nema dostupnih tipova sadržaja za upotrebu kao kompozicija.',
noAvailableCompositionsMediaType: 'Nema dostupnih tipova medija za upotrebu kao kompozicija.',
noAvailableCompositionsMemberType: 'Nema dostupnih tipova člana za upotrebu kao kompozicija.',
compositionRemoveWarning:
'Uklanjanje kompozicije će izbrisati sve povezane podatke o svojstvu. Jednom ti\n sačuvajte tip dokumenta, nema povratka.\n ',
availableEditors: 'Napravi novi',
@@ -1505,6 +1513,8 @@ export default {
tabHasNoSortOrder: 'kartica nema redoslijed sortiranja',
compositionUsageHeading: 'Gdje se koristi ovaj sastav?',
compositionUsageSpecification: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa sadržaja:\n ',
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa medija:\n ',
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n tipa člana:\n ',
variantsHeading: 'Dozvoli varijacije',
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
segmentVariantHeading: 'Dozvoli segmentaciju',
@@ -1369,8 +1369,16 @@ export default {
chooseChildNode: 'Vybrat podřízený uzel',
compositionsDescription:
'Zdědí záložky a vlastnosti z existujícího typu dokumentu. Nové záložky budou přidány do aktuálního typu dokumentu nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionsDescriptionMediaType:
'Zdědí záložky a vlastnosti z existujícího typu média. Nové záložky budou přidány do aktuálního typu média nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionsDescriptionMemberType:
'Zdědí záložky a vlastnosti z existujícího typu člena. Nové záložky budou přidány do aktuálního typu člena nebo sloučeny, pokud existuje záložka se stejným názvem.',
compositionInUse: 'Tento typ obsahu se používá ve složení, a proto jej nelze poskládat.',
compositionInUseMediaType: 'Tento typ média se používá ve složení, a proto jej nelze poskládat.',
compositionInUseMemberType: 'Tento typ člena se používá ve složení, a proto jej nelze poskládat.',
noAvailableCompositions: 'Nejsou k dispozici žádné typy obsahu, které lze použít jako složení.',
noAvailableCompositionsMediaType: 'Nejsou k dispozici žádné typy média, které lze použít jako složení.',
noAvailableCompositionsMemberType: 'Nejsou k dispozici žádné typy člena, které lze použít jako složení.',
compositionRemoveWarning:
'Odebráním složení odstraníte všechna související data vlastností. Jakmile uložíte typ dokumentu, již není cesta zpět.',
availableEditors: 'Vytvořit nové',
@@ -1403,6 +1411,8 @@ export default {
tabHasNoSortOrder: 'záložka nemá žádné řazení',
compositionUsageHeading: 'Kde se toto složení používá?',
compositionUsageSpecification: 'Toto složení se v současnosti používá ve složení následujících typů obsahu:',
compositionUsageSpecificationMediaType: 'Toto složení se v současnosti používá ve složení následujících typů média:',
compositionUsageSpecificationMemberType: 'Toto složení se v současnosti používá ve složení následujících typů člena:',
variantsHeading: 'Povolit různé jazyky',
variantsDescription: 'Povolit editorům vytvářet obsah tohoto typu v různých jazycích.',
allowVaryByCulture: 'Povolit různé jazyky',
@@ -1593,9 +1593,19 @@ export default {
chooseChildNode: 'Dewis nod blentyn',
compositionsDescription:
"Etifeddu tabiau a phriodweddau o fath o ddogfen sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o ddogfen bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionsDescriptionMediaType:
"Etifeddu tabiau a phriodweddau o fath o gyfrwng sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o gyfrwng bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionsDescriptionMemberType:
"Etifeddu tabiau a phriodweddau o fath o aelod sy'n bodoli eisoes. Bydd tabiau newydd yn cael eu ychwanegu at y fath o aelod bresennol neu eu cyfuno os mae tab gyda enw yr union yr un fath yn bodoli eisoes.",
compositionInUse:
"Mae'r math o gynnwys yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
compositionInUseMediaType:
"Mae'r math o gyfrwng yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
compositionInUseMemberType:
"Mae'r math o aelod yma wedi'i ddefnyddio mewn cyfansoddiad, felly ni ellir ei gyfansoddi ei hunan.",
noAvailableCompositions: "Nid oes unrhyw fathau o gynnwys ar gael i'w defnyddio fel cyfansoddiad.",
noAvailableCompositionsMediaType: "Nid oes unrhyw fathau o gyfrwng ar gael i'w defnyddio fel cyfansoddiad.",
noAvailableCompositionsMemberType: "Nid oes unrhyw fathau o aelod ar gael i'w defnyddio fel cyfansoddiad.",
compositionRemoveWarning:
"Bydd dileu cyfansoddiad yn dileu'r holl ddata eiddo priodwedd gysylltiedig. Ar ôl i chi arbed y math o ddogfen, bydd ddim ffordd nôl.",
availableEditors: 'Golygyddion ar gael',
@@ -1633,6 +1643,10 @@ export default {
compositionUsageHeading: "Ble mae'r cyfansoddiad yma'n cael ei ddefnyddio?",
compositionUsageSpecification:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gynnwys ganlynol:",
compositionUsageSpecificationMediaType:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o gyfrwng ganlynol:",
compositionUsageSpecificationMemberType:
"Mae'r cyfansoddiad yma yn cael ei ddefnyddio'n bresennol yng nghyfansoddiad o'r mathau o aelod ganlynol:",
variantsHeading: 'Caniatáu amrywiadau',
cultureVariantHeading: 'Caniatáu amrywiad yn ôl ddiwylliant',
segmentVariantHeading: 'Caniatáu segmentiad',
@@ -1798,9 +1798,19 @@ export default {
chooseChildNode: 'Vælg child node',
compositionsDescription:
'Nedarv faner og egenskaber fra en anden dokumenttype. Nye faner vil blive\n tilføjet den nuværende dokumenttype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionsDescriptionMediaType:
'Nedarv faner og egenskaber fra en anden medietype. Nye faner vil blive\n tilføjet den nuværende medietype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionsDescriptionMemberType:
'Nedarv faner og egenskaber fra en anden medlemstype. Nye faner vil blive\n tilføjet den nuværende medlemstype eller sammenflettet hvis fanenavnene er ens.\n ',
compositionInUse:
'Indholdstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
compositionInUseMediaType:
'Medietypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
compositionInUseMemberType:
'Medlemstypen bliver brugt i en komposition og kan derfor ikke blive anvendt som\n komposition\n ',
noAvailableCompositions: 'Der er ingen indholdstyper tilgængelige at bruge som komposition',
noAvailableCompositionsMediaType: 'Der er ingen medietyper tilgængelige at bruge som komposition',
noAvailableCompositionsMemberType: 'Der er ingen medlemstyper tilgængelige at bruge som komposition',
compositionRemoveWarning:
'Når du fjerner en komposition vil alle associerede indholdsdata blive slettet.\n Når først dokumenttypen er gemt, er der ingen vej tilbage.\n ',
availableEditors: 'Opret ny indstilling',
@@ -1838,6 +1848,8 @@ export default {
tabHasNoSortOrder: 'fane har ingen sorteringsrækkefølge',
compositionUsageHeading: 'Hvor er denne komposition brugt?',
compositionUsageSpecification: 'Denne komposition brugt i kompositionen af de følgende indholdstyper:\n ',
compositionUsageSpecificationMediaType: 'Denne komposition brugt i kompositionen af de følgende medietyper:\n ',
compositionUsageSpecificationMemberType: 'Denne komposition brugt i kompositionen af de følgende medlemstyper:\n ',
variantsHeading: 'Tillad variationer',
cultureVariantHeading: 'Tillad sprogvariation',
segmentVariantHeading: 'Tillad segmentering',
@@ -1583,9 +1583,19 @@ export default {
chooseChildNode: 'Wählen Sie einen Unterknoten',
compositionsDescription:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Inhaltstyp. Neue Tabs werden zum vorliegenden Inhaltstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionsDescriptionMediaType:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Medientyp. Neue Tabs werden zum vorliegenden Medientyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionsDescriptionMemberType:
'Übernimm Tabs und Eigenschaften vone einem vorhandenen Mitgliedstyp. Neue Tabs werden zum vorliegenden Mitgliedstyp hinzugefügt oder mit einem gleichnamigen Tab zusammengeführt.',
compositionInUse:
'Dieser Inhaltstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
compositionInUseMediaType:
'Dieser Medientyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
compositionInUseMemberType:
'Dieser Mitgliedstyp wird in einer Mischung verwendet und kann deshalb nicht selbst zusammengemischt werden.',
noAvailableCompositions: 'Es sind keine Inhaltstypen für eine Mischung vorhanden.',
noAvailableCompositionsMediaType: 'Es sind keine Medientypen für eine Mischung vorhanden.',
noAvailableCompositionsMemberType: 'Es sind keine Mitgliedstypen für eine Mischung vorhanden.',
availableEditors: 'Neu anlegen',
reuse: 'Vorhandenen nutzen',
editorSettings: 'Editor-Einstellungen',
@@ -1620,6 +1630,10 @@ export default {
compositionUsageHeading: 'Wo wird diese Mischung verwendet?',
compositionUsageSpecification:
'\n Diese Mischung wird aktuell in den Mischungen folgender Dokumenttypen verwendet:\n ',
compositionUsageSpecificationMediaType:
'\n Diese Mischung wird aktuell in den Mischungen folgender Medientypen verwendet:\n ',
compositionUsageSpecificationMemberType:
'\n Diese Mischung wird aktuell in den Mischungen folgender Mitgliedstypen verwendet:\n ',
variantsHeading: 'Kultur basierte Variationen zulassen',
variantsDescription: 'Editoren erlauben, Inhalt dieses Typs in verschiedenen Sprachen anzulegen',
allowVaryByCulture: 'Kultur basierte Variationen zulassen',
@@ -1882,8 +1882,16 @@ export default {
chooseChildNode: 'Choose child node',
compositionsDescription:
'Inherit tabs and properties from an existing Document Type. New tabs will be added to the current Document Type or merged if a tab with an identical name exists.',
compositionInUse: 'This Content Type is used in a composition, and therefore cannot be composed itself.',
noAvailableCompositions: 'There are no Content Types available to use as a composition.',
compositionsDescriptionMediaType:
'Inherit tabs and properties from an existing Media Type. New tabs will be added to the current Media Type or merged if a tab with an identical name exists.',
compositionsDescriptionMemberType:
'Inherit tabs and properties from an existing Member Type. New tabs will be added to the current Member Type or merged if a tab with an identical name exists.',
compositionInUse: 'This Document Type is used in a composition, and therefore cannot be composed itself.',
compositionInUseMediaType: 'This Media Type is used in a composition, and therefore cannot be composed itself.',
compositionInUseMemberType: 'This Member Type is used in a composition, and therefore cannot be composed itself.',
noAvailableCompositions: 'There are no Document Types available to use as a composition.',
noAvailableCompositionsMediaType: 'There are no Media Types available to use as a composition.',
noAvailableCompositionsMemberType: 'There are no Member Types available to use as a composition.',
compositionRemoveWarning:
"Removing a composition will delete all the associated property data. Once you save the Document Type there's no way back.",
availableEditors: 'Create new',
@@ -1921,7 +1929,11 @@ export default {
tabHasNoSortOrder: 'tab has no sort order',
compositionUsageHeading: 'Where is this composition used?',
compositionUsageSpecification:
'This composition is currently used in the composition of the following Content Types:',
'This composition is currently used in the composition of the following Document Types:',
compositionUsageSpecificationMediaType:
'This composition is currently used in the composition of the following Media Types:',
compositionUsageSpecificationMemberType:
'This composition is currently used in the composition of the following Member Types:',
variantsHeading: 'Variation',
cultureVariantHeading: 'Allow vary by culture',
segmentVariantHeading: 'Allow segmentation',
@@ -1120,9 +1120,19 @@ export default {
chooseChildNode: 'Elegir nodo hijo',
compositionsDescription:
'Heredar pestañas y propiedades de un tipo de documento existente. Nuevas pestañas serán añadidas al tipo de documento actual o mezcladas si una pestaña con nombre idéntico ya existe.',
compositionsDescriptionMediaType:
'Heredar pestañas y propiedades de un tipo de medio existente. Nuevas pestañas serán añadidas al tipo de medio actual o mezcladas si una pestaña con nombre idéntico ya existe.',
compositionsDescriptionMemberType:
'Heredar pestañas y propiedades de un tipo de miembro existente. Nuevas pestañas serán añadidas al tipo de miembro actual o mezcladas si una pestaña con nombre idéntico ya existe.',
compositionInUse:
'Este tipo de contenido es usado en una composición, y por tanto no puede no puede ser compuesto.',
'Este tipo de contenido es usado en una composición, y por tanto no puede ser compuesto.',
compositionInUseMediaType:
'Este tipo de medio es usado en una composición, y por tanto no puede ser compuesto.',
compositionInUseMemberType:
'Este tipo de miembro es usado en una composición, y por tanto no puede ser compuesto.',
noAvailableCompositions: 'No hay tipos de contenido disponibles para usar como composición.',
noAvailableCompositionsMediaType: 'No hay tipos de medio disponibles para usar como composición.',
noAvailableCompositionsMemberType: 'No hay tipos de miembro disponibles para usar como composición.',
availableEditors: 'Editores disponibles',
reuse: 'Reusar',
editorSettings: 'Configuración de editor',
@@ -1398,10 +1398,20 @@ export default {
childNodesDescription: 'Autorisez la création de contenu des types spécifiés sous le contenu de ce type-ci',
chooseChildNode: 'Choisissez les noeuds enfants',
compositionsDescription:
"Hériter des onglets et propriétés d'un type de document existant. De nouveaux onglets seront ajoutés au type de document actuel, ou fusionnés s'il existe un onglet avec un nom sililaire.",
"Hériter des onglets et propriétés d'un type de document existant. De nouveaux onglets seront ajoutés au type de document actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
compositionsDescriptionMediaType:
"Hériter des onglets et propriétés d'un type de media existant. De nouveaux onglets seront ajoutés au type de media actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
compositionsDescriptionMemberType:
"Hériter des onglets et propriétés d'un type de membre existant. De nouveaux onglets seront ajoutés au type de membre actuel, ou fusionnés s'il existe un onglet avec un nom similaire.",
compositionInUse:
'Ce type de contenu est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
compositionInUseMediaType:
'Ce type de media est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
compositionInUseMemberType:
'Ce type de membre est utilisé dans une composition, et ne peut donc pas être lui-même un composé.',
noAvailableCompositions: "Il n'y a pas de type de contenu disponible à utiliser dans une composition.",
noAvailableCompositionsMediaType: "Il n'y a pas de type de media disponible à utiliser dans une composition.",
noAvailableCompositionsMemberType: "Il n'y a pas de type de membre disponible à utiliser dans une composition.",
compositionRemoveWarning:
"La suppression d'une composition supprimera les données de toutes les propriétés associées. Une fois que vous sauvegardez le type de document, il n'y a plus moyen de faire marche arrière.",
availableEditors: 'Editeurs disponibles',
@@ -1438,6 +1448,10 @@ export default {
compositionUsageHeading: 'Où cette composition est-elle utilisée?',
compositionUsageSpecification:
'Cette composition est actuellement utilisée dans la composition des types de contenu suivants :',
compositionUsageSpecificationMediaType:
'Cette composition est actuellement utilisée dans la composition des types de media suivants :',
compositionUsageSpecificationMemberType:
'Cette composition est actuellement utilisée dans la composition des types de membre suivants :',
variantsHeading: 'Permettre une variation par culture',
variantsDescription: 'Permettre aux éditeurs de créer du contenu de ce type dans différentes langues.',
allowVaryByCulture: 'Permettre une variation par culture',
@@ -1513,8 +1513,16 @@ export default {
chooseChildNode: 'Odaberite podređeni čvor',
compositionsDescription:
'Naslijediti kartice i svojstva iz postojeće vrste dokumenta. Nove kartice bit će\n dodane trenutnoj vrsti dokumenta ili spojene ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMediaType:
'Naslijediti kartice i svojstva iz postojeće vrste medija. Nove kartice bit će\n dodane trenutnoj vrsti medija ili spojene ako postoji kartica s identičnim imenom.\n ',
compositionsDescriptionMemberType:
'Naslijediti kartice i svojstva iz postojeće vrste člana. Nove kartice bit će\n dodane trenutnoj vrsti člana ili spojene ako postoji kartica s identičnim imenom.\n ',
compositionInUse: 'Ova vrsta sadržaja se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMediaType: 'Ova vrsta medija se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
compositionInUseMemberType: 'Ova vrsta člana se koristi u kompoziciji i stoga se ne može sam sastaviti.\n ',
noAvailableCompositions: 'Nema dostupnih vrsta sadržaja za upotrebu kao kompozicija.',
noAvailableCompositionsMediaType: 'Nema dostupnih vrsta medija za upotrebu kao kompozicija.',
noAvailableCompositionsMemberType: 'Nema dostupnih vrsta člana za upotrebu kao kompozicija.',
compositionRemoveWarning:
'Uklanjanje kompozicije će obrisati sve povezane podatke o svojstvu. Jednom kada spremite vrstu dokumenta, nema povratka.\n ',
availableEditors: 'Napravi novi',
@@ -1548,6 +1556,8 @@ export default {
tabHasNoSortOrder: 'kartica nema redoslijed sortiranja',
compositionUsageHeading: 'Gdje se koristi ovaj sastav?',
compositionUsageSpecification: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta sadržaja:\n ',
compositionUsageSpecificationMediaType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta medija:\n ',
compositionUsageSpecificationMemberType: 'Ovaj sastav se trenutno koristi u sastavu sljedećih\n vrsta člana:\n ',
variantsHeading: 'Dozvoli varijacije',
cultureVariantHeading: 'Dozvolite varirati u zavisnosti od kulture',
segmentVariantHeading: 'Dozvoli segmentaciju',
@@ -1523,9 +1523,19 @@ export default {
chooseChildNode: 'Scegli nodo figlio',
compositionsDescription:
'Eredita schede e proprietà da un tipo di documento esistente. Le nuove schede verranno aggiunte al tipo di documento corrente o unite se esiste una scheda con un nome identico.',
compositionsDescriptionMediaType:
'Eredita schede e proprietà da un tipo di media esistente. Le nuove schede verranno aggiunte al tipo di media corrente o unite se esiste una scheda con un nome identico.',
compositionsDescriptionMemberType:
'Eredita schede e proprietà da un tipo di membro esistente. Le nuove schede verranno aggiunte al tipo di membro corrente o unite se esiste una scheda con un nome identico.',
compositionInUse:
'Questo tipo di contenuto è utlizzato in una composizione, e quindi non può essere composto da se stesso.',
'Questo tipo di contenuto è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
compositionInUseMediaType:
'Questo tipo di media è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
compositionInUseMemberType:
'Questo tipo di membro è utilizzato in una composizione, e quindi non può essere composto da se stesso.',
noAvailableCompositions: 'Non ci sono tipi di contenuto utilizzabili come composizione.',
noAvailableCompositionsMediaType: 'Non ci sono tipi di media utilizzabili come composizione.',
noAvailableCompositionsMemberType: 'Non ci sono tipi di membro utilizzabili come composizione.',
compositionRemoveWarning:
'Rimuovendo una composizione si elimineranno tutti i dati associati ad essa. Una volta salvato il tipo di documento non ci sarà nessun modo di recuperare i dati.',
availableEditors: 'Crea nuovo',
@@ -1563,6 +1573,8 @@ export default {
tabHasNoSortOrder: 'la scheda non ha un ordine',
compositionUsageHeading: 'Dove è usata questa composizione?',
compositionUsageSpecification: 'Questa composizione è usata nella composizione dei seguenti tipi di contenuto:',
compositionUsageSpecificationMediaType: 'Questa composizione è usata nella composizione dei seguenti tipi di media:',
compositionUsageSpecificationMemberType: 'Questa composizione è usata nella composizione dei seguenti tipi di membro:',
variantsHeading: 'Consenti variazioni',
cultureVariantHeading: 'Consenti variazioni in base alla lingua',
segmentVariantHeading: 'Consenti segmentazione',
@@ -879,8 +879,16 @@ export default {
chooseChildNode: '子ノードの選択',
compositionsDescription:
'既存ドキュメント タイプのタブとプロパティを継承。新しいタブを現在のドキュメント タイプに追加、または同じ名前のタブがある場合はマージされます。',
compositionsDescriptionMediaType:
'既存メディア タイプのタブとプロパティを継承。新しいタブを現在のメディア タイプに追加、または同じ名前のタブがある場合はマージされます。',
compositionsDescriptionMemberType:
'既存メンバー タイプのタブとプロパティを継承。新しいタブを現在のメンバー タイプに追加、または同じ名前のタブがある場合はマージされます。',
compositionInUse: 'このコンテンツ タイプが構成で使用されるため、自身を構成することはできません。',
compositionInUseMediaType: 'このメディア タイプが構成で使用されるため、自身を構成することはできません。',
compositionInUseMemberType: 'このメンバー タイプが構成で使用されるため、自身を構成することはできません。',
noAvailableCompositions: '構成に使用できるコンテンツ タイプはありません。',
noAvailableCompositionsMediaType: '構成に使用できるメディア タイプはありません。',
noAvailableCompositionsMemberType: '構成に使用できるメンバー タイプはありません。',
availableEditors: '使用可能なエディター',
reuse: '再利用',
editorSettings: 'エディター設定',
@@ -1477,9 +1477,19 @@ export default {
chooseChildNode: 'Kies onderliggende node',
compositionsDescription:
'Overgeërfde tabs en properties van een bestaand documenttype. Nieuwe tabs\n worden toegevoegd aan het huidige documenttype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
compositionsDescriptionMediaType:
'Overgeërfde tabs en properties van een bestaand mediatype. Nieuwe tabs\n worden toegevoegd aan het huidige mediatype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
compositionsDescriptionMemberType:
'Overgeërfde tabs en properties van een bestaand lidtype. Nieuwe tabs\n worden toegevoegd aan het huidige lidtype of samengevoegd als een tab met dezelfde naam al bestaat.\n ',
compositionInUse:
'Dit contenttype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
compositionInUseMediaType:
'Dit mediatype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
compositionInUseMemberType:
'Dit lidtype wordt gebruikt in een compositie en kan daarom niet zelf een\n compositie worden.\n ',
noAvailableCompositions: 'Er zijn geen contenttypen beschikbaar om als compositie te gebruiken.',
noAvailableCompositionsMediaType: 'Er zijn geen mediatypen beschikbaar om als compositie te gebruiken.',
noAvailableCompositionsMemberType: 'Er zijn geen lidtypen beschikbaar om als compositie te gebruiken.',
compositionRemoveWarning:
'Een compositie verwijderen zal alle bijbehorende eigenschapsdata ook\n verwijderen. Zodra je het documenttype hebt opgeslagen is er geen weg meer terug.\n ',
availableEditors: 'Beschikbare editors',
@@ -1517,6 +1527,10 @@ export default {
compositionUsageHeading: 'Waar wordt deze compositie gebruikt?',
compositionUsageSpecification:
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende inhoudstypen:\n ',
compositionUsageSpecificationMediaType:
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende mediatypen:\n ',
compositionUsageSpecificationMemberType:
'Deze samenstelling wordt momenteel gebruikt bij de samenstelling van de\n volgende lidtypen:\n ',
variantsHeading: 'Variaties toestaan',
cultureVariantHeading: 'Variëren per cultuur toestaan',
segmentVariantHeading: 'Segmentatie toestaan',
@@ -1062,8 +1062,16 @@ export default {
chooseChildNode: 'Wybierz węzeł dziecka',
compositionsDescription:
'Odziedzicz zakładki i właściwości z istniejącego typu dokumentu. Nowe zakładki będą dodane do bieżącego typu dokumentu lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
compositionsDescriptionMediaType:
'Odziedzicz zakładki i właściwości z istniejącego typu mediów. Nowe zakładki będą dodane do bieżącego typu mediów lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
compositionsDescriptionMemberType:
'Odziedzicz zakładki i właściwości z istniejącego typu członka. Nowe zakładki będą dodane do bieżącego typu członka lub złączone jeśli zakładka z identyczną nazwą już istnieje.',
compositionInUse: 'Ten typ zawartości jest używany w kompozycji, przez co sam nie może być złożony.',
compositionInUseMediaType: 'Ten typ mediów jest używany w kompozycji, przez co sam nie może być złożony.',
compositionInUseMemberType: 'Ten typ członka jest używany w kompozycji, przez co sam nie może być złożony.',
noAvailableCompositions: 'Brak możliwych typów zawartości do użycia jako kompozycja.',
noAvailableCompositionsMediaType: 'Brak możliwych typów mediów do użycia jako kompozycja.',
noAvailableCompositionsMemberType: 'Brak możliwych typów członka do użycia jako kompozycja.',
availableEditors: 'Dostępni edytorzy',
reuse: 'Użyj ponownie',
editorSettings: 'Ustawienia edytora',
@@ -1729,8 +1729,16 @@ export default {
chooseChildNode: 'Escolher nó filho',
compositionsDescription:
'Herde separadores e propriedades de um Tipo de Documento existente. Novos separadores serão adicionados ao Tipo de Documento atual ou fundidos se existir um separador com um nome idêntico.',
compositionsDescriptionMediaType:
'Herde separadores e propriedades de um Tipo de Multimédia existente. Novos separadores serão adicionados ao Tipo de Multimédia atual ou fundidos se existir um separador com um nome idêntico.',
compositionsDescriptionMemberType:
'Herde separadores e propriedades de um Tipo de Membro existente. Novos separadores serão adicionados ao Tipo de Membro atual ou fundidos se existir um separador com um nome idêntico.',
compositionInUse: 'Este Tipo de Conteúdo é usado numa composição e, portanto, não pode ser composto ele próprio.',
compositionInUseMediaType: 'Este Tipo de Multimédia é usado numa composição e, portanto, não pode ser composto ele próprio.',
compositionInUseMemberType: 'Este Tipo de Membro é usado numa composição e, portanto, não pode ser composto ele próprio.',
noAvailableCompositions: 'Não existem Tipos de Conteúdo disponíveis para usar como composição.',
noAvailableCompositionsMediaType: 'Não existem Tipos de Multimédia disponíveis para usar como composição.',
noAvailableCompositionsMemberType: 'Não existem Tipos de Membro disponíveis para usar como composição.',
compositionRemoveWarning:
'Remover uma composição eliminará todos os dados de propriedade associados. Depois de guardar o Tipo de Documento, não há como voltar atrás.',
availableEditors: 'Criar novo',
@@ -1767,6 +1775,8 @@ export default {
tabHasNoSortOrder: 'o separador não tem ordem',
compositionUsageHeading: 'Onde é usada esta composição?',
compositionUsageSpecification: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Conteúdo:',
compositionUsageSpecificationMediaType: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Multimédia:',
compositionUsageSpecificationMemberType: 'Esta composição é atualmente usada na composição dos seguintes Tipos de Membro:',
variantsHeading: 'Variação',
cultureVariantHeading: 'Permitir variar por cultura',
segmentVariantHeading: 'Permitir segmentação',
@@ -275,11 +275,23 @@ export default {
chooseChildNode: 'Выбрать дочерний узел',
compositionsDescription:
'Унаследовать вкладки и свойства из уже существующего типа документов. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
compositionsDescriptionMediaType:
'Унаследовать вкладки и свойства из уже существующего типа медиа. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
compositionsDescriptionMemberType:
'Унаследовать вкладки и свойства из уже существующего типа участников. Вкладки будут либо добавлены в создаваемый тип, либо в случае совпадения названий вкладок будут добавлены наследуемые свойства.',
compositionInUse:
'Этот тип документов уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
compositionInUseMediaType:
'Этот тип медиа уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
compositionInUseMemberType:
'Этот тип участников уже участвует в композиции другого типа, поэтому сам не может быть композицией.',
compositionUsageHeading: 'Где используется эта композиция?',
compositionUsageSpecification: 'Эта композиция сейчас используется при создании следующих типов документов:',
compositionUsageSpecificationMediaType: 'Эта композиция сейчас используется при создании следующих типов медиа:',
compositionUsageSpecificationMemberType: 'Эта композиция сейчас используется при создании следующих типов участников:',
noAvailableCompositions: 'В настоящее время нет типов документов, допустимых для построения композиции.',
noAvailableCompositionsMediaType: 'В настоящее время нет типов медиа, допустимых для построения композиции.',
noAvailableCompositionsMemberType: 'В настоящее время нет типов участников, допустимых для построения композиции.',
availableEditors: 'Доступные редакторы',
reuse: 'Переиспользовать',
editorSettings: 'Установки редактора',
@@ -1356,8 +1356,16 @@ export default {
chooseChildNode: 'Alt düğümü seçin',
compositionsDescription:
'Mevcut bir belge türünden sekmeleri ve özellikleri devralın. Mevcut belge türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
compositionsDescriptionMediaType:
'Mevcut bir medya türünden sekmeleri ve özellikleri devralın. Mevcut medya türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
compositionsDescriptionMemberType:
'Mevcut bir üye türünden sekmeleri ve özellikleri devralın. Mevcut üye türüne yeni sekmeler eklenecek veya aynı ada sahip bir sekme varsa birleştirilecektir.',
compositionInUse: 'Bu içerik türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
compositionInUseMediaType: 'Bu medya türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
compositionInUseMemberType: 'Bu üye türü bir bestede kullanıldığından kendi başına oluşturulamaz.',
noAvailableCompositions: 'Beste olarak kullanılabilecek içerik türü yok.',
noAvailableCompositionsMediaType: 'Beste olarak kullanılabilecek medya türü yok.',
noAvailableCompositionsMemberType: 'Beste olarak kullanılabilecek üye türü yok.',
compositionRemoveWarning:
'Bir kompozisyonun kaldırılması, ilişkili tüm özellik verilerini silecektir. Belge türünü kaydettikten sonra geri dönüş yoktur.',
availableEditors: 'Yeni oluştur',
@@ -1392,6 +1400,8 @@ export default {
tabHasNoSortOrder: 'sekmesinde sıralama düzeni yok',
compositionUsageHeading: 'Bu beste nerede kullanılıyor?',
compositionUsageSpecification: 'Bu beste şu anda aşağıdaki içerik türlerinin oluşturulmasında kullanılmaktadır:',
compositionUsageSpecificationMediaType: 'Bu beste şu anda aşağıdaki medya türlerinin oluşturulmasında kullanılmaktadır:',
compositionUsageSpecificationMemberType: 'Bu beste şu anda aşağıdaki üye türlerinin oluşturulmasında kullanılmaktadır:',
cultureVariantHeading: 'Kültüre göre değişikliklere izin ver',
segmentVariantHeading: 'Segmentasyona izin ver',
cultureVariantLabel: 'Kültüre göre değişiklik yapın',
@@ -275,10 +275,20 @@ export default {
chooseChildNode: 'Вибрати дочірній вузол',
compositionsDescription:
'Успадкувати вкладки та властивості з існуючого типу документів. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
compositionsDescriptionMediaType:
'Успадкувати вкладки та властивості з існуючого типу медіа. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
compositionsDescriptionMemberType:
'Успадкувати вкладки та властивості з існуючого типу учасників. Вкладки будуть або додані до створюваного типу, або у разі збігу назв вкладок будуть додані успадковані властивості.',
compositionInUse: 'Цей тип документів вже бере участь у композиції іншого типу, тому сам може бути композицією.',
compositionInUseMediaType: 'Цей тип медіа вже бере участь у композиції іншого типу, тому сам може бути композицією.',
compositionInUseMemberType: 'Цей тип учасників вже бере участь у композиції іншого типу, тому сам може бути композицією.',
compositionUsageHeading: 'Де використовується ця композиція?',
compositionUsageSpecification: 'Ця композиція зараз використовується при створенні таких типів документів:',
compositionUsageSpecificationMediaType: 'Ця композиція зараз використовується при створенні таких типів медіа:',
compositionUsageSpecificationMemberType: 'Ця композиція зараз використовується при створенні таких типів учасників:',
noAvailableCompositions: 'Наразі немає типів документів, допустимих побудови композиції.',
noAvailableCompositionsMediaType: 'Наразі немає типів медіа, допустимих побудови композиції.',
noAvailableCompositionsMemberType: 'Наразі немає типів учасників, допустимих побудови композиції.',
availableEditors: 'Доступні редактори',
reuse: 'Перевикористати',
editorSettings: 'Налаштування редактора',
@@ -1736,8 +1736,16 @@ export default {
chooseChildNode: 'Chọn nút con',
compositionsDescription:
'Kế thừa các tab và thuộc tính từ một loại tài liệu hiện có. Các tab mới sẽ được thêm vào loại tài liệu hiện tại hoặc được hợp nhất nếu một tab có tên giống hệt tồn tại.',
compositionsDescriptionMediaType:
'Kế thừa các tab và thuộc tính từ một loại phương tiện hiện có. Các tab mới sẽ được thêm vào loại phương tiện hiện tại hoặc được hợp nhất nếu một tab có tên giống hệt tồn tại.',
compositionsDescriptionMemberType:
'Kế thừa các tab và thuộc tính từ một loại thành viên hiện có. Các tab mới sẽ được thêm vào loại thành viên hiện tại hoặc được hợp nhất nếu một tab có tên giống hệt tồn tại.',
compositionInUse: 'Loại nội dung này đang được sử dụng trong một thành phần, vì vậy không thể tự tạo thành phần.',
compositionInUseMediaType: 'Loại phương tiện này đang được sử dụng trong một thành phần, vì vậy không thể tự tạo thành phần.',
compositionInUseMemberType: 'Loại thành viên này đang được sử dụng trong một thành phần, vì vậy không thể tự tạo thành phần.',
noAvailableCompositions: 'Không có loại nội dung nào có sẵn để sử dụng làm thành phần.',
noAvailableCompositionsMediaType: 'Không có loại phương tiện nào có sẵn để sử dụng làm thành phần.',
noAvailableCompositionsMemberType: 'Không có loại thành viên nào có sẵn để sử dụng làm thành phần.',
compositionRemoveWarning:
'Việc xóa một thành phần sẽ xóa tất cả dữ liệu thuộc tính liên quan. Khi bạn lưu loại tài liệu, sẽ không có cách nào quay lại.',
availableEditors: 'Tạo mới',
@@ -1775,6 +1783,10 @@ export default {
compositionUsageHeading: 'Loại tài liệu này đang được sử dụng ở đâu?',
compositionUsageSpecification:
'Loại tài liệu này hiện đang được sử dụng trong thành phần của các loại nội dung sau:',
compositionUsageSpecificationMediaType:
'Loại phương tiện này hiện đang được sử dụng trong thành phần của các loại phương tiện sau:',
compositionUsageSpecificationMemberType:
'Loại thành viên này hiện đang được sử dụng trong thành phần của các loại thành viên sau:',
variantsHeading: 'Biến thể',
cultureVariantHeading: 'Cho phép thay đổi theo văn hóa',
segmentVariantHeading: 'Cho phép phân đoạn',
@@ -849,8 +849,16 @@ export default {
chooseChildNode: '選擇子節點',
compositionsDescription:
'從已存在的文檔類別中繼承選項卡以及屬性。新選項卡將被新增至目前文檔種類或合併至已存在同名的選項卡中。',
compositionsDescriptionMediaType:
'從已存在的媒體類別中繼承選項卡以及屬性。新選項卡將被新增至目前媒體種類或合併至已存在同名的選項卡中。',
compositionsDescriptionMemberType:
'從已存在的會員類別中繼承選項卡以及屬性。新選項卡將被新增至目前會員種類或合併至已存在同名的選項卡中。',
compositionInUse: '此內容種類已經用於集合中,因此不能重複添加本身。',
compositionInUseMediaType: '此媒體種類已經用於集合中,因此不能重複添加本身。',
compositionInUseMemberType: '此會員種類已經用於集合中,因此不能重複添加本身。',
noAvailableCompositions: '沒有可用於集合的內容種類。',
noAvailableCompositionsMediaType: '沒有可用於集合的媒體種類。',
noAvailableCompositionsMemberType: '沒有可用於集合的會員種類。',
availableEditors: '可用的編輯器',
reuse: '重複使用',
editorSettings: '編輯器設定',
@@ -847,8 +847,16 @@ export default {
chooseChildNode: '选择子节点',
compositionsDescription:
'从现有文档类型继承选项卡和属性。如果存在同名的选项卡, 则新选项卡将添加到当前文档类型或合并。',
compositionsDescriptionMediaType:
'从现有媒体类型继承选项卡和属性。如果存在同名的选项卡, 则新选项卡将添加到当前媒体类型或合并。',
compositionsDescriptionMemberType:
'从现有成员类型继承选项卡和属性。如果存在同名的选项卡, 则新选项卡将添加到当前成员类型或合并。',
compositionInUse: '此内容类型在组合中使用, 因此不能自行组成。',
compositionInUseMediaType: '此媒体类型在组合中使用, 因此不能自行组成。',
compositionInUseMemberType: '此成员类型在组合中使用, 因此不能自行组成。',
noAvailableCompositions: '没有可供组合使用的内容类型。',
noAvailableCompositionsMediaType: '没有可供组合使用的媒体类型。',
noAvailableCompositionsMemberType: '没有可供组合使用的成员类型。',
availableEditors: '可用编辑器',
reuse: '重用',
editorSettings: '编辑器设置',
+1 -1
View File
@@ -6,6 +6,6 @@
"build": "vite build"
},
"dependencies": {
"@umbraco-ui/uui": "^2.0.0-rc.2"
"@umbraco-ui/uui": "^2.0.0"
}
}
@@ -0,0 +1,132 @@
import type { ManifestBase } from '../types/index.js';
import { UmbExtensionRegistry } from '../registry/extension.registry.js';
import { loadManifestPlainJs } from '../functions/load-manifest-plain-js.function.js';
import { UmbExtensionInitializerBase } from './extension-initializer-base.js';
import { UmbObserver } from '../../observable-api/observer.js';
import { expect, fixture } from '@open-wc/testing';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import type { UmbElement } from '@umbraco-cms/backoffice/element-api';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
@customElement('umb-test-initializer-base-host')
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class UmbTestInitializerBaseHostElement extends UmbElementMixin(HTMLElement) {}
async function wait(ms: number) {
await new Promise((r) => setTimeout(r, ms));
}
// Factory for a concrete initializer over the 'test' manifest type. The base constructor's
// `observe` callback fires synchronously during `super()` — before any subclass field would
// initialise — so the record of instantiated aliases is a closed-over array created up front
// rather than instance state.
function createTestInitializer(host: UmbElement, registry: UmbExtensionRegistry<ManifestBase>) {
const instantiated: string[] = [];
class UmbTestInitializer extends UmbExtensionInitializerBase<'test'> {
constructor() {
super(host, registry as never, 'test');
}
async instantiateExtension(manifest: ManifestBase & { js?: unknown }): Promise<void> {
if (manifest.js) {
await loadManifestPlainJs(manifest.js as never);
}
instantiated.push(manifest.alias);
}
unloadExtension(manifest: ManifestBase): void {
const index = instantiated.indexOf(manifest.alias);
if (index !== -1) instantiated.splice(index, 1);
}
}
return { initializer: new UmbTestInitializer(), instantiated };
}
describe('UmbExtensionInitializerBase — loaded signal', () => {
let hostElement: UmbElement;
beforeEach(async () => {
hostElement = await fixture(html`<umb-test-initializer-base-host></umb-test-initializer-base-host>`);
});
// Regression for the v17.4+ external-login race (introduced in #22522).
//
// A default Umbraco install registers ZERO app-entry-point extensions. The boot sequence
// awaits the app-entry-point initializer's `loaded` before deciding which login provider
// to use. If `loaded` never resolves when there are no matching extensions, that await
// hangs forever — which is precisely why the await was removed, leaving externally
// registered auth providers un-awaited and the login flow racing on slow connections.
//
// So: an initializer for a type with zero matching extensions MUST still resolve `loaded`.
it('resolves `loaded` even when no extensions of the type are registered', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
const { initializer } = createTestInitializer(hostElement, extensionRegistry);
const outcome = await Promise.race([
new UmbObserver(initializer.loaded).asPromise().then(() => 'resolved'),
wait(1000).then(() => 'timeout'),
]);
expect(outcome, '`loaded` must resolve for an initializer with zero matching extensions').to.equal('resolved');
});
// Regression for the late-loading race that the external-login bug is built on.
//
// This simulates an extension that registers AFTER the initial load and whose
// instantiation is slow (the app-entry-point case: its onInit registers an auth provider
// after an async module load). A consumer that awaits `loaded` must not be told "loaded"
// until that late, slow extension has actually finished instantiating — otherwise it makes
// its decision (e.g. which login provider to redirect to) against a stale registry.
it('does not report `loaded` until a late-registered, slow extension has finished instantiating', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
// Initial, fast extension — load settles to `true`.
extensionRegistry.register({ type: 'test', name: 'a', alias: 'Umb.Test.A' } as never);
const { initializer, instantiated } = createTestInitializer(hostElement, extensionRegistry);
await new UmbObserver(initializer.loaded).asPromise();
expect(instantiated, 'initial extension instantiated').to.eql(['Umb.Test.A']);
// A late, slow extension registers (mirrors an app-entry-point's onInit registering an
// auth provider after an async delay).
extensionRegistry.register({
type: 'test',
name: 'b-late',
alias: 'Umb.Test.B.Late',
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
} as never);
// Awaiting `loaded` now must wait for the late extension to finish instantiating.
const lateExtInstantiatedWhenLoaded = await new UmbObserver(initializer.loaded)
.asPromise()
.then(() => instantiated.includes('Umb.Test.B.Late'));
expect(lateExtInstantiatedWhenLoaded, '`loaded` resolved before the late, slow extension finished instantiating').to
.be.true;
});
// Permission-timing guard (re: the #22522 "user permissions resolved too late" concern).
//
// The backoffice route is gated by `#loadedGuard`, which awaits `bundleInitializer.loaded`
// via `.asPromise()`; the private extensions and user-permission data that load behind that
// gate must not be raced. So the gate must NOT open until the extensions registered before it
// was awaited have actually finished instantiating. This guards against a naive "resolve
// unconditionally" that sets `loaded` before instantiation completes.
//
// Note: user-permission *condition* resolution itself lives in UmbBaseExtensionInitializer
// (see base-extension-initializer.race.test.ts) — a different class this change does not touch.
it('does not open the `loaded` gate until the initially-registered extensions have instantiated', async () => {
const extensionRegistry = new UmbExtensionRegistry<ManifestBase>();
extensionRegistry.register({
type: 'test',
name: 'slow-boot',
alias: 'Umb.Test.SlowBoot',
js: () => new Promise((r) => setTimeout(() => r({}), 100)),
} as never);
const { initializer, instantiated } = createTestInitializer(hostElement, extensionRegistry);
const instantiatedWhenGateOpened = await new UmbObserver(initializer.loaded)
.asPromise()
.then(() => instantiated.includes('Umb.Test.SlowBoot'));
expect(instantiatedWhenGateOpened, '`loaded` opened the gate before the extension instantiated').to.be.true;
});
});
@@ -20,11 +20,23 @@ export abstract class UmbExtensionInitializerBase<
#loaded = new UmbBooleanState(undefined);
loaded = this.#loaded.asObservable();
// Identifies the current processing pass. The observer callback is async, so passes can
// overlap; only the latest pass is allowed to settle `loaded`, so a slower earlier pass
// cannot unblock waiters before the newest set of extensions has finished instantiating.
#loadPass = 0;
constructor(host: UmbElement, extensionRegistry: UmbExtensionRegistry<T>, manifestType: Key) {
super(host);
this.host = host;
this.extensionRegistry = extensionRegistry;
this.observe(extensionRegistry.byType<Key, T>(manifestType), async (extensions) => {
const pass = ++this.#loadPass;
// Re-arm while this pass is in flight so a consumer awaiting `loaded` waits for it to
// finish instead of resolving on a stale `true` from a previous pass. `undefined`
// rather than `false` because `asPromise()` resolves on the first non-undefined value.
this.#loaded.setValue(undefined);
this.#extensionMap.forEach((existingExt) => {
if (!extensions.find((b) => b.alias === existingExt.alias)) {
this.unloadExtension(existingExt);
@@ -32,7 +44,10 @@ export abstract class UmbExtensionInitializerBase<
}
});
await Promise.all(
// `allSettled` so a throwing/rejecting `instantiateExtension` cannot leave `loaded`
// stuck at `undefined` and hang a waiter (e.g. the app boot gate). Failures are
// surfaced rather than swallowed.
const results = await Promise.allSettled(
extensions.map((extension) => {
if (this.#extensionMap.has(extension.alias)) return;
this.#extensionMap.set(extension.alias, extension);
@@ -40,7 +55,16 @@ export abstract class UmbExtensionInitializerBase<
}),
);
if (extensions.length > 0) {
for (const result of results) {
if (result.status === 'rejected') {
console.error('[UmbExtensionInitializer] Failed to instantiate extension', result.reason);
}
}
// Only the latest pass settles `loaded`. Resolving unconditionally — including for
// zero extensions — so a consumer awaiting `loaded` (the app-entry-point boot gate,
// the bundle guard) never hangs on a default install that registers none of this type.
if (pass === this.#loadPass) {
this.#loaded.setValue(true);
}
});
@@ -14,7 +14,11 @@ import type { UmbBlockEditorCustomViewConfiguration } from '@umbraco-cms/backoff
import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
import type { UmbDataTypeDetailModel } from '@umbraco-cms/backoffice/data-type';
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import type { UMB_BLOCK_WORKSPACE_CONTEXT, UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type {
UMB_BLOCK_WORKSPACE_CONTEXT,
UmbBlockDataType,
UmbBlockLabelUfmValueType,
} from '@umbraco-cms/backoffice/block';
const apiArgsCreator: UmbApiConstructorArgumentsMethodType<unknown> = (manifest: unknown) => {
return [{ manifest }];
@@ -204,7 +208,7 @@ export class UmbBlockGridBlockInlineElement extends UmbLitElement {
}
#renderBlockInfo() {
const blockValue = { ...this.content, $settings: this.settings, $index: this.index };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings, $index: this.index };
return html`
<span id="content">
<span id="icon">
@@ -1,6 +1,6 @@
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { css, customElement, html, property, when } from '@umbraco-cms/backoffice/external/lit';
import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockDataType, UmbBlockLabelUfmValueType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockEditorCustomViewConfiguration } from '@umbraco-cms/backoffice/block-custom-view';
import '@umbraco-cms/backoffice/ufm';
@@ -30,7 +30,7 @@ export class UmbBlockGridBlockElement extends UmbLitElement {
settings?: UmbBlockDataType;
override render() {
const blockValue = { ...this.content, $settings: this.settings, $index: this.index };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings, $index: this.index };
return html`
<umb-ref-grid-block
standalone
@@ -12,7 +12,11 @@ import { UmbLanguageItemRepository } from '@umbraco-cms/backoffice/language';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbApiConstructorArgumentsMethodType } from '@umbraco-cms/backoffice/extension-api';
import type { UmbBlockDataType, UMB_BLOCK_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/block';
import type {
UmbBlockDataType,
UMB_BLOCK_WORKSPACE_CONTEXT,
UmbBlockLabelUfmValueType,
} from '@umbraco-cms/backoffice/block';
import '../../../block/workspace/views/edit/block-workspace-view-edit-content-no-router.element.js';
import { UmbContextBoundary } from '@umbraco-cms/backoffice/context-api';
@@ -186,7 +190,7 @@ export class UmbInlineListBlockElement extends UmbLitElement {
}
#renderBlockInfo() {
const blockValue = { ...this.content, $settings: this.settings, $index: this.index };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings, $index: this.index };
return html`
<span id="content">
<span id="icon">
@@ -1,6 +1,6 @@
import { css, customElement, html, property, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockDataType, UmbBlockLabelUfmValueType } from '@umbraco-cms/backoffice/block';
import '@umbraco-cms/backoffice/ufm';
import type { UmbBlockEditorCustomViewConfiguration } from '@umbraco-cms/backoffice/block-custom-view';
@@ -30,7 +30,7 @@ export class UmbRefListBlockElement extends UmbLitElement {
config?: UmbBlockEditorCustomViewConfiguration;
override render() {
const blockValue = { ...this.content, $settings: this.settings, $index: this.index };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings, $index: this.index };
return html`
<uui-ref-node
standalone
@@ -497,6 +497,10 @@ export class UmbPropertyEditorUIBlockListElement
static override readonly styles = [
css`
:host {
display: block;
}
uui-button-group {
margin-top: 1px;
display: grid;
@@ -1,12 +1,11 @@
import type { UmbBlockRteLayoutModel } from '../../types.js';
import { UMB_BLOCK_RTE } from '../../constants.js';
import { UmbBlockRteEntryContext } from '../../context/block-rte-entry.context.js';
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
import { UMB_BLOCK_RTE } from '../../constants.js';
import { css, customElement, html, nothing, property, when, state } from '@umbraco-cms/backoffice/external/lit';
import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type {
ManifestBlockEditorCustomView,
UmbBlockEditorCustomViewProperties,
@@ -15,6 +14,7 @@ import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/propert
import type { UmbExtensionElementInitializer } from '@umbraco-cms/backoffice/extension-api';
import '../ref-rte-block/index.js';
import '../unsupported-rte-block/index.js';
import '../../../block/action/block-action-list.element.js';
/**
@@ -63,6 +63,10 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
@state()
private _contentTypeAlias?: string;
/** Reflects whether the block's content type is no longer registered. Set internally by context — do not set externally. */
@property({ type: Boolean, attribute: 'unsupported', reflect: true })
unsupported?: boolean;
@state()
private _contentTypeName?: string;
@@ -96,67 +100,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
// We do not have index for RTE Blocks at the moment.
this.#context.setIndex(0);
this.observe(
this.#context.showContentEdit,
(showContentEdit) => {
this._showContentEdit = showContentEdit;
this.#updateBlockViewProps({ config: { ...this._blockViewProps.config!, showContentEdit } });
},
null,
);
this.observe(
this.#context.settingsElementTypeKey,
(key) => {
this.#updateBlockViewProps({ config: { ...this._blockViewProps.config!, showSettingsEdit: !!key } });
},
null,
);
this.observe(
this.#context.contentElementTypeAlias,
(alias) => {
this._contentTypeAlias = alias;
},
null,
);
this.observe(
this.#context.contentElementTypeName,
(contentElementTypeName) => {
this._contentTypeName = contentElementTypeName;
},
null,
);
this.observe(
this.#context.blockType,
(blockType) => {
this.#updateBlockViewProps({ blockType });
},
null,
);
this.observe(this.#context.index, (index) => this.#updateBlockViewProps({ index }), null);
this.observe(
this.#context.label,
(label) => {
this.#updateBlockViewProps({ label });
this._label = label;
},
null,
);
this.observe(
this.#context.contentElementTypeIcon,
(icon) => {
this.#updateBlockViewProps({ icon });
this._icon = icon;
},
null,
);
this.observe(
this.#context.hasExpose,
(exposed) => {
this.#updateBlockViewProps({ unpublished: !exposed });
this._exposed = exposed;
},
null,
);
this.#observeBlockViewProps();
this.observe(this.#context.actionsVisibility, (showActions) => (this._showActions = showActions), null);
@@ -215,6 +159,67 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
);
}
#observeBlockViewProps() {
this.observe(
this.#context.showContentEdit,
(showContentEdit) => {
this._showContentEdit = showContentEdit;
this.#updateBlockViewProps({ config: { ...this._blockViewProps.config!, showContentEdit } });
},
null,
);
this.observe(
this.#context.settingsElementTypeKey,
(key) => {
this.#updateBlockViewProps({ config: { ...this._blockViewProps.config!, showSettingsEdit: !!key } });
},
null,
);
this.observe(this.#context.contentElementTypeAlias, (alias) => (this._contentTypeAlias = alias), null);
this.observe(this.#context.contentElementTypeName, (name) => (this._contentTypeName = name), null);
this.observe(
this.#context.blockType,
(blockType) => {
this.#updateBlockViewProps({ blockType });
},
null,
);
this.observe(this.#context.index, (index) => this.#updateBlockViewProps({ index }), null);
this.observe(
this.#context.label,
(label) => {
this.#updateBlockViewProps({ label });
this._label = label;
},
null,
);
this.observe(
this.#context.contentElementTypeIcon,
(icon) => {
this.#updateBlockViewProps({ icon });
this._icon = icon;
},
null,
);
this.observe(
this.#context.hasExpose,
(exposed) => {
this.#updateBlockViewProps({ unpublished: !exposed });
this._exposed = exposed;
},
null,
);
this.observe(
this.#context.unsupported,
(unsupported) => {
if (unsupported === undefined) return;
this.#updateBlockViewProps({ unsupported });
this.unsupported = unsupported;
},
null,
);
}
async #observeData() {
this.observe(
await this.#context.contentValues(),
@@ -238,6 +243,10 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
}
readonly #filterBlockCustomViews = (manifest: ManifestBlockEditorCustomView) => {
if (this.unsupported) {
return false;
}
const elementTypeAlias = this._contentTypeAlias ?? '';
const isForBlockEditor =
!manifest.forBlockEditor || stringOrStringArrayContains(manifest.forBlockEditor, UMB_BLOCK_RTE);
@@ -255,34 +264,38 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
if (this._exposed || this._isReadOnly) {
return ext.component;
} else {
return html`<div>
${ext.component}
<umb-block-overlay-expose-button
.contentTypeName=${this._contentTypeName}
@click=${this.#expose}></umb-block-overlay-expose-button>
</div>`;
return html`
<div>
${ext.component}
<umb-block-overlay-expose-button
.contentTypeName=${this._contentTypeName}
@click=${this.#expose}></umb-block-overlay-expose-button>
</div>
`;
}
};
#renderBlock() {
return this.contentKey && this._contentTypeAlias
? html`
<div class="uui-text uui-font">
<umb-extension-slot
type="blockEditorCustomView"
default-element="umb-ref-rte-block"
.renderMethod=${this.#extensionSlotRenderMethod}
.fallbackRenderMethod=${this.#renderBuiltinBlockView}
.props=${this._blockViewProps}
.filter=${this.#filterBlockCustomViews}
single></umb-extension-slot>
${this.#renderActionBar()}
${!this._showContentEdit && this._contentInvalid
? html`<uui-badge attention color="invalid" label="Invalid content">!</uui-badge>`
: nothing}
</div>
`
: nothing;
return when(
this.contentKey && (this._contentTypeAlias || this.unsupported),
() => html`
<div>
<umb-extension-slot
type="blockEditorCustomView"
default-element="umb-ref-rte-block"
.renderMethod=${this.#extensionSlotRenderMethod}
.fallbackRenderMethod=${this.#renderBuiltinBlockView}
.props=${this._blockViewProps}
.filter=${this.#filterBlockCustomViews}
single></umb-extension-slot>
${this.#renderActionBar()}
${when(
!this._showContentEdit && this._contentInvalid,
() => html`<uui-badge attention color="invalid" label="Invalid content">!</uui-badge>`,
)}
</div>
`,
);
}
#renderActionBar() {
@@ -290,23 +303,28 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
return html`<umb-block-action-list id="actions" block-editor=${UMB_BLOCK_RTE}></umb-block-action-list>`;
}
#renderUnsupportedBlock() {
return html`<umb-unsupported-rte-block></umb-unsupported-rte-block>`;
}
#renderBuiltinBlockView = () => {
// TODO: Missing unsupported rendering [NL]
/*if (this._unsupported) {
if (this.unsupported) {
return this.#renderUnsupportedBlock();
}*/
}
return this.#renderRefBlock();
};
#renderRefBlock() {
return html`<umb-ref-rte-block
.label=${this._label}
.icon=${this._icon}
.index=${this._blockViewProps.index}
.unpublished=${!this._exposed}
.content=${this._blockViewProps.content}
.settings=${this._blockViewProps.settings}
.config=${this._blockViewProps.config}></umb-ref-rte-block>`;
return html`
<umb-ref-rte-block
.label=${this._label}
.icon=${this._icon}
.index=${this._blockViewProps.index}
.unpublished=${!this._exposed}
.content=${this._blockViewProps.content}
.settings=${this._blockViewProps.settings}
.config=${this._blockViewProps.config}></umb-ref-rte-block>
`;
}
override render() {
@@ -314,7 +332,6 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
}
static override readonly styles = [
UmbTextStyles,
css`
:host {
position: relative;
@@ -332,10 +349,8 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert
}
:host(.ProseMirror-selectednode) {
umb-ref-rte-block {
--uui-color-default-contrast: initial;
outline: 3px solid var(--uui-color-focus);
}
--uui-color-default-contrast: initial;
outline: 3px solid var(--uui-color-focus);
}
umb-extension-slot::part(component) {
@@ -1,6 +1,6 @@
import { css, customElement, html, property } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockDataType, UmbBlockLabelUfmValueType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockEditorCustomViewConfiguration } from '@umbraco-cms/backoffice/block-custom-view';
/**
@@ -31,7 +31,7 @@ export class UmbRefRteBlockElement extends UmbLitElement {
config?: UmbBlockEditorCustomViewConfiguration;
override render() {
const blockValue = { ...this.content, $settings: this.settings, $index: this.index };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings, $index: this.index };
return html`
<uui-ref-node
standalone
@@ -0,0 +1 @@
export * from './unsupported-rte-block.element.js';
@@ -0,0 +1,56 @@
import { css, customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
/**
* @element umb-unsupported-rte-block
*/
@customElement('umb-unsupported-rte-block')
export class UmbUnsupportedRteBlockElement extends UmbLitElement {
override render() {
return html`
<uui-ref-node
standalone
.readonly=${true}
detail=${this.localize.term('blockEditor_unsupportedBlockDescription')}>
<div class="selection-background" aria-hidden="true">&emsp;</div>
<umb-icon slot="icon" name="icon-alert"></umb-icon>
<span slot="name">${this.localize.term('blockEditor_unsupportedBlockName')}</span>
</uui-ref-node>
`;
}
static override readonly styles = [
css`
:host {
display: block;
}
uui-ref-node {
min-height: var(--uui-size-16);
}
/* HACK: Stretches a space character (&emsp;) to be full-width to make the RTE block appear text-selectable. [LK,NL] */
.selection-background {
position: absolute;
pointer-events: none;
font-size: 100vw;
inset: 0;
overflow: hidden;
z-index: 0;
}
umb-icon,
span {
z-index: 1;
}
`,
];
}
export default UmbUnsupportedRteBlockElement;
declare global {
interface HTMLElementTagNameMap {
'umb-unsupported-rte-block': UmbUnsupportedRteBlockElement;
}
}
@@ -0,0 +1,22 @@
import { expect, fixture, html } from '@open-wc/testing';
import { type UmbTestRunnerWindow, defaultA11yConfig } from '@umbraco-cms/internal/test-utils';
import UmbUnsupportedRteBlockElement from './unsupported-rte-block.element.js';
describe('UmbUnsupportedRteBlock', () => {
let element: UmbUnsupportedRteBlockElement;
beforeEach(async () => {
element = await fixture(html`<umb-unsupported-rte-block></umb-unsupported-rte-block>`);
});
it('is defined with its own instance', () => {
expect(element).to.be.instanceOf(UmbUnsupportedRteBlockElement);
});
if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {
it('passes the a11y audit', async () => {
await expect(element).to.be.accessible(defaultA11yConfig);
});
}
});
@@ -7,7 +7,11 @@ import { UmbLanguageItemRepository } from '@umbraco-cms/backoffice/language';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import type { UmbApiConstructorArgumentsMethodType } from '@umbraco-cms/backoffice/extension-api';
import type { UmbBlockDataType, UMB_BLOCK_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/block';
import type {
UmbBlockDataType,
UMB_BLOCK_WORKSPACE_CONTEXT,
UmbBlockLabelUfmValueType,
} from '@umbraco-cms/backoffice/block';
import '../../../block/workspace/views/edit/block-workspace-view-edit-content-no-router.element.js';
@@ -154,7 +158,7 @@ export class UmbInlineSingleBlockElement extends UmbLitElement {
}
#renderBlockInfo() {
const blockValue = { ...this.content, $settings: this.settings };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings };
return html`
<span id="content">
<span id="icon">
@@ -1,6 +1,6 @@
import { css, customElement, html, property, when } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockDataType, UmbBlockLabelUfmValueType } from '@umbraco-cms/backoffice/block';
import '@umbraco-cms/backoffice/ufm';
import type { UmbBlockEditorCustomViewConfiguration } from '@umbraco-cms/backoffice/block-custom-view';
@@ -27,7 +27,7 @@ export class UmbRefSingleBlockElement extends UmbLitElement {
config?: UmbBlockEditorCustomViewConfiguration;
override render() {
const blockValue = { ...this.content, $settings: this.settings };
const blockValue: UmbBlockLabelUfmValueType = { ...this.content, $settings: this.settings };
return html`
<uui-ref-node
standalone
@@ -459,6 +459,10 @@ export class UmbPropertyEditorUIBlockSingleElement
UmbTextStyles,
css`
:host {
display: block;
}
uui-button-group {
margin-top: 1px;
display: grid;
@@ -35,7 +35,13 @@ export interface UmbBlockValueDataPropertiesBaseType {
expose: Array<UmbBlockExposeModel>;
}
export interface UmbBlockValueType<BlockLayoutType extends UmbBlockLayoutBaseModel = UmbBlockLayoutBaseModel>
extends UmbBlockValueDataPropertiesBaseType {
export interface UmbBlockValueType<
BlockLayoutType extends UmbBlockLayoutBaseModel = UmbBlockLayoutBaseModel,
> extends UmbBlockValueDataPropertiesBaseType {
layout: { [key: string]: Array<BlockLayoutType> | undefined };
}
export type UmbBlockLabelUfmValueType = Record<string, unknown> & {
$settings?: Record<string, unknown>;
$index?: number;
};
@@ -17,6 +17,7 @@ import { UmbValidationController } from '@umbraco-cms/backoffice/validation';
import {
UmbContentValidationToHintsManager,
UmbElementWorkspaceDataManager,
umbExtractVariantValues,
type UmbElementPropertyDataOwner,
} from '@umbraco-cms/backoffice/content';
import { UmbReadOnlyVariantGuardManager } from '@umbraco-cms/backoffice/utils';
@@ -61,6 +62,25 @@ export class UmbBlockElementManager<LayoutDataType extends UmbBlockLayoutBaseMod
new UmbDocumentTypeDetailRepository(this),
);
// The values resolved to a single entry per property, matching the current variant. [NL]
readonly variantValues = mergeObservables(
[this.structure.contentTypeProperties, this.values, this.variantId],
([properties, values, variantId]) => {
if (!variantId) {
return [];
}
const propertyVariantIds = properties.map((property) => ({
alias: property.alias,
variantId: this.#createPropertyVariantId(property, variantId),
}));
return umbExtractVariantValues(propertyVariantIds, values);
},
);
#variantValuesSnapshot: Array<UmbBlockDataValueModel> = [];
getVariantValues() {
return this.#variantValuesSnapshot;
}
public readonly propertyViewGuard = new UmbVariantPropertyGuardManager(this);
public readonly propertyWriteGuard = new UmbVariantPropertyGuardManager(this);
@@ -123,6 +143,14 @@ export class UmbBlockElementManager<LayoutDataType extends UmbBlockLayoutBaseMod
},
null,
);
this.observe(
this.variantValues,
(resolvedValues) => {
this.#variantValuesSnapshot = resolvedValues;
},
null,
);
}
public isLoaded() {
@@ -0,0 +1,34 @@
import { expect } from '@open-wc/testing';
import { buildBlockLabelValueObject } from './block-workspace-label-value.function.js';
import type { UmbBlockDataValueModel } from '../types.js';
function value(alias: string, val: unknown): UmbBlockDataValueModel {
return { alias, value: val, culture: null, segment: null, editorAlias: 'test' };
}
describe('buildBlockLabelValueObject', () => {
it('places content values at the top level keyed by alias', () => {
const result = buildBlockLabelValueObject([value('heading', 'Hello')], undefined);
expect(result.heading).to.equal('Hello');
});
it('places settings values under $settings as a key-value object keyed by alias', () => {
const result = buildBlockLabelValueObject(undefined, [value('notes', 'A note')]);
// $settings must be an object (not the raw value array) so labels can use `${$settings.notes}`. [NL]
expect(result.$settings).to.eql({ notes: 'A note' });
});
it('includes $index when an index is provided', () => {
const result = buildBlockLabelValueObject(undefined, undefined, 3);
expect(result.$index).to.equal(3);
});
it('omits $index when no index is provided', () => {
const result = buildBlockLabelValueObject(undefined, undefined);
expect('$index' in result).to.be.false;
});
it('returns an empty object when no values are provided', () => {
expect(buildBlockLabelValueObject(undefined, undefined)).to.eql({});
});
});
@@ -0,0 +1,38 @@
import type { UmbBlockDataValueModel, UmbBlockLabelUfmValueType } from '../types.js';
/**
* Builds the value object consumed by the block label UFM render. Content values are placed at the top
* level and settings values under a `$settings` key both keyed by property alias so labels can
* reference them as `${alias}` and `${$settings.alias}` respectively.
* @param {Array<UmbBlockDataValueModel> | undefined} contentValues - The resolved block content values.
* @param {Array<UmbBlockDataValueModel> | undefined} settingsValues - The resolved block settings values.
* @param {number | undefined} index - The block index, exposed as `$index` when defined.
* @returns {UmbBlockLabelUfmValueType} The value object for the label render.
*/
export function buildBlockLabelValueObject(
contentValues: Array<UmbBlockDataValueModel> | undefined,
settingsValues: Array<UmbBlockDataValueModel> | undefined,
index?: number,
): UmbBlockLabelUfmValueType {
const valueObject: UmbBlockLabelUfmValueType = {};
if (contentValues) {
for (const property of contentValues) {
valueObject[property.alias] = property.value;
}
}
if (settingsValues) {
const settingsObject: Record<string, unknown> = {};
for (const property of settingsValues) {
settingsObject[property.alias] = property.value;
}
valueObject['$settings'] = settingsObject;
}
if (index !== undefined) {
valueObject['$index'] = index;
}
return valueObject;
}
@@ -6,6 +6,7 @@ import type { UmbBlockWorkspaceOriginData } from './block-workspace.modal-token.
import { UMB_BLOCK_WORKSPACE_VIEW_CONTENT, UMB_BLOCK_WORKSPACE_VIEW_SETTINGS } from './constants.js';
import { UmbBlockLanguageAccessWorkspaceController } from './block-workspace-language-access.controller.js';
import { resolveBlockWorkspaceLabelIndex } from './block-workspace-label-index.function.js';
import { buildBlockLabelValueObject } from './block-workspace-label-value.function.js';
import {
UmbSubmittableWorkspaceContextBase,
type UmbRoutableWorkspaceContext,
@@ -113,6 +114,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
this.#blockEntries = context;
if (context) {
this.observe(
// TODO: Turn this into a observablePart that is retrievable from the block entries context, so we do not have to observe multiple values here. [NL]
observeMultiple([context.layoutEntries, this.contentKey]).pipe(
map(([layouts, contentKey]) => {
const found = contentKey ? layouts.findIndex((x) => x.contentKey === contentKey) : -1;
@@ -122,7 +124,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
),
(index) => {
this.#index = index;
this.#renderLabel(this.content.getValues(), this.settings.getValues());
this.#renderLabel(this.content.getVariantValues(), this.settings.getVariantValues());
},
'observeLayoutIndex',
);
@@ -166,7 +168,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
);
this.observe(
observeMultiple([this.content.values, this.settings.values]),
observeMultiple([this.content.variantValues, this.settings.variantValues]),
async ([contentValues, settingsValues]) => {
this.#renderLabel(contentValues, settingsValues);
},
@@ -277,7 +279,7 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
#gotLabel(label: string | undefined) {
if (label) {
this.#labelRender.markdown = label;
this.#renderLabel(this.content.getValues(), this.settings.getValues());
this.#renderLabel(this.content.getVariantValues(), this.settings.getVariantValues());
}
}
@@ -285,31 +287,18 @@ export class UmbBlockWorkspaceContext<LayoutDataType extends UmbBlockLayoutBaseM
contentValues: Array<UmbBlockDataValueModel> | undefined,
settingsValues: Array<UmbBlockDataValueModel> | undefined,
) {
const valueObject = {} as Record<string, unknown>;
if (contentValues) {
for (const property of contentValues) {
valueObject[property.alias] = property.value;
}
}
if (settingsValues) {
valueObject['$settings'] = settingsValues;
}
const index = resolveBlockWorkspaceLabelIndex(
this.#index,
this.#originData,
this.#blockEntries?.getLayouts().length,
);
if (index !== undefined) {
valueObject['$index'] = index;
}
this.#labelRender.value = valueObject;
this.#labelRender.value = buildBlockLabelValueObject(contentValues, settingsValues, index);
// Await one animation frame:
await new Promise((resolve) => requestAnimationFrame(() => resolve(true)));
// Check have we been destroyed while waiting for the animation frame? then back out:
if (!this.#blockManager) return;
const prefix = this.getIsNew() === true ? '#general_add' : '#general_edit';
const label = this.#labelRender.toString();
const title = `${prefix} ${label}`;
@@ -144,6 +144,21 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
this.modalContext?.setValue({ selection: this._selection });
}
get #typeKeySuffix(): string {
switch (this.data?.entityType) {
case 'media-type':
return 'MediaType';
case 'member-type':
return 'MemberType';
default:
return '';
}
}
get #editPathBase(): string {
return `/section/settings/workspace/${this.data?.entityType ?? 'document-type'}/edit/`;
}
override render() {
return html`
<umb-body-layout headline="${this.localize.term('contentTypeEditor_compositions')}">
@@ -168,13 +183,13 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
#renderHasReference() {
return html`
<umb-localize key="contentTypeEditor_compositionInUse">
<umb-localize key="contentTypeEditor_compositionInUse${this.#typeKeySuffix}">
This Content Type is used in a composition, and therefore cannot be composed itself.
</umb-localize>
<h4>
<umb-localize key="contentTypeEditor_compositionUsageHeading">Where is this composition used?</umb-localize>
</h4>
<umb-localize key="contentTypeEditor_compositionUsageSpecification">
<umb-localize key="contentTypeEditor_compositionUsageSpecification${this.#typeKeySuffix}">
This composition is currently used in the composition of the following Content Types:
</umb-localize>
<div class="reference-list">
@@ -183,7 +198,7 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
(item) => item.unique,
(item) => html`
<uui-ref-node-document-type
href=${'/section/settings/workspace/document-type/edit/' + item.unique}
href=${this.#editPathBase + item.unique}
name=${this.localize.string(item.name)}>
<umb-icon slot="icon" name=${item.icon}></umb-icon>
</uui-ref-node-document-type>
@@ -202,7 +217,7 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
if (this._compatibleCompositions) {
return html`
<umb-localize key="contentTypeEditor_compositionsDescription">
<umb-localize key="contentTypeEditor_compositionsDescription${this.#typeKeySuffix}">
Inherit tabs and properties from an existing Document Type. New tabs will be<br />added to the current
Document Type or merged if a tab with an identical name exists.<br />
</umb-localize>
@@ -224,7 +239,7 @@ export class UmbCompositionPickerModalElement extends UmbModalBaseElement<
`;
} else {
return html`
<umb-localize key="contentTypeEditor_noAvailableCompositions">
<umb-localize key="contentTypeEditor_noAvailableCompositions${this.#typeKeySuffix}">
There are no Content Types available to use as a composition
</umb-localize>
`;
@@ -10,6 +10,7 @@ export interface UmbCompositionPickerModalData {
isElement: boolean;
currentPropertyAliases: Array<string>;
isNew: boolean;
entityType?: string;
}
export interface UmbCompositionPickerModalValue {
@@ -450,6 +450,7 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements
isElement: ownerContentType.isElement,
currentPropertyAliases,
isNew: this.#workspaceContext.getIsNew()!,
entityType: this.#workspaceContext.getEntityType(),
};
const value = await umbOpenModal(this, UMB_COMPOSITION_PICKER_MODAL, {
@@ -1,5 +1,6 @@
import type { UmbElementDetailModel } from '../types.js';
import type { UmbElementPropertyDataOwner } from './element-property-data-owner.interface.js';
import { umbExtractVariantValues } from './merge-variant-values.function.js';
import type { UmbPropertyDatasetContext } from '@umbraco-cms/backoffice/property';
import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
@@ -19,13 +20,13 @@ import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
type UmbPropertyVariantIdMapType = Array<{ alias: string; variantId: UmbVariantId }>;
export abstract class UmbElementPropertyDatasetContext<
ContentModel extends UmbElementDetailModel = UmbElementDetailModel,
ContentTypeModel extends UmbContentTypeModel = UmbContentTypeModel,
DataOwnerType extends UmbElementPropertyDataOwner<ContentModel, ContentTypeModel> = UmbElementPropertyDataOwner<
ContentModel,
ContentTypeModel
>,
>
ContentModel extends UmbElementDetailModel = UmbElementDetailModel,
ContentTypeModel extends UmbContentTypeModel = UmbContentTypeModel,
DataOwnerType extends UmbElementPropertyDataOwner<ContentModel, ContentTypeModel> = UmbElementPropertyDataOwner<
ContentModel,
ContentTypeModel
>,
>
extends UmbContextBase
implements UmbPropertyDatasetContext
{
@@ -69,7 +70,8 @@ export abstract class UmbElementPropertyDatasetContext<
this.#variantContext.setVariantId(this.#variantId);
this.#propertyVariantIdPromise = new Promise((resolve) => {
this.#propertyVariantIdPromiseResolver = resolve as any;
this.#propertyVariantIdPromiseResolver = resolve as unknown as () => void;
// TODO: implement a rejector as well, and handle that in the places awaiting this promise. [NL]
});
this.observe(
@@ -118,16 +120,7 @@ export abstract class UmbElementPropertyDatasetContext<
}
#mergeVariantIdsAndValues([props, values]: [UmbPropertyVariantIdMapType, ContentModel['values'] | undefined]) {
const r: ContentModel['values'] = [];
if (values) {
for (const prop of props) {
const f = values.find((v) => prop.alias === v.alias && prop.variantId.compare(v));
if (f) {
r.push(f);
}
}
}
return r as ContentModel['values'];
return umbExtractVariantValues(props, values) as ContentModel['values'];
}
async getProperties(): Promise<ContentModel['values']> {
@@ -2,3 +2,4 @@ export * from './content-property-dataset.context-token.js';
export * from './content-property-dataset.context.js';
export type * from './element-property-data-owner.interface.js';
export * from './element-property-dataset.context.js';
export * from './merge-variant-values.function.js';
@@ -0,0 +1,60 @@
import { expect } from '@open-wc/testing';
import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import { umbExtractVariantValues } from './merge-variant-values.function.js';
type TestValue = { alias: string; culture: string | null; segment: string | null; value: unknown };
describe('umbExtractVariantValues', () => {
it('picks the value matching the property variant among multiple variants of the same alias', () => {
// 'en' comes first and 'da' last, so a naive last-wins flatten would wrongly resolve to 'Danish'. [NL]
const values: Array<TestValue> = [
{ alias: 'title', culture: 'en', segment: null, value: 'English' },
{ alias: 'title', culture: 'da', segment: null, value: 'Danish' },
];
const result = umbExtractVariantValues(
[{ alias: 'title', variantId: UmbVariantId.Create({ culture: 'en', segment: null }) }],
values,
);
expect(result.length).to.equal(1);
expect(result[0].value).to.equal('English');
});
it('returns one entry per property, in property order', () => {
const values: Array<TestValue> = [
{ alias: 'body', culture: null, segment: null, value: 'B' },
{ alias: 'title', culture: null, segment: null, value: 'T' },
];
const result = umbExtractVariantValues(
[
{ alias: 'title', variantId: UmbVariantId.CreateInvariant() },
{ alias: 'body', variantId: UmbVariantId.CreateInvariant() },
],
values,
);
expect(result.map((x) => x.value)).to.eql(['T', 'B']);
});
it('skips properties that have no matching value', () => {
const values: Array<TestValue> = [{ alias: 'title', culture: null, segment: null, value: 'T' }];
const result = umbExtractVariantValues(
[
{ alias: 'title', variantId: UmbVariantId.CreateInvariant() },
{ alias: 'missing', variantId: UmbVariantId.CreateInvariant() },
],
values,
);
expect(result.length).to.equal(1);
expect(result[0].alias).to.equal('title');
});
it('returns an empty array when values are undefined', () => {
const result = umbExtractVariantValues([{ alias: 'title', variantId: UmbVariantId.CreateInvariant() }], undefined);
expect(result).to.eql([]);
});
});
@@ -0,0 +1,25 @@
import type { UmbPropertyValueDataWithVariant } from '@umbraco-cms/backoffice/property';
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
/**
* Resolves a single value per property for a given variant.
* @param {Array<{ alias: string; variantId: UmbVariantId }>} propertyVariantIds - The property types paired with the
* variant id to extract values for.
* @param {Array<UmbPropertyValueDataWithVariant> | undefined} values - The full value set to pick from.
* @returns {Array<UmbPropertyValueDataWithVariant>} One value per property whose alias and variant matches, in property order.
*/
export function umbExtractVariantValues<ValueType extends UmbPropertyValueDataWithVariant>(
propertyVariantIds: Array<{ alias: string; variantId: UmbVariantId }>,
values: Array<ValueType> | undefined,
): Array<ValueType> {
const result: Array<ValueType> = [];
if (values) {
for (const property of propertyVariantIds) {
const found = values.find((value) => property.alias === value.alias && property.variantId.compare(value));
if (found) {
result.push(found);
}
}
}
return result;
}
@@ -1,5 +1,5 @@
import type { UmbEntityFlag } from '@umbraco-cms/backoffice/entity-flag';
import type { UmbPropertyValueData } from '@umbraco-cms/backoffice/property';
import type { UmbPropertyValueData, UmbPropertyValueDataWithVariant } from '@umbraco-cms/backoffice/property';
import type { UmbEntityVariantModel } from '@umbraco-cms/backoffice/variant';
export type * from './collection/types.js';
@@ -10,8 +10,7 @@ export interface UmbElementDetailModel {
values: Array<UmbElementValueModel>;
}
export interface UmbElementValueModel<ValueType = unknown> extends UmbPropertyValueData<ValueType> {
culture: string | null;
export interface UmbElementValueModel<ValueType = unknown> extends UmbPropertyValueDataWithVariant<ValueType> {
editorAlias: string;
segment: string | null;
}
@@ -24,8 +23,9 @@ export interface UmbPotentialContentValueModel<ValueType = unknown> extends UmbP
segment?: string | null;
}
export interface UmbContentDetailModel<VariantModelType extends UmbEntityVariantModel = UmbEntityVariantModel>
extends UmbElementDetailModel {
export interface UmbContentDetailModel<
VariantModelType extends UmbEntityVariantModel = UmbEntityVariantModel,
> extends UmbElementDetailModel {
unique: string;
entityType: string;
variants: Array<VariantModelType>;
@@ -33,5 +33,4 @@ export interface UmbContentDetailModel<VariantModelType extends UmbEntityVariant
}
export interface UmbContentLikeDetailModel
extends UmbElementDetailModel,
Partial<Pick<UmbContentDetailModel, 'variants' | 'flags'>> {}
extends UmbElementDetailModel, Partial<Pick<UmbContentDetailModel, 'variants' | 'flags'>> {}
@@ -9,7 +9,7 @@ export class UmbDateTableColumnViewElement extends UmbLitElement {
override render() {
if (!this.value) return nothing;
const date = new Date(this.value);
return html`${date.toLocaleString()}`;
return html`${date.toLocaleString(this.localize.lang())}`;
}
}
@@ -3,5 +3,6 @@ export { UmbEntityContext } from './entity.context.js';
export * from './constants.js';
export * from './contexts/ancestors/index.js';
export * from './contexts/parent/index.js';
export * from './input/index.js';
export type * from './types.js';
@@ -0,0 +1,55 @@
import { UmbEntityInputInteractionMemoryManager } from './entity-input-interaction-memory.manager.js';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { expect, oneEvent } from '@open-wc/testing';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import { UmbInteractionMemoriesChangeEvent, UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory';
@customElement('test-entity-input-interaction-memory-host')
class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
describe('UmbEntityInputInteractionMemoryManager', () => {
let hostElement: UmbTestControllerHostElement;
let interactionMemory: UmbInteractionMemoryManager;
let manager: UmbEntityInputInteractionMemoryManager;
beforeEach(() => {
hostElement = new UmbTestControllerHostElement();
interactionMemory = new UmbInteractionMemoryManager(hostElement);
manager = new UmbEntityInputInteractionMemoryManager(hostElement, interactionMemory);
});
describe('setMemories()', () => {
it('seeds the interaction memory from the incoming snapshot', () => {
manager.setMemories([{ unique: 'a' }, { unique: 'b' }]);
expect(manager.getMemories().map((memory) => memory.unique)).to.eql(['a', 'b']);
});
it('removes memories that are no longer present when the snapshot shrinks', () => {
manager.setMemories([{ unique: 'a' }, { unique: 'b' }]);
manager.setMemories([{ unique: 'a' }]);
expect(manager.getMemories().map((memory) => memory.unique)).to.eql(['a']);
});
it('clears all memories when the snapshot is emptied', () => {
manager.setMemories([{ unique: 'a' }, { unique: 'b' }]);
manager.setMemories([]);
expect(manager.getMemories()).to.eql([]);
});
it('treats undefined as an empty snapshot', () => {
manager.setMemories([{ unique: 'a' }]);
manager.setMemories(undefined);
expect(manager.getMemories()).to.eql([]);
});
});
describe('change event', () => {
it('dispatches a change event from the host when the interaction memory changes externally', async () => {
const listener = oneEvent(hostElement, UmbInteractionMemoriesChangeEvent.TYPE);
interactionMemory.setMemory({ unique: 'expansion', value: { expansion: ['a'] } });
const event = await listener;
expect(event).to.exist;
expect(manager.getMemories().map((memory) => memory.unique)).to.eql(['expansion']);
});
});
});
@@ -0,0 +1,74 @@
import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import {
UmbInteractionMemoriesChangeEvent,
type UmbInteractionMemoryManager,
type UmbInteractionMemoryModel,
} from '@umbraco-cms/backoffice/interaction-memory';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
/**
* Bridges a picker input's interaction-memory manager to its host element's `interactionMemories`
* property and `interaction-memories-change` event, keeping the two in sync.
* @exports
* @class UmbEntityInputInteractionMemoryManager
* @augments {UmbControllerBase}
*/
export class UmbEntityInputInteractionMemoryManager extends UmbControllerBase {
#interactionMemory: UmbInteractionMemoryManager;
#snapshot: Array<UmbInteractionMemoryModel> = [];
/**
* Creates an instance of UmbEntityInputInteractionMemoryManager.
* @param {UmbControllerHost} host - The host element; the change event is dispatched from it.
* @param {UmbInteractionMemoryManager} interactionMemory - The picker input context's interaction-memory manager to bridge.
* @memberof UmbEntityInputInteractionMemoryManager
*/
constructor(host: UmbControllerHost, interactionMemory: UmbInteractionMemoryManager) {
super(host);
this.#interactionMemory = interactionMemory;
this.observe(
this.#interactionMemory.memories,
(memories) => {
// only dispatch the event if the interaction memories have actually changed
if (jsonStringComparison(memories, this.#snapshot)) return;
this.#snapshot = memories;
this.getHostElement().dispatchEvent(new UmbInteractionMemoriesChangeEvent());
},
'_observeMemories',
);
}
/**
* Gets all interaction memories currently held by the picker input context.
* @returns {Array<UmbInteractionMemoryModel>} The current interaction memories.
* @memberof UmbEntityInputInteractionMemoryManager
*/
getMemories(): Array<UmbInteractionMemoryModel> {
return this.#interactionMemory.getAllMemories();
}
/**
* Syncs the picker input context to the provided snapshot. The incoming array is authoritative:
* memories no longer present are removed, the rest are added or updated. Short-circuits when the
* snapshot already matches to avoid redundant writes and the re-entrant change events they trigger.
* @param {Array<UmbInteractionMemoryModel> | undefined} value - The authoritative snapshot of interaction memories.
* @memberof UmbEntityInputInteractionMemoryManager
*/
setMemories(value: Array<UmbInteractionMemoryModel> | undefined): void {
const next = value ?? [];
const current = this.#interactionMemory.getAllMemories();
this.#snapshot = next;
if (jsonStringComparison(next, current)) return;
const nextUniques = new Set(next.map((memory) => memory.unique));
current
.filter((memory) => !nextUniques.has(memory.unique))
.forEach((memory) => this.#interactionMemory.deleteMemory(memory.unique));
next.forEach((memory) => this.#interactionMemory.setMemory(memory));
}
}
@@ -0,0 +1 @@
export { UmbEntityInputInteractionMemoryManager } from './entity-input-interaction-memory.manager.js';
@@ -3,7 +3,13 @@ export interface UmbPropertyValueData<ValueType = unknown> {
value?: ValueType;
}
export interface UmbPropertyValueDataPotentiallyWithEditorAlias<ValueType = unknown>
extends UmbPropertyValueData<ValueType> {
export interface UmbPropertyValueDataPotentiallyWithEditorAlias<
ValueType = unknown,
> extends UmbPropertyValueData<ValueType> {
editorAlias?: string;
}
export interface UmbPropertyValueDataWithVariant<ValueType = unknown> extends UmbPropertyValueData<ValueType> {
culture: string | null;
segment: string | null;
}
@@ -0,0 +1,83 @@
import { umbParseDeprecationOrigin, umbShouldLogDeprecation } from './deprecation-origin.js';
import { expect } from '@open-wc/testing';
// A production-style core URL for the deprecation util itself (so core is recognisable).
const SELF_URL = 'https://example.com/umbraco/backoffice/packages/core/utils/index.js';
describe('umbParseDeprecationOrigin', () => {
it('returns "unknown" when there is no stack', () => {
expect(umbParseDeprecationOrigin(undefined, SELF_URL).type).to.equal('unknown');
expect(umbParseDeprecationOrigin('', SELF_URL).type).to.equal('unknown');
});
it('classifies a caller under /App_Plugins/ as a package (Chrome format)', () => {
const stack = [
'Error',
` at UmbDeprecation.warn (${SELF_URL}:30:10)`,
' at new UmbImagingThumbnailElement (https://example.com/umbraco/backoffice/packages/media/index.js:12:5)',
' at MyDashboard.render (https://example.com/App_Plugins/Acme.Widgets/dashboard.js:40:9)',
].join('\n');
const origin = umbParseDeprecationOrigin(stack, SELF_URL);
expect(origin.type).to.equal('package');
expect(origin.label).to.contain('Acme.Widgets');
});
it('classifies a caller under /App_Plugins/ as a package (Firefox format)', () => {
const stack = [
`warn@${SELF_URL}:30:10`,
'render@https://example.com/App_Plugins/Cool.Package/index.js:1:1',
].join('\n');
const origin = umbParseDeprecationOrigin(stack, SELF_URL);
expect(origin.type).to.equal('package');
expect(origin.label).to.contain('Cool.Package');
});
it('classifies as "core" when every caller frame is under /umbraco/backoffice/', () => {
const stack = [
'Error',
` at UmbDeprecation.warn (${SELF_URL}:30:10)`,
' at new UmbImagingThumbnailElement (https://example.com/umbraco/backoffice/packages/media/index.js:12:5)',
' at UmbMediaCollection.render (https://example.com/umbraco/backoffice/packages/media/index.js:99:3)',
].join('\n');
expect(umbParseDeprecationOrigin(stack, SELF_URL).type).to.equal('core');
});
it('classifies a non-core, non-App_Plugins caller as "external"', () => {
const stack = [
'Error',
` at UmbDeprecation.warn (${SELF_URL}:30:10)`,
' at Widget.render (https://example.com/my-rcl-assets/widget.js:5:1)',
].join('\n');
const origin = umbParseDeprecationOrigin(stack, SELF_URL);
expect(origin.type).to.equal('external');
expect(origin.label).to.contain('my-rcl-assets/widget.js');
});
it('returns "unknown" for a development build where core is not recognisable', () => {
const devSelf = 'http://localhost:5173/src/packages/core/utils/deprecation/deprecation.ts';
const stack = [
'Error',
` at UmbDeprecation.warn (${devSelf}:30:10)`,
' at MyDashboard.render (http://localhost:5173/src/my-package/dashboard.ts:40:9)',
].join('\n');
expect(umbParseDeprecationOrigin(stack, devSelf).type).to.equal('unknown');
});
});
describe('umbShouldLogDeprecation', () => {
it('suppresses core-origin warnings only when suppressCore is true', () => {
expect(umbShouldLogDeprecation({ type: 'core', label: '' }, true)).to.equal(false);
expect(umbShouldLogDeprecation({ type: 'core', label: '' }, false)).to.equal(true);
});
it('always logs package, external and unknown origins', () => {
for (const type of ['package', 'external', 'unknown'] as const) {
expect(umbShouldLogDeprecation({ type, label: '' }, true), type).to.equal(true);
}
});
});
@@ -0,0 +1,66 @@
/**
* Where a deprecated API was most likely called from, derived from the call stack.
* Best-effort and only used to annotate console warnings never load-bearing.
*/
export interface UmbDeprecationOrigin {
/**
* - `core`: Umbraco backoffice itself
* - `package`: an `/App_Plugins/` package
* - `external`: other non-core code (e.g. a Razor Class Library)
* - `unknown`: could not be determined (e.g. a development build or no stack)
*/
type: 'core' | 'package' | 'external' | 'unknown';
/** Human-readable label for the console message. */
label: string;
}
// In a production build, core is served from this path; any frame under it is core code.
const UMB_CORE_PATH_MARKER = '/umbraco/backoffice/';
/**
* Classifies the caller of a deprecated API from a stack trace.
*
* The frames read: the deprecation util the deprecated API (core) the caller. So the first
* frame that is *not* core code is the most likely caller; if every frame is core, the caller is core
* itself. Detection only works for production builds where core is served under `/umbraco/backoffice/`
* in development builds it returns `unknown`.
* @param {string | undefined} stack - A raw `Error.stack` string (the format differs per engine).
* @param {string} selfUrl - The deprecation util's own module URL (`import.meta.url`), used both to recognise core and to drop its own frames.
* @returns {UmbDeprecationOrigin} The classified origin.
*/
export function umbParseDeprecationOrigin(stack: string | undefined, selfUrl: string): UmbDeprecationOrigin {
if (!stack) return { type: 'unknown', label: 'unknown' };
// Frame URLs, tolerant of Chrome "at fn (URL:1:2)" and Firefox/Safari "fn@URL:1:2"; strip line:col and any query/fragment.
const urls = Array.from(stack.matchAll(/(?:https?|blob|file):\/\/[^\s)'"]+/g)).map((match) =>
match[0].replace(/:\d+:\d+\)?$/, '').replace(/[?#].*$/, ''),
);
const selfBase = selfUrl.replace(/[?#].*$/, '');
const frames = urls.filter((url) => url !== selfBase);
const coreKnown = selfUrl.includes(UMB_CORE_PATH_MARKER) || frames.some((url) => url.includes(UMB_CORE_PATH_MARKER));
if (!coreKnown) return { type: 'unknown', label: 'unknown' };
const caller = frames.find((url) => !url.includes(UMB_CORE_PATH_MARKER));
if (!caller) return { type: 'core', label: 'Umbraco backoffice (core)' };
const appPlugin = caller.match(/\/App_Plugins\/([^/?#]+)/i);
if (appPlugin) return { type: 'package', label: `package "${appPlugin[1]}" (/App_Plugins/${appPlugin[1]})` };
return { type: 'external', label: `custom code (${caller})` };
}
/**
* Decides whether a deprecation should be logged.
*
* Core-origin deprecations are noise in production a consumer cannot act on Umbraco's own code so
* they are suppressed there. Package, external and unknown origins are always logged so the responsible
* developer sees them.
* @param {UmbDeprecationOrigin} origin - The classified origin.
* @param {boolean} suppressCore - Whether core-origin warnings should be suppressed (production).
* @returns {boolean} `true` if the warning should be logged.
*/
export function umbShouldLogDeprecation(origin: UmbDeprecationOrigin, suppressCore: boolean): boolean {
return !(suppressCore && origin.type === 'core');
}
@@ -1,3 +1,5 @@
import { umbIsProductionBuild } from '../is-production-build.function.js';
import { umbParseDeprecationOrigin, umbShouldLogDeprecation } from './deprecation-origin.js';
import type { UmbDeprecationArgs } from './types.js';
/**
@@ -18,12 +20,26 @@ export class UmbDeprecation {
}
/**
* Logs a warning message to the console.
* Logs a deprecation warning to the console, annotated with the likely caller origin (Umbraco core,
* an `/App_Plugins` package, or other custom code). Core-origin warnings are suppressed in production
* builds, where a consumer cannot act on them.
* @memberof UmbDeprecation
* @param {object} [options] - Options for the warning message.
* @param {boolean} [options.logAlways] - If true, the warning is always logged regardless of origin (defaults to false).
* @returns {void}
* @example
* const deprecation = new UmbDeprecation({
* deprecated: 'The "foo" function is deprecated.',
* removeInVersion: '2.0.0',
* solution: 'Use the "bar" function instead.'
* });
* deprecation.warn();
*/
warn() {
console.warn(
`${this.#messagePrefix} ${this.#deprecated} The feature will be removed in version ${this.#removeInVersion}. ${this.#solution}`,
);
warn(options: { logAlways?: boolean } = {}): void {
const origin = umbParseDeprecationOrigin(new Error().stack, import.meta.url);
if (!options.logAlways && !umbShouldLogDeprecation(origin, umbIsProductionBuild())) return;
const message = `${this.#messagePrefix} ${this.#deprecated} The feature will be removed in version ${this.#removeInVersion}. ${this.#solution}`;
console.warn(origin.type === 'unknown' ? message : `${message}\nOrigin: ${origin.label}`);
}
}
@@ -11,6 +11,7 @@ export * from './expansion/index.js';
export * from './get-guid-from-udi.function.js';
export * from './get-processed-image-url.function.js';
export * from './guard-manager/index.js';
export * from './is-production-build.function.js';
export * from './is-test-environment.function.js';
export * from './math/math.js';
export * from './media/image-size.function.js';
@@ -0,0 +1,11 @@
/**
* Returns true if the current build is a production build, false otherwise.
*
* Vite substitutes `import.meta.env.PROD` with `true` in the shipped, Vite-bundled core package (and
* leaves it `false` in Vite dev). Outside a Vite build the initial `tsc` pass and web-test-runner
* `import.meta.env` is undefined, so the guard returns false there.
* @returns {boolean} `true` if the current build is a production build.
*/
export function umbIsProductionBuild(): boolean {
return typeof import.meta.env !== 'undefined' && import.meta.env.PROD === true;
}
@@ -49,7 +49,7 @@ export class UmbDocumentTableColumnPropertyValueElement extends UmbLitElement im
case 'contentTypeAlias':
return { value: item.documentType.alias };
case 'createDate':
return { value: this._createDate?.toLocaleString() };
return { value: this._createDate?.toLocaleString(this.localize.lang()) };
case 'creator':
case 'owner':
return { value: item.creator };
@@ -58,7 +58,7 @@ export class UmbDocumentTableColumnPropertyValueElement extends UmbLitElement im
case 'sortOrder':
return { value: item.sortOrder };
case 'updateDate':
return { value: this._updateDate?.toLocaleString() };
return { value: this._updateDate?.toLocaleString(this.localize.lang()) };
case 'updater':
return { value: item.updater };
default: {
@@ -1,11 +1,10 @@
import type { UmbDocumentItemModel } from '../../item/types.js';
import { UmbDocumentPickerInputContext } from './input-document.context.js';
import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit';
import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api';
import { splitStringToArray } from '@umbraco-cms/backoffice/utils';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory';
import { UmbEntityInputInteractionMemoryManager } from '@umbraco-cms/backoffice/entity';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbSorterController } from '@umbraco-cms/backoffice/sorter';
import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/document-type';
@@ -132,15 +131,12 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string, typeof
@property({ type: Array, attribute: false })
public get interactionMemories(): Array<UmbInteractionMemoryModel> | undefined {
return this.#pickerInputContext.interactionMemory.getAllMemories();
return this.#interactionMemoryManager.getMemories();
}
public set interactionMemories(value: Array<UmbInteractionMemoryModel> | undefined) {
this.#interactionMemories = value;
value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory));
this.#interactionMemoryManager.setMemories(value);
}
#interactionMemories?: Array<UmbInteractionMemoryModel> = [];
@state()
private _items?: Array<UmbDocumentItemModel>;
@@ -148,6 +144,10 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string, typeof
private _statuses?: Array<UmbRepositoryItemsStatus>;
#pickerInputContext = new UmbDocumentPickerInputContext(this);
#interactionMemoryManager = new UmbEntityInputInteractionMemoryManager(
this,
this.#pickerInputContext.interactionMemory,
);
constructor() {
super();
@@ -183,20 +183,6 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string, typeof
);
this.observe(this.#pickerInputContext.statuses, (statuses) => (this._statuses = statuses), '_observerStatuses');
this.observe(
this.#pickerInputContext.interactionMemory.memories,
(memories) => {
// only dispatch the event if the interaction memories have actually changed
const isIdentical = jsonStringComparison(memories, this.#interactionMemories);
if (!isIdentical) {
this.#interactionMemories = memories;
this.dispatchEvent(new UmbInteractionMemoriesChangeEvent());
}
},
'_observeMemories',
);
}
#openPicker() {
@@ -12,6 +12,25 @@ describe('UmbInputDocumentElement', () => {
expect(element).to.be.instanceOf(UmbInputDocumentElement);
});
describe('interactionMemories', () => {
it('seeds the picker context from the incoming snapshot', () => {
element.interactionMemories = [{ unique: 'a' }, { unique: 'b' }];
expect(element.interactionMemories?.map((memory) => memory.unique)).to.eql(['a', 'b']);
});
it('removes memories that are no longer present when the snapshot shrinks', () => {
element.interactionMemories = [{ unique: 'a' }, { unique: 'b' }];
element.interactionMemories = [{ unique: 'a' }];
expect(element.interactionMemories?.map((memory) => memory.unique)).to.eql(['a']);
});
it('clears all memories when the snapshot is emptied', () => {
element.interactionMemories = [{ unique: 'a' }, { unique: 'b' }];
element.interactionMemories = [];
expect(element.interactionMemories).to.eql([]);
});
});
if ((window as UmbTestRunnerWindow).__UMBRACO_TEST_RUN_A11Y_TEST) {
it('passes the a11y audit', async () => {
await expect(element).shadowDom.to.be.accessible(defaultA11yConfig);
@@ -4,6 +4,7 @@ export * from './entity-bulk-actions/constants.js';
export * from './folder/constants.js';
export * from './item/constants.js';
export * from './modals/constants.js';
export * from './property-editor/constants.js';
export * from './recycle-bin/constants.js';
export * from './reference/constants.js';
export * from './repository/constants.js';
@@ -1 +1,2 @@
export { UmbElementItemRepository } from './repository/index.js';
export * from './data-resolver/element-item-data-resolver.js';
@@ -0,0 +1 @@
export * from './element-picker/constants.js';
@@ -0,0 +1 @@
export * from './value-type/constants.js';
@@ -1,4 +1,5 @@
import { manifest as schemaManifest } from './Umbraco.ElementPicker.js';
import { manifests as valueSummaryManifests } from './value-summary/manifests.js';
import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/property-editor';
const propertyEditorUi: ManifestPropertyEditorUi = {
@@ -46,4 +47,4 @@ const propertyEditorUi: ManifestPropertyEditorUi = {
},
};
export const manifests: Array<UmbExtensionManifest> = [propertyEditorUi, schemaManifest];
export const manifests: Array<UmbExtensionManifest> = [propertyEditorUi, schemaManifest, ...valueSummaryManifests];
@@ -0,0 +1,13 @@
import { UMB_ELEMENT_PICKER_PROPERTY_EDITOR_VALUE_TYPE } from '../value-type/constants.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'valueSummary',
kind: 'default',
alias: 'Umb.ValueSummary.PropertyEditor.ElementPicker',
name: 'Element Picker Property Editor Value Summary',
forValueType: UMB_ELEMENT_PICKER_PROPERTY_EDITOR_VALUE_TYPE,
element: () => import('./value-summary.js'),
valueResolver: () => import('./value-summary.js'),
},
];
@@ -0,0 +1,85 @@
import { customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbValueSummaryElementBase } from '@umbraco-cms/backoffice/value-summary';
import { UmbElementItemDataResolver } from '../../../item/data-resolver/element-item-data-resolver.js';
import type { UmbElementItemModel } from '../../../types.js';
import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit';
/** Renders picked element names (variant-aware, comma-joined) for collection view cells. */
@customElement('umb-element-picker-property-editor-value-summary')
export class UmbElementPickerPropertyEditorValueSummaryElement extends UmbValueSummaryElementBase<
Array<UmbElementItemModel>
> {
@state()
private _names: Array<string> = [];
readonly #resolvers = new Map<string, UmbElementItemDataResolver<UmbElementItemModel>>();
readonly #resolvedNames = new Map<string, string>();
protected override willUpdate(changedProperties: PropertyValueMap<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has('_value' as keyof this)) {
this.#syncResolvers();
}
}
#syncResolvers() {
const value = this._value ?? [];
for (const unique of this.#resolvers.keys()) {
if (!value.some((item) => item.unique === unique)) {
this.#removeResolver(unique);
}
}
for (const item of value) {
this.#addOrUpdateResolver(item);
}
this.#buildNames();
}
#removeResolver(unique: string) {
this.#resolvers.get(unique)?.destroy();
this.#resolvers.delete(unique);
this.#resolvedNames.delete(unique);
this.removeUmbControllerByAlias(`element-${unique}`);
}
#addOrUpdateResolver(item: UmbElementItemModel) {
if (this.#resolvers.has(item.unique)) {
this.#resolvers.get(item.unique)!.setData(item);
return;
}
const resolver = new UmbElementItemDataResolver<UmbElementItemModel>(this);
resolver.setData(item);
this.#resolvers.set(item.unique, resolver);
this.observe(
resolver.name,
(name) => {
this.#resolvedNames.set(item.unique, name ?? '');
this.#buildNames();
},
`element-${item.unique}`,
);
}
#buildNames() {
this._names = (this._value ?? []).map((item) => this.#resolvedNames.get(item.unique) ?? '');
}
override render() {
if (!this._value?.length) return nothing;
const text = this._names.filter(Boolean).join(', ');
if (!text) return nothing;
return html`<span title="${text}">${text}</span>`;
}
}
export { UmbElementPickerPropertyEditorValueSummaryElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-element-picker-property-editor-value-summary': UmbElementPickerPropertyEditorValueSummaryElement;
}
}
@@ -0,0 +1,129 @@
import { expect } from '@open-wc/testing';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
import { useMockSet } from '@umbraco-cms/internal/mock-manager';
import { UmbElementItemStore } from '../../../item/repository/element-item.store.js';
import { UmbElementPickerValueSummaryResolver } from './value-summary.resolver.js';
// IDs from mocks/data/sets/default/element.data.ts
const SIMPLE_ELEMENT_ID = 'simple-element-id';
const ELEMENT_IN_FOLDER_ID = 'element-in-folder-id';
const ELEMENT_IN_SUBFOLDER_ID = 'element-in-subfolder-1-id';
@customElement('umb-test-element-picker-value-summary-host')
class UmbTestElementPickerValueSummaryHostElement extends UmbControllerHostElementMixin(HTMLElement) {
constructor() {
super();
new UmbElementItemStore(this);
}
}
describe('UmbElementPickerValueSummaryResolver', () => {
let host: UmbTestElementPickerValueSummaryHostElement;
let resolver: UmbElementPickerValueSummaryResolver;
before(async () => {
await useMockSet('default');
});
beforeEach(() => {
host = new UmbTestElementPickerValueSummaryHostElement();
document.body.appendChild(host);
resolver = new UmbElementPickerValueSummaryResolver(host);
});
afterEach(() => {
resolver.destroy();
document.body.innerHTML = '';
});
it('returns empty arrays when called with no values', async () => {
const result = await resolver.resolveValues([]);
expect(result.data).to.deep.equal([]);
});
it('returns an empty array per entry when all values are undefined', async () => {
const result = await resolver.resolveValues([undefined, undefined]);
expect(result.data).to.deep.equal([[], []]);
});
it('returns an empty array per entry when all values are empty arrays', async () => {
const result = await resolver.resolveValues([[], []]);
expect(result.data).to.deep.equal([[], []]);
});
it('resolves a single element ID to its item', async () => {
const result = await resolver.resolveValues([[SIMPLE_ELEMENT_ID]]);
expect(result.data).to.have.length(1);
expect(result.data[0]).to.have.length(1);
expect(result.data[0][0].unique).to.equal(SIMPLE_ELEMENT_ID);
expect(result.data[0][0].variants[0].name).to.equal('Simple Element');
});
it('resolves multiple separate values to their respective items', async () => {
const result = await resolver.resolveValues([[SIMPLE_ELEMENT_ID], [ELEMENT_IN_FOLDER_ID]]);
expect(result.data).to.have.length(2);
expect(result.data[0]).to.have.length(1);
expect(result.data[0][0].unique).to.equal(SIMPLE_ELEMENT_ID);
expect(result.data[0][0].variants[0].name).to.equal('Simple Element');
expect(result.data[1]).to.have.length(1);
expect(result.data[1][0].unique).to.equal(ELEMENT_IN_FOLDER_ID);
expect(result.data[1][0].variants[0].name).to.equal('Element In Folder');
});
it('resolves a multi-pick value (array of IDs) to multiple items', async () => {
const result = await resolver.resolveValues([[SIMPLE_ELEMENT_ID, ELEMENT_IN_FOLDER_ID]]);
expect(result.data).to.have.length(1);
expect(result.data[0]).to.have.length(2);
const uniques = result.data[0].map((item) => item.unique);
expect(uniques).to.include(SIMPLE_ELEMENT_ID);
expect(uniques).to.include(ELEMENT_IN_FOLDER_ID);
});
it('returns an empty array for an unknown element ID', async () => {
const result = await resolver.resolveValues([['00000000-0000-0000-0000-000000000000']]);
expect(result.data).to.have.length(1);
expect(result.data[0]).to.deep.equal([]);
});
it('returns an empty array for the unknown ID while still resolving the known ID', async () => {
const result = await resolver.resolveValues([['00000000-0000-0000-0000-000000000000'], [SIMPLE_ELEMENT_ID]]);
expect(result.data).to.have.length(2);
expect(result.data[0]).to.deep.equal([]);
expect(result.data[1][0].unique).to.equal(SIMPLE_ELEMENT_ID);
});
it('deduplicates IDs used across multiple values when fetching', async () => {
const result = await resolver.resolveValues([[SIMPLE_ELEMENT_ID], [SIMPLE_ELEMENT_ID]]);
expect(result.data).to.have.length(2);
expect(result.data[0][0].unique).to.equal(SIMPLE_ELEMENT_ID);
expect(result.data[1][0].unique).to.equal(SIMPLE_ELEMENT_ID);
});
it('includes an asObservable function in the result when items are found', async () => {
const result = await resolver.resolveValues([[SIMPLE_ELEMENT_ID]]);
expect(result.asObservable).to.be.a('function');
});
it('emits resolved items via the observable', async () => {
const result = await resolver.resolveValues([[ELEMENT_IN_SUBFOLDER_ID]]);
const observed = await new Promise<typeof result.data>((resolve) => {
result.asObservable!().subscribe((value) => {
if (value.length > 0) resolve(value);
});
});
expect(observed[0][0].unique).to.equal(ELEMENT_IN_SUBFOLDER_ID);
expect(observed[0][0].variants[0].name).to.equal('Element In Subfolder 1');
});
});
@@ -0,0 +1,41 @@
import type { UmbValueSummaryResolveResult, UmbValueSummaryResolver } from '@umbraco-cms/backoffice/value-summary';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { createObservablePart } from '@umbraco-cms/backoffice/observable-api';
import { UmbElementItemRepository } from '../../../item/repository/index.js';
import type { UmbElementItemModel } from '../../../types.js';
/** Batch-resolves Element Picker value (array of element uniques) to their item models. */
export class UmbElementPickerValueSummaryResolver
extends UmbControllerBase
implements UmbValueSummaryResolver<Array<string> | undefined, Array<UmbElementItemModel>>
{
readonly #repo = new UmbElementItemRepository(this);
async resolveValues(
values: ReadonlyArray<Array<string> | undefined>,
): Promise<UmbValueSummaryResolveResult<Array<UmbElementItemModel>>> {
const allKeys = [...new Set(values.flatMap((v) => v ?? []))];
if (!allKeys.length) return { data: values.map(() => []) };
const { data, asObservable } = await this.#repo.requestItems(allKeys);
const items = Array.isArray(data) ? data : [];
return {
data: this.#map(values, items),
asObservable: asObservable
? () =>
createObservablePart(asObservable()!, (latest) => this.#map(values, latest))
: undefined,
};
}
#map(
values: ReadonlyArray<Array<string> | undefined>,
items: ReadonlyArray<UmbElementItemModel>,
): ReadonlyArray<Array<UmbElementItemModel>> {
const itemByKey = new Map(items.map((item) => [item.unique, item]));
return values.map((v) =>
(v ?? []).map((key) => itemByKey.get(key)).filter((item): item is UmbElementItemModel => !!item),
);
}
}
@@ -0,0 +1,2 @@
export { UmbElementPickerPropertyEditorValueSummaryElement as element } from './value-summary.element.js';
export { UmbElementPickerValueSummaryResolver as valueResolver } from './value-summary.resolver.js';
@@ -0,0 +1,7 @@
export const UMB_ELEMENT_PICKER_PROPERTY_EDITOR_VALUE_TYPE = 'Umbraco.ElementPicker' as const;
declare global {
interface UmbValueTypeMap {
[UMB_ELEMENT_PICKER_PROPERTY_EDITOR_VALUE_TYPE]: Array<string>;
}
}
@@ -128,7 +128,7 @@ export class UmbLogViewerMessageElement extends UmbLitElement {
return html`
<details @open=${this.#setOpen}>
<summary>
<div id="timestamp">${this.date?.toLocaleString()}</div>
<div id="timestamp">${this.date?.toLocaleString(this.localize.lang())}</div>
<div id="level">
<umb-log-viewer-level-tag .level=${this.level ? this.level : 'Information'}></umb-log-viewer-level-tag>
</div>
@@ -139,7 +139,7 @@ export class UmbLogViewerMessageElement extends UmbLitElement {
<ul id="properties-list">
<li class="property">
<div class="property-name"><umb-localize key="logViewer_timestamp">Timestamp</umb-localize></div>
<div class="property-value">${this.date?.toLocaleString()}</div>
<div class="property-value">${this.date?.toLocaleString(this.localize.lang())}</div>
</li>
<li class="property">
<div class="property-name">@MessageTemplate</div>
@@ -192,12 +192,12 @@ export class UmbManagementApiDetailDataRequestManager<
if (newIds.length > 0) {
try {
const getItemsController = new UmbItemDataApiGetRequestController(this, {
api: (args) => this.#readMany!(args.uniques),
api: async (args) => ({ data: (await this.#readMany!(args.uniques)).data.items }),
uniques: newIds,
});
const { data: serverData, error: serverError } = await getItemsController.request();
const serverItems = serverData?.items ?? [];
const serverItems = serverData ?? [];
error = serverError;
if (this.#isConnectedToServerEvents) {
@@ -0,0 +1,19 @@
import { UmbImagingThumbnailElement } from './imaging-thumbnail.element.js';
import { UmbMediaThumbnailElement } from './media-thumbnail.element.js';
import { expect, fixture, html } from '@open-wc/testing';
// Behaviour (the "img" part, checkerboard default, --umb-media-thumbnail-background) is covered by
// media-thumbnail.element.test.ts and inherited from UmbMediaThumbnailElement. This suite only guards
// that the deprecated `umb-imaging-thumbnail` alias stays registered and on the inheritance chain.
describe('UmbImagingThumbnailElement (deprecated alias)', () => {
let element: UmbImagingThumbnailElement;
beforeEach(async () => {
element = await fixture<UmbImagingThumbnailElement>(html`<umb-imaging-thumbnail></umb-imaging-thumbnail>`);
});
it('is still registered and extends UmbMediaThumbnailElement', () => {
expect(element).to.be.instanceOf(UmbImagingThumbnailElement);
expect(element).to.be.instanceOf(UmbMediaThumbnailElement);
});
});

Some files were not shown because too many files have changed in this diff Show More