Issues

Client Side Backoffice Extensions With Server Data

Along with the introduction of a new Backoffice and Management API in Umbraco 14+, came a change in focus for presentation level customizations. In earlier versions, server-side notifications were often used to amend the values provided to the Backoffice, which would then modify the editor experience.

These days the policy is to keep changes of the editor experience that are purely presentational as a client-side concern, with each possibility provided as a discrete extension point. The idea is that, by controlling the precise methods used to customize the Backoffice, the team can ensure that these remain available and compatible between releases, and greatly reduce the risk of unexpected breaking changes in project customizations.

For some Umbraco developers working with the short-term support (STS) releases since 14, and those now considering their long-term support (LTS) upgrades from 13 to 17, this has, and might still, elicit some concern.

Part of this will be coding some extensions in a different language - moving away from server-side notification handlers in C# to client-side extensions in JavaScript or TypeScript. There's more than a case of "moving cheese" here though. Sometimes you may not have the data available on the client to implement the logic needed to make the editorial experience modification that you want.

I thought it would be worth sharing a sample of how to solve problems of this type: where you are making a client-side extension but need to use some data that is stored or generated on the server. Although I'll show this for a very particular, illustrative example, I believe the pattern will be common, and useful in many contexts where this situation arises.

The case I looked at came up when gathering feedback from Umbraco developers as to what common Backoffice customizations they look to make by modifying the models sent from the server. Default values were raised, where on creating new documents some values will be prefilled for the editor to either accept or modify. In Umbraco 16, property value presets are the mechanism where this is done, which is a client-side extension point.

In this particular situation the values of these presets were only available on the server. The developer supported several Umbraco sites and had a common mechanism where the specific default values for the website were provided in configuration. So the challenge becomes making available this information held server-side to the client-side extension point.

Breaking it down, there are three parts to solve here with modern Umbraco

  1. Create a Management API endpoint that securely provides the necessary data to the Backoffice user.
  2. Create a context that will read the data from the API and make it available for use to other elements in the Backoffice.
  3. Implement the specific extension - in this case a property value preset - that will use the context's data to provide the necessary functionality.

You can see the full source code of how I've gone about this at https://github.com/AndyButland/UmbracoPropertyValuePresetSample. Note that Umbraco 16.3 or higher is required. In the rest of the article we'll look at the most important points.

A Custom Management API Controller

With the new Backoffice in Umbraco came an API layer that handles the interaction between the Backoffice client and the server-side services. This is the Management API, which provides a consistent and best practice interface for interacting with Umbraco as an authorized Backoffice user. The operations for all the different entities in Umbraco can be viewed at /umbraco/swagger after selecting "Umbraco Management API" from the drop-down list at the top.

When exposing data from the server to the Backoffice, either as a package or for a custom extension point, you'd likely want to create your own set of Management API endpoints.

The code for the controller in the sample discussed in this article is in GetPresetValuesController.cs and listed below. Note the various attributes that are added to the class and method, most of which you will need in any custom Management API controller. Here they are all added directly to the controller, but in something more sophisticated you'll likely create a base controller, to which many of these attributes can be moved.

The functionality of the endpoint is deliberately very straightforward: returning a hard-coded collection of default values for given document types and property aliases. I want to focus in this article and sample on the task of making this server-side data available to the client, but of course here you can apply whatever logic you need - reading from configuration, from a database or calculating the values based on other information.

If you want to run this sample directly, it relies on a document type being created with the alias of testPage, and will be expecting the property aliases shown in the hard-coded collection of values.

using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.Filters;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Authorization;
using UmbracoPropertyValuePresetSample.Web.Models;

namespace UmbracoPropertyValuePresetSample.Web.Controllers;

[ApiController]
[ApiVersion("1.0")]
[MapToApi("project-api")]
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
[JsonOptionsName(Constants.JsonOptionsNames.BackOffice)]
[Route("api/v{version:apiVersion}/project")]
[ApiExplorerSettings(GroupName = "Preset Values")]
public class GetPresetValuesController : Controller
{
    private readonly IContentTypeService _contentTypeService;

    public GetPresetValuesController(IContentTypeService contentTypeService) => _contentTypeService = contentTypeService;

