HomeDev GuideAPI Reference
Dev GuideAPI ReferenceUser GuideGitHubNuGetDev CommunityDoc feedbackLog In

On-page editing and Content Graph

Describes how to implement on-page editing integration in with a SPA client site using Content Graph (for CMS 12 only).

Prerequisites

Example code

The example site in this article is named Music Festival is hosted on Github. Follow the Readme file to run the sample site and check the implementation.

https://github.com/episerver/content-graph-js-sdk/tree/main/decoupled-site

How OPE works with decoupled site using Content Graph

There are a few components working together to make editor preview feature possible.

  1. The Optimizely Content Management System (CMS) management site: the source of data, also provides the editor UI.
  2. The Content Graph .NET client: it is installed as a a module in the CMS management site.
  3. The decoupled frontend site: a JavaScript Single Page Application, which retrieves content from Content Graph and display them to visitors in view mode and editors in edit mode.
  4. Content Graph Proxy: a proxy endpoint which is part of the Content Graph .NET client, it is used to retrieve unpublished content versions from Content Graph for editors to see when editing contents.

When CMS editors log in to the CMS management site to edit the frontend site, the frontend site is displayed in an iframe in the editor UI.

The CMS management site and the frontend site communicates with each other using a JS script called communicationinjector.js which is hosted in the CMS management site. This mechanism is needed because the two sites are not on the same domain, so the CMS management site cannot check the iframe of the frontend site directly to facilitate OPE.

The frontend site will determine if it is being browsed in edit mode (by editors using the management site) or view mode (by visitors visiting the site). This is currently accomplished by checking the existence of a query string parameter in the URL of the frontend site.

You must be logged in on both CMS site and the frontend site (using OIDC in this case).

When the site is in view mode, it sends a GraphQL query to Content Graph to get published contents to display to visitors.

When the site is in edit mode, it sends GraphQL queries to the Content Graph Proxy, it sends along the access token to identify the CMS editor, the Proxy then retrieves requested content versions (published or not) from Content Graph, filtered on the editor's access rights and permissions.

Every time you modify content in the CMS management site, the Content Graph client synchronizes that content to Content Graph, then the management site sends content-saved events to the frontend site through the communicationinjector.js script. The frontend site will then retrieve the updated data from Content Graph through the Content Graph proxy and re-render itself in the iframe.

The rendered DOM of the frontend site needs to contain some HTML attributes to tell the CMS management site which fields are editable and where they are in the frontend site's rendered HTML.

In order to implement this editor preview feature, it's important that you understand the roles and interactions between components, and design your frontend site to support integration with the CMS management site editor.

Preparations

This example uses a client site (decoupled site, it is hosted independently from the CMS site) written in React that retrieves contents from Content Graph. The data is synchronized from the CMS management site to Content Graph, and the client site in turn creates queries to Content Graph to retrieve the data to display.

In practice, the client site can be developed using Vue, Angular, or any other SPA framework.

For your React site, follow this doc for a guide on how to create a React site, the guide also includes how to setup the backend site (Optimizely CMS)https://docs.developers.optimizely.com/digital-experience-platform/v1.4.0-content-graph/docs/tutorial-create-a-site-with-strongly-typed-music-festival-content-using-react-and-content-graph

Setup backend site (Optimizely CMS)

  1. Update app settings.
    The OPE feature needs a full set of Turnstile keys that include SingleKey, AppKey, and Secret. You also need to set "AllowSyncDraftContent": true. All the configs should be like following.
    {
      "Optimizely": {
        "ContentGraph": {
          "GatewayAddress": "https://cg.optimizely.com",
          "AppKey": "INPUT_APP_KEY_HERE",
          "Secret": "INPUT_SECRET_KEY_HERE",
          "SingleKey": "INPUT_SINGLE_KEY_HERE",
          "AllowSyncDraftContent": true,
          ...
        } 
      },
      "FRONT_END_URI": "http://localhost:3000"
    }
    
    "AllowSyncDraftContent": true allows the .NET Content Graph sync package to sync all the versions of all contents to Content Graph. Otherwise only publicly available contents (Published and can be read by Everyone) will be synced to Content Graph.
  2. Configure the CMS site.
    In the setting Admin > Config > Manage Websites, add the decoupled frontend host into the website. It should be like following.
    Name: MusicFestival.Backend  
        URL: Backend_HOST  
        Start page: Root > Start  
        Host names  
            Backend_HOST - Edit  
            Client_HOST - Primary
    
    1. Example of a local development environment
      • The backend is hosted at localhost:8082
      • The frontend (React client) is hosted at localhost:3000

