Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2766d52
Tiptap RTE Blocks: support Element Library (reusable content) references
leekelleher Jun 24, 2026
9b4c617
Tiptap RTE Blocks: wire up is-reference attribute for Element Library…
leekelleher Jun 24, 2026
ab054cb
Tiptap RTE Blocks: fix unpublished state for Element Library references
leekelleher Jun 24, 2026
1e69009
Tiptap RTE Blocks: address PR review feedback
leekelleher Jun 24, 2026
007d6cd
Tiptap RTE Blocks: restore margin-top on RTE block entries
leekelleher Jun 24, 2026
475386d
Merge branch 'v19/dev' into v19/feature/rcob-rte-blocks
leekelleher Jul 7, 2026
3f388fc
RTE: address SonarCloud code smells on PR #23195
leekelleher Jul 7, 2026
d4e59ff
Merge branch 'v19/dev' into v19/feature/rcob-rte-blocks
leekelleher Aug 7, 2026
37498c0
test(tiptap): add layout-key coverage for block doc-walk
leekelleher Aug 7, 2026
2125bdc
Merge branch 'v19/dev' into v19/feature/rcob-rte-blocks
leekelleher Aug 10, 2026
fa83fe3
refactor(block): derive isExposed on shared block entry context
leekelleher Aug 10, 2026
cb9669c
test(tiptap): update div-container block expectations for layout keys
leekelleher Aug 10, 2026
68b82bb
fix(core): allow BlockRegex to match RTE blocks carrying data-key
leekelleher Aug 10, 2026
6f6ba70
Merge branch 'v19/dev' into v19/feature/rcob-rte-blocks
leekelleher Sep 7, 2026
2d3bfb3
Changed `combineLatest` to `observeMultiple`
leekelleher Sep 7, 2026
435a81f
Merge remote-tracking branch 'origin/v19/dev' into v19/feature/rcob-r…
nielslyngsoe Sep 7, 2026
0298f12
Border radius on focused block
leekelleher Sep 7, 2026
f3b212f
Fix orphaned RTE block re-insertion when deleting mixed local/externa…
leekelleher Sep 7, 2026
0a1e0ec
Move RTE block-key markup scan into the base class, deprecate _filter…
leekelleher Sep 7, 2026
41de41e
Adds state to `_name`
leekelleher Sep 7, 2026
79eb0b4
Split this PR: move server-side, shared block-entry and RTE entry UI …
leekelleher Sep 7, 2026
830ad4e
test(rte): assert against manager state, add layout-before-content or…
leekelleher Sep 7, 2026
c5ce29f
perf(rte): hoist layout/content key regex out of markup scan loop
leekelleher Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,11 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext<
* Delete a block by requesting its removal through the pending deletion mechanism.
* This enables undo support by removing the HTML element first via Tiptap,
* which triggers _filterUnusedBlocks to store block data before removal.
* @param {string} contentKey - The content key of the block to delete.
* @param {string} layoutKey - The layout key of the block to delete.
*/
override async delete(contentKey: string) {
override async delete(layoutKey: string) {
await this._retrieveManager;
this._manager?.requestPendingDeletion(contentKey);
this._manager?.requestPendingDeletion(layoutKey);
}

async #insertFromRtePropertyValues(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,28 @@ export class UmbBlockRteManagerContext<
public readonly pendingDeletions = this.#pendingDeletions.asObservable();

/**
* Request a block to be deleted. This adds the contentKey to pending deletions,
* Request a block to be deleted. This adds the layout key to pending deletions,
* which will be processed by the Tiptap API to remove the HTML element first,
* enabling undo support.
* @param {string} contentKey - The content key of the block to delete.
* @param {string} layoutKey - The layout key of the block to delete.
*/
public requestPendingDeletion(contentKey: string) {
this.#pendingDeletions.appendOne(contentKey);
public requestPendingDeletion(layoutKey: string) {
this.#pendingDeletions.appendOne(layoutKey);
}

/**
* Clear a pending deletion after it has been processed.
* @param {string} contentKey - The content key to clear from pending deletions.
* @param {string} layoutKey - The layout key to clear from pending deletions.
*/
public clearPendingDeletion(contentKey: string) {
this.#pendingDeletions.removeOne(contentKey);
public clearPendingDeletion(layoutKey: string) {
this.#pendingDeletions.removeOne(layoutKey);
}

removeOneLayout(contentKey: string) {
this._layouts.removeOne(contentKey);
removeOneLayout(layoutKey: string) {
this._layouts.removeOne(layoutKey);
}
removeManyLayouts(contentKeys: Array<string>) {
this._layouts.remove(contentKeys);
removeManyLayouts(layoutKeys: Array<string>) {
this._layouts.remove(layoutKeys);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ export abstract class UmbBlockManagerContext<
public readonly contents = this.#contents.asObservable();

readonly #externalContentValues = new UmbArrayState(<Array<UmbBlockDataModel>>[], (x) => x.key);

/**
* Combined observable of local block content and resolved external (library element) content.
* Use this alongside `contents` when you also need to react to library elements becoming available.
* The two arrays are concatenated without de-duplication — a key present in both (which should not
* normally happen) appears twice, with the local entry taking precedence in lookups like `getContentOf`.
*/
public readonly allContents = mergeObservables(
[this.#contents.asObservable(), this.#externalContentValues.asObservable()],
([local, external]) => [...(local ?? []), ...(external ?? [])],
);
Comment thread
leekelleher marked this conversation as resolved.
readonly #externalContentVariants = new UmbArrayState(
<
Array<{ key: string; variants: Array<{ culture: string | null; segment: string | null; state: string | null }> }>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { UmbPropertyEditorUiRteElementBase } from './rte-base.element.js';
import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js';
import { expect, fixture, html } from '@open-wc/testing';
import { customElement } from '@umbraco-cms/backoffice/external/lit';
import { UMB_BLOCK_RTE_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/block-rte';
import type { UmbPropertyEditorRteValueType } from '../types.js';
import type { UmbBlockRteLayoutModel, UmbBlockRteManagerContext } from '@umbraco-cms/backoffice/block-rte';
import type { UmbBlockDataModel } from '@umbraco-cms/backoffice/block';

@customElement('umb-test-rte-base')
class UmbTestRteBaseElement extends UmbPropertyEditorUiRteElementBase {
public callFilterUnusedBlocksFromMarkup(markup: string) {
this._filterUnusedBlocksFromMarkup(markup);
}

// eslint-disable-next-line @typescript-eslint/no-deprecated
public callFilterUnusedBlocks(usedLayoutKeys: Array<string | null>) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
this._filterUnusedBlocks(usedLayoutKeys);
}
}

declare global {
interface HTMLElementTagNameMap {
'umb-test-rte-base': UmbTestRteBaseElement;
}
}

function makeValue(layouts: Array<UmbBlockRteLayoutModel>, contents: Array<UmbBlockDataModel>): UmbPropertyEditorRteValueType {
return {
markup: '',
blocks: {
layout: { [UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
contentData: contents,
settingsData: [],
expose: [],
},
};
}

const LAYOUT_A: UmbBlockRteLayoutModel = { key: 'layout-a', contentKey: 'content-a' };
const CONTENT_A: UmbBlockDataModel = { key: 'content-a', contentTypeKey: 'content-type', values: [] };
const LAYOUT_B: UmbBlockRteLayoutModel = { key: 'layout-b', contentKey: 'content-b' };
const CONTENT_B: UmbBlockDataModel = { key: 'content-b', contentTypeKey: 'content-type', values: [] };

describe('UmbPropertyEditorUiRteElementBase', () => {
let element: UmbTestRteBaseElement;
// Asserted against directly: _filterUnusedBlocksByLayoutKeys' contract is mutating manager state.
// element.value only re-syncs from that state via a property-context-dependent observer, which this
// fixture (no real UMB_PROPERTY_CONTEXT provider) never wires up.
let managerContext: UmbBlockRteManagerContext;

beforeEach(async () => {
element = await fixture(html`<umb-test-rte-base></umb-test-rte-base>`);
managerContext = (await element.getContext(UMB_BLOCK_RTE_MANAGER_CONTEXT))!;
});

describe('_filterUnusedBlocksFromMarkup', () => {
it('keeps only blocks referenced by data-key in the given markup', () => {
element.value = makeValue([LAYOUT_A, LAYOUT_B], [CONTENT_A, CONTENT_B]);

element.callFilterUnusedBlocksFromMarkup('<umb-rte-block data-key="layout-a" data-content-key="content-a"></umb-rte-block>');

expect(managerContext.getLayouts()).to.deep.equal([LAYOUT_A]);
expect(managerContext.getContents()).to.deep.equal([CONTENT_A]);
});

it('falls back to data-content-key for legacy layouts with no separate key', () => {
const legacyLayout: UmbBlockRteLayoutModel = { key: 'content-c', contentKey: 'content-c' };
const legacyContent: UmbBlockDataModel = { key: 'content-c', contentTypeKey: 'content-type', values: [] };
element.value = makeValue([legacyLayout], [legacyContent]);

element.callFilterUnusedBlocksFromMarkup('<umb-rte-block data-content-key="content-c"></umb-rte-block>');

expect(managerContext.getLayouts()).to.deep.equal([legacyLayout]);
});

it('removes a block entirely absent from the markup', () => {
element.value = makeValue([LAYOUT_A], [CONTENT_A]);

element.callFilterUnusedBlocksFromMarkup('<p>No blocks here.</p>');

expect(managerContext.getLayouts()).to.deep.equal([]);
expect(managerContext.getContents()).to.deep.equal([]);
});

// Removing content/settings emits synchronously; if a still-present layout's content stays
// resolvable while that happens, #updateBlocks (block.tiptap-api.ts) re-inserts the node before
// the layout removal catches up, orphaning it. Layouts must therefore be removed first.
it('removes the layout before removing its content', () => {
element.value = makeValue([LAYOUT_A], [CONTENT_A]);

const removalOrder: Array<'layouts' | 'contents'> = [];
managerContext.layouts.subscribe((layouts) => {
if (!layouts.some((x) => x.key === LAYOUT_A.key)) removalOrder.push('layouts');
});
managerContext.contents.subscribe((contents) => {
if (!contents.some((x) => x.key === CONTENT_A.key)) removalOrder.push('contents');
});

element.callFilterUnusedBlocksFromMarkup('<p>No blocks here.</p>');

expect(removalOrder).to.deep.equal(['layouts', 'contents']);
});
});

// The deprecated overload must keep working for existing RTE implementations built against it.
describe('_filterUnusedBlocks (deprecated)', () => {
it('keeps only blocks referenced by the given layout keys', () => {
element.value = makeValue([LAYOUT_A, LAYOUT_B], [CONTENT_A, CONTENT_B]);

element.callFilterUnusedBlocks(['layout-a']);

expect(managerContext.getLayouts()).to.deep.equal([LAYOUT_A]);
expect(managerContext.getContents()).to.deep.equal([CONTENT_A]);
});
});
});
Loading
Loading