    [HttpGet("preset-values")]
    [MapToApiVersion("1.0")]
    [ProducesResponseType(typeof(IEnumerable<PresetValueDto>), StatusCodes.Status200OK)]
    public IActionResult GetPresetValues()
    {
        const string TestPageDocTypeAlias = "testPage";

        Guid testPageDocumentTypeKey = _contentTypeService.Get(TestPageDocTypeAlias)!.Key;

        var dtos = new List<PresetValueDto>
        {
            new() {
                DocTypeId = testPageDocumentTypeKey,
                PropertyAlias = "text1",
                Value = "Green"
            },
            new() {
                DocTypeId = testPageDocumentTypeKey,
                PropertyAlias = "text2",
                Value = "Red"
            },
            new() {
                DocTypeId = testPageDocumentTypeKey,
                PropertyAlias = "list1",
                Value = "Blue"
            },
            new() {
                DocTypeId = testPageDocumentTypeKey,
                PropertyAlias = "number1",
                Value = 42
            }
        };
        return Ok(dtos);
    }
}

As well as your controllers you'll need a composer to hook up the Management API with Umbraco, and have it displayed nicely in the Swagger UI.

Such a composer will look like this:

using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Umbraco.Cms.Api.Management.OpenApi;
using Umbraco.Cms.Core.Composing;

namespace UmbracoPropertyValuePresetSample.Web;

public class ManagementApiComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
        => builder.Services.ConfigureOptions<ProjectConfigureSwaggerGenOptions>();
}

public class ProjectBackOfficeSecurityRequirementsOperationFilter : BackOfficeSecurityRequirementsOperationFilterBase
{
    protected override string ApiName => "project-api";
}

public class ProjectConfigureSwaggerGenOptions : IConfigureOptions<SwaggerGenOptions>
{
    public void Configure(SwaggerGenOptions options)
    {
        options.SwaggerDoc("project-api", new OpenApiInfo { Title = "Project API", Version = "1.0" });
        options.OperationFilter<ProjectBackOfficeSecurityRequirementsOperationFilter>();
        options.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["action"]}");
    }
}

A Backoffice client project

Now we have an authorized endpoint, it's perfectly possible to make a call from JavaScript to this using fetch or any other client-side library capable of making requests over HTTP. I'm going to assume here though that this may not be the only Backoffice extension we'd like to make, so we'll go to the trouble of setting up a client project using TypeScript and Vite.

There are a few ways to get started with preparing your front-end build project. The umbraco extension template is a good way to begin, and the opinionated package starter template from Lotte Pitcher, that wraps this with other goodies for package development, is proving popular with the community. Of course there's also good old "copy and paste", which you can do from this sample by copying and adapting the src/UmbracoPropertyValuePresetSample.Web/Client folder.

These are the key files to note:

  • public/umbraco-package.json - exposes the entry point to the client-side code and defines various metadata about the extension. It's this file that makes the extension available to Umbraco.
  • scripts/generate-openapi.js - this will be used in a client-side build command to generate a typed client for calling our API. Not essential but very useful, as it ensures we don't have to repeat the work of defining models on the server and client and can work with typed methods rather than lower-level "JSON over HTTP" requests and responses.
  • package.json - defines the client-side build commands and the dependencies needed for the project. Among a few others, you'll need a reference here to the Umbraco Backoffice library @umbraco-cms/backoffice.
  • tsconfig.json - customizes how TypeScript is set up for the project.
  • vite.config.ts - customizes how the Vite build system is set up for the project, including defining the output directory in App_Plugins.

The client-side source code will go in the src folder, which at a bare minimum will need the index.ts file. This serves as the entry point for the client-side extension project, and has the responsibility of hooking up the authorization of the current user, as well as loading in the various extensions we've provided in the form of manifests. For an empty project there won't be any of these yet, but that'll change - as every customization will require a manifest to register it.

You should be able to run the following commands now to successfully generate a working, albeit empty, client-side extension project:

npm i
npm run build

A typed client

As mentioned earlier, we want to work with a typed client on the front-end so we have a higher level of abstraction than HTTP and JSON when communicating with the Management API. We can use the tools registered as dependencies to generate this. Ensure Umbraco is running and the expected Management API endpoint is visible on the Swagger UI, then run the following from the Client folder:

npm run generate-client

You should find types and services created automatically and saved into src/api.

A Backoffice context

At this point we can start building our extension, but before diving directly into that we'll also set up a small amount of client-side architecture. We won't call the API directly from our extension, rather we'll create a context responsible for the data access, and use that in our extension.

If you are coming from a C# background, you can think of a context a little bit like a component registered with a dependency injection framework. We'll be able to request it from the elements that need access to it.

Given our configured default values are hardcoded, it's not efficient to request them multiple times, or every time a new document is created. Rather we'd prefer to retrieve them once and cache them. Adding a context will also allow for re-use if we ever need it, and will follow familiar Backoffice practices that will be recognizable if you ever go digging in the source code of packages or Umbraco itself.