Since the frontend site is displayed in the CMS management site UI in an iframe and they are in different hosts, you
need to configure Cross Origin Resource Sharing policy to allow iframing the frontend site in the UI of the CMS site
(_frontendUri is an environment variable for the frontend site URL that is set on the appsetting.json).

app.UseCors(b => b
            .WithOrigins(new[] { $"{_frontendUri}" })
  1. Authentication & Authorization
    The example uses an OpenID Connect server implementation by Optimizely that is hosted in the same host as the CMS management site. For OPE, when configuring Content Graph, configure it to use OpenIDConnect Authentication scheme in the second argument of AddContentGraph in Startup.cs file.
    services.AddContentGraph(_configuration, OpenIDConnectOptionsDefaults.AuthenticationScheme);
    

If you use another authentication scheme

The example site uses OIDC access token to make queries to a Proxy to retrieve unpublished versions of contents that are filtered based on the user's access rights to contents, if you use another authentication scheme, you need to do the following.

  1. Use that authentication scheme in the second argument of AddContentGraph: services.AddContentGraph(_configuration, <Your authentication scheme>);
  2. Create an implementation of the interface ICGUserInfoResolver:
    public interface ICGUserInfoResolver
    {
        string UserName { get; }
        IEnumerable<string> Roles { get; }
    }
    
    The implementation should return the authenticated CMS User's username and their roles, the implementation details depend on the authentication scheme you are using.
  1. Register the ICGUserInfoResolver implementation as a singleton, so Content Graph sync package can use it in Startup.cs: services.AddSingleton<ICGUserInfoResolver, MyCustomCGUserInfoResolver>();

  2. Implement your frontend site to make sure to authenticate user when retrieving contents through the Content Graph Proxy.

Setup decoupled site

After you have developed a client-site written in React, this topics shows how to make the site work with On-Page Editing using Content Graph.

Because of step 2 of setting backend that sets Client_HOST as primary host, the client site is displayed on the Edit view of CMS as below

  1. Import communicationinjector.js JS library to allow communication between the frontend and the management site, the script is hosted at https://<Backend_HOST>/episerver/cms/latest/clientresources/communicationinjector.js.
    The script allows the frontend site to listen to changes from CMS when contents are updated by editors by subscribing to contentSaved event. Try to import it as soon as possible when the site loads.
    For example, if your back end site is on localhost:8082, you can load the JS script from this URL: http://localhost:8082/episerver/cms/latest/clientresources/communicationinjector.js

  2. Check edit view.
    You need to know if the site is being shown on the edit view, then you can make properties editable. The URL of client site when loading on edit view always includes a request parameter epieditmode so that you can easily figure it out.

    • If epieditmode = true, then the site is being edited.
    • If epieditmode = false, then the site is being previewed on the CMS UI.
      If the epieditmode parameter is available in the URL of the site, the site is being displayed in the iframe in CMS UI, and it needs to retrieve data from Content Graph through the Content Graph proxy running on Content Graph. The proxy uses the authentication scheme configured in Startup.cs. In the case of OIDC, each request to the proxy needs to include the access token to authenticate the CMS user.
      http://localhost:8082/EpiServer/ContentGraph/CGProxy/Query
      The proxy will return results that are filtered so that only contents user has access to will be returned.
      If the epieditmode parameter is not available in the URL, the site is being displayed publicly, and it needs to retrieve published contents directly from ContentGraph using Turnstile single key.
      https://cg.optimizely.com?auth=<Your single key>
  3. Querying contents using Content Graph.
    Assuming that you followed and created some GraphQL queries (*.graphql files) by following Add GraphQL query with fragments. This section describes how to query contents in some scenarios.

    1. Retrieve content in editor preview mode when user selects a content version. In this mode the management site will display the frontend site in an iframe. It will pass some information in the URL of the iframe, an example of such URL is /EPiServer/CMS/Content/en/artists/ash-ravine,,33_127/.
      This URL is set to the iframe when you select a version of a content in the Versions gadget.

      You can use this URL to query for the content, the information that can be extracted from the URL path are
    • The locale and language of the content: en
    • The relative path of the content: /en/artists/ash-ravine
    • The ID of the content: 33
    • The WorkID of the content version: 127
    1. You can refer to the sample code to see how you extract the values from the URL using some string manipulation. To retrieve this content from ContentGraph, you can use a query similar to the following query, just pass the values you extracted from the URL to the corresponding variables. In fact in this case only the Id and WorkID values are needed to get the version user is viewing or editing. If a value is null, ContentGraph will ignore that condition.
      Content(
              locale: [$locales]
              where: {
                  ContentLink: {WorkId: {eq: $workId}, Id: {eq: $contentId}}
                  RelativePath: {
                      eq: $relativePath
                  }
                  Language: {
                      Name: {
                          eq: $language
                      }
                  }
              })
              {
                //retrieve properties or use fragments here
              }
      
  4. Retrieve primary draft content when a content is selected in navigation pane. When a page is selected from the CMS UI navigation pane, the CMS site will select a version of the content to edit.

    In this case, the artist "Almost Up Today" has some versions in English language, 1 primary draft version, and one Published version.

    1. In this example, the generated URL for the iframe will be EPiServer/CMS/Content/en/artists/almost-up-today,,35/.
      The following information can be extracted from the URL:
      • The content's language and locale: en
      • The relative path of the content: /en/artists/almost-up-today
      • The content ID: 35
      • Notice the WorkID (ID of the specific version) is not provided.
        In this case, construct your GraphQL query to retrieve the same version that CMS selects when a content is selected in the Navigation Pane.
        A content can have multiple versions in different statuses but are put into two categories: published and others. Each version has a boolean field called IsCommonDraft. CMS will only select either the published version or the primary draft version. This field is also synchronized to ContentGraph.
        The rules to select the version are
    • The published version has IsCommonDraft = true.
    • The primary draft version has IsCommonDraft = true.
    • Other versions have IsCommonDraft = false.
    • If there are both Published and primary draft versions, the one that was saved last will be selected.
      When selecting a page from the navigation pane, CMS management site will refresh the iframe with just the Content ID in the URL, to know which version to display, make sure you are doing the following
    • Filter contents withIsCommonDraft to be true
    • Filter contents using the Content ID extracted from the URL.
    • Sort by Saved field in descending order orderBy: {Saved: DESC},
    • Select the first version or use limit: 1
      The query will be like following, it will use the values of content ID, relative path, locale/language and filter IsCommonDraft = true.
    Content(
            locale: [$locales]
            where: {
                ContentLink: {Id: {eq: $contentId}}
                RelativePath: {
                    eq: $relativePath
                }
                Language: {
                    Name: {
                        eq: $language
                    }
                },
                IsCommonDraft: {
                    eq: $isCommonDraft
                }
            },
            orderBy: {Saved: DESC},
            # need to sort by Saved time in descending order
            # otherwise sometimes you may get Published version instead of primary draft version.
            limit: 1
        ) {
          //use fragments or select fields to retrieve here
        }
    
  5. Retrieve published content when the site is viewed by visitors, outside the CMS management site.
    When epieditmode parameter is not in the URL, the site is being viewed by visitors and not being displayed inside the CMS management site.
    In this case, query for published contents directly to ContentGraph search endpoint, the full URL may look something like this: http://localhost:3000/en/artists
    You can detect that the following information from the URL

    • The locale and language: en
    • The relative path of the content: /en/artists
      In this case, you can make a GraphQL query to ContentGraph search endpoint and retrieve the published content by
    • Filtering by Relative path
    • Filtering by locales/language.
      The GraphQL query may look something like this:
    Content(
            locale: [$locales]
            where: {
                RelativePath: {
                    eq: $relativePath
                }
                Language: {
                    Name: {
                        eq: $language
                    }
                }
            }) {
              //use fragments or select your fields here
            }
    
  6. Use a single GraphQL query for all three scenarios.
    To summarize

    • If the frontend site URL contains querystring parameter epieditmode, make GraphQL queries to the Content Graph proxy to be able to retrieve unpublished versions of contents to preview in the Editor. Be sure to autenticate the request to the Content Graph proxy with the authentication scheme you are using.
      • If the URL contains the WorkID, query for the exact content version, this is when user selects an exact version.
      • If the URL does not contain WorkID, user is selecting a content from the Navigation pane, and you need to determine the correct version to display based on the following rules.
        • Filter contents by contentID and IsCommonDraft = true.
        • Sort contents by Saved field, in descending order.
        • Limit results by 1 to pick the top 1, this will be the selected version to display (either Published version or the primary draft).
    • If the frontend site URL does not contain the epieditmode parameter, the site is being viewed by a visitor. You need to make GraphQL requests directly to ContentGraph search endpoint, instead of going through the Content Graph proxy.
      In 3 scenarios, some filter parameters are used, some are not used. Since ContentGraph is designed to ignore some filter conditions if the value is not provided (variable set to null), you can use a single GraphQL query to cover 3 scenarios, instead of using separate queries for each scenario.
      The single GQL query looks something like this.
    query Start(
        $relativePath: String
        $locales: Locales
        $language: String
        $stageName: String
        $artistGenre: String
        $contentId: Int
        $workId: Int
        $statusEqual: String
        $isCommonDraft: Boolean
    ) {
        Content(
            locale: [$locales]
            where: {
                ContentLink: {WorkId: {eq: $workId}, Id: {eq: $contentId}}
                RelativePath: {
                    eq: $relativePath
                }
                Language: {
                    Name: {
                        eq: $language
                    }
                },
                Status: {
                    eq: $statusEqual
                },
                IsCommonDraft: {
                    eq: $isCommonDraft
                }
            },
            orderBy: {Saved: DESC},
            limit: 1
        ) {
            items {
                Name
                ParentLink {
                    Url
                }
                Url
                __typename
                RelativePath
                ....
            }
        }
    }
    
  7. Subscribe to CMS contentSaved to be notified when content is changed.
    After loading communicationinjector.js, the CMS management site and the frontend site loaded in the iframe will be able to communicate with each other. Whenever the editor modifies a content, it will notify the frontend site and triggers a contentSaved event.
    Subscribing to this event will notify the site of changes of contents from CMS. When the changes are successful, the event will be raised. You should update the property on the UI to display the changes.

    window.addEventListener("load", function () {
      (window as any).epi?.subscribe('contentSaved', function(eventData: any){
            // TODO: Re-update the changed property on the UI here
        });
    });
    

    The eventData looks like this, in this example, user modified the title property of the current content.

    {
        "contentLink": "5_122",
        "properties": [
            {
                "name": "title",
                "value": "music festival 2018",
                "successful": true,
                "validationErrors": null
            }
        ],
        "editUrl": "http://localhost:8082/EPiServer/CMS/#context=epi.cms.contentdata:///5_122",
        "previewUrl": "http://localhost:3000/EPiServer/CMS/Content/en/,,5_122/?epieditmode=true"
    }
    

    If the value in properties is of simple type like string, number, boolean,... You ust need to replace it on the UI that match with the changed property, update the React state and React will automatically update the DOM.
    On the other hand, if the type of the value is an object that means the property is complex type like MainContentArea, refresh the page to update the changes, or invalidate the query results so the React site will run the GraphQL query again to get fresh data. This is a limitation because the data structure in the eventData does not match the format of the data in ContentGraph, so it is difficult to extract such data and update the UI. The eventData looks like this

    {
        "contentLink": "5_122",
        "properties": [
            {
                "name": "mainContentArea",
                "value": [
                    {
                        "name": "Music Festival",
                        "contentGroup": "",
                        "contentLink": "41",
                        "typeIdentifier": "episerver.core.blockdata",
                        "contentTypeName": "",
                        "roleIdentities": [],
                        "attributes": {
                            "data-contentgroup": ""
                        },
                        "contentGuid": "6d559290-a37f-4741-a0de-944efba547ee"
                    },
                    {
                        "name": "Lineup",
                        "contentGroup": "",
                        "contentLink": "42",
                        "typeIdentifier": "episerver.core.blockdata",
                        "contentTypeName": "",
                        "roleIdentities": [],
                        "attributes": {
                            "data-contentgroup": ""
                        },
                        "contentGuid": "c2136e2a-0520-4c53-9358-7e32997634de"
                    }
                ],
                "successful": true,
                "validationErrors": null
            }
        ],
        "editUrl": "http://localhost:8082/EPiServer/CMS/#context=epi.cms.contentdata:///5_122",
        "previewUrl": "http://localhost:3000/EPiServer/CMS/Content/en/,,5_122/?epieditmode=true"
    }
    

    📘

    Note

    In some special cases, the name of a property in properties has one of prefixes ["icontent_", "ichangetrackable_", "iversionable_", "iroutable_"]. Remove the prefix to get correct property name. The eventData would be like this:

    {
        "contentLink": "6_121",
        "properties": [
            {
                "name": "icontent_name",
                "value": "Artists 123",
                "successful": true,
                "validationErrors": null
            }
        ],
        "editUrl": "http://localhost:8082/EPiServer/CMS/#context=epi.cms.contentdata:///6_121",
        "previewUrl": "http://localhost:3000/EPiServer/CMS/Content/en/artists,,6_121/?epieditmode=true"
    }
    
  8. Make a property editable.
    To make a property editable in edit mode, add the attribute data-epi-edit="INPUT_PROPERTY_NAME_HERE" into the tag wrapping the property value. The CMS management site would then know that the DOM element contains the value for that property and produces UI controls to make the property editable in edit mode like bellow.
    At this time, editor can click on the property and a dialog will show up for them to modify the content, inline editing is not supported.

    For example:

    • The simple properties

      <div className='top'>
        <h1 data-epi-edit="ArtistName">{content?.ArtistName}</h1>
      </div>
      <div className="artist-information">
        <p data-epi-edit="StageName">{content?.StageName}</p>
      <p><span data-epi-edit="PerformanceStartTime">{content?.PerformanceStartTime}</span> - <span data-epi-edit="PerformanceEndTime">{content?.PerformanceEndTime}</span></p>
      </div>
      <div className="artist-description" data-epi-edit="ArtistDescription">
        {parse(content?.ArtistDescription ?? '')}
      </div>
      
    • The complex property like MainContentArea

      <main className='Page-container'>
          <div>
              <section data-epi-edit="MainContentArea" className='Grid Grid--alignMiddle Grid--gutterA ContentArea'>
                  {content?.MainContentArea?.map((mainContentAreaItem: any, mainContentAreaItemIdx: number) => {
                      return (
                          (() => {
                              const contentItem = mainContentAreaItem?.ContentLink?.Expanded
                              ......
                              return (
                                  <div key={mainContentAreaItemIdx}>
                                      {GetBlockComponent(contentItem)}
                                  </div>
                              )
                          })()
                      )
                  })}
              </section>
          </div>
      </main>
      

Limitations and workarounds

The contentSaved event is triggered multiple times from backend

At the time of this writing, whenever the CMS editor changes a field of the current page from the management site, the backend site sends a contentSaved event to the frontend site using the communicationinjector.js script. However the same event seems to be triggered multiple times for each change.

To work around this limitation, in the event handler of the contentSaved event, you store the event message in a variable so you can check if the same message is sent multiple times later, so that you make sure that the same event is only handled once.

let previousSavedMessage: any = null;

const { mutate } = useMutation((obj: any) => obj, {
  onSuccess: (message: ContentSavedMessage) => {
    if (previousSavedMessage !== message) {
      previousSavedMessage = message;
      updateStartQueryCache(queryClient, data, variables, message)
    }
  }
});

In this case, you receive a ContentSavedMessage from the contentSaved event, since this same event might have been raised multiple times, compare it to a variable called previousSavedMessage that is set to null initially, if the event is not the same as previousSavedMessage, it means that the event is new and you handle the event and update the changed property. After updating the changed content property, set the value of message to previousSavedMessage, so that if you encounter the same message again, you will skip them, making sure you handle contentSaved events once.

References