Enhancing Block Previews in Umbraco 15
This is a follow-up to Matthew Wise’s post from 2022 in 24days, where he explored how to make the most out of the block list editor in Umbraco. He detailed how advanced techniques for rendering block previews could be implemented more efficiently for content editors. You can check out Matthew’s post here: Getting More Out of the Block List Editor.
Additionally, I want to acknowledge Dave Woestenborghs’s great post from 2021, where he discussed how to empower content editors by controlling, hiding, or scheduling a block’s “published” state. He also provided a solid approach for rendering blocks in the backoffice using partial views, which mirrors the front-end views, reducing a developer’s maintenance and startup costs. You can find his insightful work here: Advanced Blocklist Editor.
Key C# Updates for Umbraco 15
BlockPreviewApiController
[HttpPost]
[Route("PreviewMarkup")]
public async Task<IActionResult> PreviewMarkup(
[FromBody] BlockItemData? data,
[FromQuery] string pageId = "",
[FromQuery] bool isGrid = false,
[FromQuery] string culture = "")
{
string markup;
try
{
IPublishedContent? page = null;
if (!string.IsNullOrWhiteSpace(pageId))
{
page = GetPublishedContentForPage(pageId);
}
if (page == null)
{
return Ok("The page is not saved yet, so we can't create a preview. Save the page first.");
}
await SetupPublishedRequest(culture, page);
markup = await _backOfficePreviewService.GetMarkupForBlock(page, data, isGrid, ControllerContext);
}
catch (Exception ex)
{
markup = "Something went wrong rendering a preview.";
_logger.LogError(ex, "Error rendering preview for a block {ContentTypeAlias}", data.ContentTypeAlias);
}
return Ok(CleanUpMarkup(markup));
}
BackOfficePreviewService
public async Task<string> GetMarkupForBlock(
IPublishedElement publishedElement,
BlockItemData blockData,
bool isGrid,
ControllerContext controllerContext)
{
var element = _blockEditorConverter.ConvertToElement(publishedElement, blockData, PropertyCacheLevel.None, true);
if (element == null)
{
throw new InvalidOperationException($"Unable to find Element {blockData.ContentTypeAlias}");
}
var blockType = _typeFinder.FindClassesWithAttribute<PublishedModelAttribute>().FirstOrDefault(
x => x.GetCustomAttribute<PublishedModelAttribute>(false)?.ContentTypeAlias == element.ContentType.Alias);
if (blockType == null)
{
throw new InvalidOperationException($"Unable to find BlockType {element.ContentType.Alias}");
}
var blockInstance = Activator.CreateInstance(blockType, element, _publishedValueFallback);
ViewDataDictionary viewData;
var contentAlias = element.ContentType.Alias.ToFirstUpper();
var viewComponent = _viewComponentSelector.SelectComponent(contentAlias);
string partialPath = isGrid
? $"/Views/Partials/blockGrid/Components/{contentAlias}.cshtml"
: $"/Views/Partials/blocklist/Components/{contentAlias}.cshtml";
viewData = isGrid
? CreateViewDataForGrid(blockType, blockData, blockInstance)
: CreateViewDataForList(blockType, blockData, blockInstance);
if (viewComponent != null)
{
return await GetMarkupFromViewComponent(controllerContext, viewData, viewComponent);
}
return await GetMarkFromPartial(controllerContext, viewData, partialPath);
}
Lit Component for Block Previews
import { html, customElement, LitElement, property, css, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api';
import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block';
import type { UmbBlockEditorCustomViewConfiguration,UmbBlockEditorCustomViewProperties } from '@umbraco-cms/backoffice/block-custom-view';
import type { UmbMediaPickerPropertyValue } from '@umbraco-cms/backoffice/media';
import { UmbBlockGridTypeModel } from '@umbraco-cms/backoffice/block-grid';
import { UMB_BLOCK_GRID_MANAGER_CONTEXT } from "@umbraco-cms/backoffice/block-grid";
import { observeMultiple } from "@umbraco-cms/backoffice/observable-api";
import { UMB_PROPERTY_DATASET_CONTEXT } from "@umbraco-cms/backoffice/property";
interface BlockPropertyValue {
PropertyType: string | null;
EditorAlias: string | null;
Culture: string | null;
Segment: string | null;
Alias: string;
Value: string;
}
@customElement('example-block-custom-view')
export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implements UmbBlockEditorCustomViewProperties {
@property({ attribute: false })
content?: UmbBlockDataType;
@property({ attribute: false })
settingsData?: UmbBlockDataType;
@property({ attribute: false })
contentKey?: string;
@property({ attribute: false })
config?: UmbBlockEditorCustomViewConfiguration;
@state()
logos: UmbMediaPickerPropertyValue[] = [];
@state()
blockType?: UmbBlockGridTypeModel;
@state()
logoUrls: Map<string, string> = new Map();
@state()
markup: string = 'Loading preview';
@state()
loading: boolean = true;
private _previewTimeout: number | undefined;
private _blockContext = {
blockEditorAlias: "",
culture: "",
};
connectedCallback() {
super.connectedCallback();
}
async updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('content')) {
if (this._previewTimeout) {
clearTimeout(this._previewTimeout);
}
this._previewTimeout = window.setTimeout(() => {
this.loadPreview(this.content);
}, 500);
}
}
async updateBlockEditorAlias() {
this.consumeContext(UMB_BLOCK_GRID_MANAGER_CONTEXT, (context) => {
this.observe(
observeMultiple([context.propertyAlias]),
async ([propertyAlias]) => {
this._blockContext.blockEditorAlias = propertyAlias ?? '';
}
);
});
}
updateCulture() {
this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (instance) => {
this._blockContext.culture = instance.getVariantId().culture ?? "";
});
}
async loadPreview(blockData: any | undefined) {
this.loading = true;
this.markup = 'Loading preview';
// Update context info
await this.updateBlockEditorAlias();
this.updateCulture();
let pageId = '';
const guidRegex = /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/;
const match = this.config?.editContentPath?.match(guidRegex);
if (match) {
pageId = match[0];
} else {
console.log("No GUID found in editContentPath.");
}
const isGrid = this._blockContext.blockEditorAlias === 'grid';
const culture = this._blockContext.culture || 'en-US';
const previewApi = "https://localhost:44393/PreviewMarkup";
if (!previewApi) {
this.markup = 'Preview API not configured.';
this.loading = false;
this.requestUpdate();
return;
}
const url = `${previewApi}?pageId=${pageId}&isGrid=${isGrid}&culture=${culture}`;
try {
const mappedData = this.mapBlockData(blockData);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mappedData)
});
if (!response.ok) throw new Error('Network response was not ok');
this.markup = await response.text();
} catch (e) {
this.markup = 'Failed to get preview markup';
}
this.loading = false;
this.requestUpdate();
}
mapBlockData(inputData: Record<string, any>): any {
const mappedProperties: BlockPropertyValue[] = Object.entries(inputData).map(([key, value]) => {
const processedValue = Array.isArray(value) ? JSON.stringify(value) : String(value);
return {
PropertyType: null,
EditorAlias: null,
Culture: null,
Segment: null,
Alias: key,
Value: processedValue
};
});
return {
Key: this.contentKey,
ContentTypeKey: this.blockType?.contentElementTypeKey || '',
ContentTypeAlias: "",
Udi: null,
Values: mappedProperties,
RawPropertyValues: null
};
}
override render() {
return html`
<h5>My Custom Views</h5>
<a href="${this.config?.editContentPath}">
${this.loading ? html`
<div class="preview-loader">
<div class="spinner"></div> <!-- Loader Animation -->
<p>Loading Preview...</p>
</div>
` : html`
<div .innerHTML="${this.markup}" style="pointer-events: none;"></div>
`}
</a>
`;
}
static override styles = [
css`
:host {
display: block;
height: 100%;
box-sizing: border-box;
background-color: white;
border-radius: 9px;
padding: 12px;
}
a {
text-decoration: none;
color: inherit;
}
// ...additional styling if required...
`
];
}
export default ExampleBlockCustomView;
declare global {
interface HTMLElementTagNameMap {
'example-block-custom-view': ExampleBlockCustomView;
}
}
Note: This is just a sample to get you started. If you’re looking for a ready-made solution that addresses similar block preview needs, Umbraco.Community.BlockPreview is worth checking out. It offers great functionality and can save a lot of time in building custom preview mechanisms.
As we move toward the next LTS release, we can expect more changes, improvements, and perhaps even new approaches that may further impact how we work with block previews and content rendering in the CMS. As always, it’s crucial to stay updated on these changes to ensure our workflows remain efficient and adaptable.