You'll find the code for this in the src/contexts folder, and it consists of three files.

Firstly, in property-value-preset-context-token.ts, a token, that associates our implementation of the context with a constant that can be used to retrieve it in the client-side components that need it:

import type { ProjectPropertyValuePresetContext } from "./property-value-preset-context.js";
import { UmbContextToken } from "@umbraco-cms/backoffice/context-api";

export const PROJECT_PROPERTY_VALUE_PRESET_CONTEXT = new UmbContextToken<ProjectPropertyValuePresetContext>("project-property-value-preset-context");

Then we have the implementation of the context itself in property-value-preset-context.ts.

The main concerns to pick up start from the call to #getPresets() when the context is loaded. This function uses the generated service to retrieve a typed collection of values from the Management API, saves them to #presetDefinitions which in turn is exposed publicly from the context as an observable collection in presetDefinitions.

import { UmbContextBase } from "@umbraco-cms/backoffice/class-api";
import { UmbObjectState } from "@umbraco-cms/backoffice/observable-api";
import type { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api";
import { PROJECT_PROPERTY_VALUE_PRESET_CONTEXT } from "./property-value-preset-context-token.js";
import { tryExecute } from "@umbraco-cms/backoffice/resources";
import { PresetValueDto, PresetValues } from "../api/index.js";

export class ProjectPropertyValuePresetContext extends UmbContextBase {
  #presetDefinitions = new UmbObjectState<PresetValueDto[] | undefined>(undefined);
  presetDefinitions = this.#presetDefinitions.asObservable();

  constructor(host: UmbControllerHost) {
    super(host, PROJECT_PROPERTY_VALUE_PRESET_CONTEXT);
  }

  async hostConnected() {
    super.hostConnected();

    await this.#getPresets();
  }

  async #getPresets() {
    const { data } = await tryExecute(this, PresetValues.getPresetValues());
    if (data) {
      this.#presetDefinitions.setValue(data)
    }
  }
}

export default ProjectPropertyValuePresetContext;

Finally we have our first manifest definition, in manifests.ts. Here we define the context, giving it a type, name and alias and linking it to the implementation.

import type { ManifestGlobalContext } from "@umbraco-cms/backoffice/extension-registry";

const contextManifest: ManifestGlobalContext = {
  type: "globalContext",
  alias: "Project.PropertyValuePresetContext",
  name: "Project Property Value Preset Context",
  api: () => import("./property-value-preset-context.js"),
};

export const manifests = [contextManifest];

If you look back to index.ts you'll see how this exported context is picked up and registered with the Backoffice application.

A property value preset extension

The extension itself is defined in the src/property-value-presets folder, and it consists of two files.

The implementation is in project-property-value-preset.ts. The first thing to note in this file is in the constructor, where we consume our context based on the constant PROJECT_PROPERTY_VALUE_PRESET_CONTEXT that we defined earlier, observe it and read the exposed value of presetDefinitions. We then save this as to the private variable #presetDefinitions.

A property value preset extension requires the implementation of the UmbPropertyValuePreset interface that defines a single method: processValue. That's the next piece of code to look at. You'll see the method takes various parameters. One of which, callArgs, defines various details about the property for which we are looking for a default value for. We use this to find the document type ID and property alias, and look up in #presetDefinitions to see if we have a default value defined. If so, we return that value.

The second file is, of course, another manifest. This one defines our property value preset and allows it to be registered for use in the Backoffice. As property value presets are associated with property editors via the forPropertyEditorSchemaAlias we need to add an entry for each one we want to support.

export const manifests: Array<UmbExtensionManifest> = [
  {
    type: 'propertyValuePreset',
    forPropertyEditorSchemaAlias: 'Umbraco.TextBox',
    alias: 'Project.PropertyValuePreset.TextBox',
    name: 'Text Box Preset for Initial Values',
    api: () => import('./project-property-value-preset.js'),
  },
  {
    type: 'propertyValuePreset',
    forPropertyEditorSchemaAlias: 'Umbraco.TextArea',
    alias: 'Project.PropertyValuePreset.TextArea',
    name: 'Text Area Preset for Initial Values',
    api: () => import('./project-property-value-preset.js'),
  },
  ...
];

With this in place we can run npm run build and see the results when creating a new page in the Backoffice:

Bonus: Updating the preset values

I had intended to stop there with this example, just sticking to showing how to handle server-side provided data in a client-side extension. We started with a Management API returning a hard-coded list of preset values, which was aligned with the use case discussed, where the developer wanted to provide the values from configuration. In that case, it's likely fine to load the values once and expect that they aren't going to change for the lifetime of the running Umbraco application.

What if they did need to change on occasion though - perhaps if read from a database or updated via a notification? Can we keep our context and the value it provides of efficiently retrieving, storing and exposing the preset values, but still allow them to be refreshed?

The answer is yes. We have a feature in the Backoffice using SignalR that allows the Backoffice to retrieve details of document and data types once and only go back to the server to get them again if an event is handled that indicates they have been updated and the local Backoffice cache should be invalidated. We can piggy-back on this for our own extensions.

Firstly, on the server we need to broadcast an event to the connected clients that the configured preset values have changed. I've chosen to do this in a second controller called in RefreshPresetValuesController.cs, but this could come from anywhere - such as a notification handler, or in some other code that is related to managing these values.

The key code is as follows, which uses the injected IServerEventRouter to broadcast an event, which is defined with a particular source and type:

await _serverEventRouter.RouteEventAsync(new ServerEvent
{
    EventSource = Constants.ServerEvents.EventSource.Document,
    EventType = "PresetValuesUpdated"
});

We can respond to that event on the client side in our context implementation, which is extended as follows. Note in the constructor where we consume a core context via UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT and observe the server events. In #observeServerEvents() we filter just the events that match the source and type we are interested in and, if and when found, re-retrieve the preset values.

import { UmbContextBase } from "@umbraco-cms/backoffice/class-api";
import { UmbObjectState } from "@umbraco-cms/backoffice/observable-api";
import type { UmbControllerHost } from "@umbraco-cms/backoffice/controller-api";
import { PROJECT_PROPERTY_VALUE_PRESET_CONTEXT } from "./property-value-preset-context-token.js";
import { tryExecute } from "@umbraco-cms/backoffice/resources";
import { PresetValueDto, PresetValues } from "../api/index.js";
import { UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT, UmbManagementApiServerEventModel } from "@umbraco-cms/backoffice/management-api";

export class ProjectPropertyValuePresetContext extends UmbContextBase {
  #presetDefinitions = new UmbObjectState<PresetValueDto[] | undefined>(undefined);
  presetDefinitions = this.#presetDefinitions.asObservable();

  #serverEventContext?: typeof UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT.TYPE;

  constructor(host: UmbControllerHost) {
    super(host, PROJECT_PROPERTY_VALUE_PRESET_CONTEXT);

    this.consumeContext(UMB_MANAGEMENT_API_SERVER_EVENT_CONTEXT, (context) => {
      this.#serverEventContext = context;
      this.#observeServerEvents();
    });
  }

  async hostConnected() {
    super.hostConnected();

    await this.#getPresets();
  }

  async #getPresets() {
    const { data } = await tryExecute(this, PresetValues.getPresetValues());
    if (data) {
      this.#presetDefinitions.setValue(data)
    }
  }

  #observeServerEvents() {
    const eventSources: Array<string> = ["Umbraco:CMS:Document"];
    const eventTypes: Array<string> = ["PresetValuesUpdated"];
    this.observe(
      this.#serverEventContext?.byEventSourcesAndEventTypes(eventSources, eventTypes),
      (event) => {
        if (!event) return;
        this.#onServerEvent(event);
      },
      'umbObserveServerEvents',
    );
  }

  async #onServerEvent(_event: UmbManagementApiServerEventModel) {
    await this.#getPresets();
  }
}

export default ProjectPropertyValuePresetContext;

Summing up

If this is the first time you are looking at building a client-side extension, there may seem a lot here. New languages, new concepts and a bit of ceremony to get going - and maybe this seems like a lot of effort. When compared to techniques in earlier versions of Umbraco, in terms of number of files, and lines of code, it's probably true that you see an increase.

Hopefully though, as you get started with it, you'll also appreciate the improved architecture, better separation of concerns and the increased safety around inadvertent breaking changes to customizations that the new approach brings.

Bear in mind too that most of what I've described in this article you only need to do once, and once prepared, you'll have a statically typed, front-end project and build system you'll be able to easily add further extensions too.

There's no doubt that Backoffice customization has moved on and some things you used to do in Umbraco you'll need to approach in an alternative way, likely more client-side focussed than in the past. However what I've hopefully shown from this article and sample is that doesn't mean you have to forgo control of a given customization from the server. If that's where the data is, you can make it available, efficiently and reusable, on the client.

Andy Butland

Andy lives in Bassano del Grappa, Italy and works remotely for Zone, a digital agency now part of Cognizant Digital. He's worked with Umbraco for many years, on projects, packages and core contributions. Find him on Twitter at @andybutland.

comments powered by Disqus