Views: 296
Number of votes: 3
Average rating:

Represent the concept of "pages" and "blocks" with matching client side components in a SPA

To demonstrate some concepts that are useful when creating a SPA with working OPE, we are releasing a new SPA template site on Github, called MusicFestival, together with a series of blog posts. Don’t be discouraged that the SPA is written in Vue.js if you’re using React, Angular, or something else, as these concepts will work in any client side framework.

https://world.episerver.com/blogs/john-philip-johansson/dates/2018/10/introducing-a-new-template-site-for-spas-musicfestival/

Episerver CMS uses the concepts of pages and blocks, and this is how editors are trained and used to editing a site in the CMS UI. SPAs could make up one view from multiple page models, but how should an editor know where to edit it? Pages and blocks match their thinking, and are represented in the UI, so create components that match that.

An example would be that an editor of the MusicFestival site needs to update the dates for an artist’s performance. The natural way to do that would be to use the page tree to navigate to the artists page and edit it using On Page Edit (OPE).

edit-artist.gif

As the page data is represented as one model on the server, it makes sense that it is still one model on the client. Otherwise, you might need multiple places to fetch data and patch it together to represent the view that is rendered to visitors of the site. So by having one model on the client, it can trickle down property data, allowing each child component to be developed independently of each other (i.e. story book in React)

Who should own the model on the client?

The beta/contentSaved message tells you when the editor has edited a property in OPE, and that’s the time to re-render the view. The MusicFestival does this in only one place, and gets the full page JSON that in turn updates a model property that each page and block component takes as a param. The alternative is to have components own their own data updating, which will require more work per component. Having the data update in one place while editing, just as it would update while loading data in view mode, can make your API simpler.

The way pages and blocks are shown in OPE is slightly different.

  • A page will have a friendly URL in view mode; otherwise, it will have an Episerver URL that includes the content link.
  • A block does not have any friendly URLs, but can be previewed in OPE if you implement an ASP.NET MVC controller. They will always rely on content links.
  • The beta/contentSaved message includes the content link to the model version that is being edited, but no URL.

The friendly URL always gets the published model, while the content link can get any version of the model.

This means that pages will initially get their data with a friendly URL, while blocks get it with a content link. Both will update in OPE using content links. So your API needs to support both cases, and it’s helpful for the client side to expose both.

In MusicFestival we use a Vue.js mixin to set up a model property that represents the serialized page or block model, and automatically registers for the beta/contentSaved message that in turn updates the model with updateModelByContentLink. Block components initiate themselves with the same method, while page components use updateModelByFriendlyUrl instead. The key point is that there is only one instance of the page or block model on the client side, and it’s updated in one place. This allows the client side components to be written as if they weren’t in a CMS.

An excerpt from the EpiDataModelMixin shows how it registers for the beta/contentSaved message, how it owns the model as data it makes available to pages and blocks, and which methods it exposes:

export default {
    // registers for the `beta/contentSaved` message
    watch: {
        '$epi.isEditable': '_registerContentSavedEvent'
    },

    // it owns the model as data it makes available to pages and blocks
    data: function () {
        return {
            modelLoaded: false,
            model: {}
        };
    },

    // which methods it exposes
    methods: {
        updateModelByFriendlyUrl: function (friendlyUrl) {
            /* Updating a model by friendly URL is done when routing in the SPA. */
        },

        updateModelByContentLink: function (contentLink) {
            /* Updating a model by content link is done when something is being edited ('beta/contentSaved') and when previewing a block. */
        },

        _registerContentSavedEvent(isEditable) {
            if (isEditable) {
                window.epi.subscribe('beta/contentSaved', message => {
                    this.updateModelByContentLink(message.contentLink);
                });
            }
        }
    }
};

See the full source for the epiDataModelMixin.js mixin here.

The pages rely on friendly URLs, and are loaded by the PageComponentSelector, so it owns the model through the EpiDataModelMixin while getting the page URL from the router as a prop:

<script>
export default {
    mixins: [EpiDataModelMixin],
    created() {
        this.updateModelByFriendlyUrl(this.$props.url);
    }
};
</script>

See the full source for the PageComponentSelector.vue component here.

The blocks rely on content links, and need a view to decide how to preview blocks. In MusicFestival, we have a Preview component that loads multiple instances of BlockComponentSelector in different sizes, so Preview is the component owning the model through the EpiDataModelMixin:

<script>
export default {
    mixins: [EpiDataModelMixin],
    created() {
        this.updateModelByContentLink(this.contentLink);
    }
};
</script>

See the full source for the Preview.vue component here.

Avoiding unnecessary Razor files

As you might have seen in our AlloyReact template site, or as we’ve seen in partner solutions, it’s common to have a client side component render helper on the server side. They make it easier to get a React, or other, component where one would usually have written HTML in the various */Index.cshtml files. This is sometimes partly because of SEO, but that requires that the client side code is rendered on the server. So usually it’s done because the frontend developer prefers to write all of the visual components separate from the CMS, and then just deliver the page as set of client side resource files. This leads to creating mostly empty ASP.NET MVC controllers and Razor views that just include one element that is then bootstrapped by the client side:

// There's a better way to handle this.
@using AlloyTemplates.Models.Blocks
@model ThreeKeyFactsBlock


@Html.ReactComponent("ThreeKeyFacts", Model)

We’ve seen examples where developers have pasted their entire client side code into razor views because they believe that is required to get OPE working, but that is not the case. Read more about it in our developer guide.

If you use the concepts we introduced in the previous section, such as an Episerver page having a corresponding client side component, then it’s enough to have one ASP.NET MVC controller for all pages that will delegate the responsibility of choosing the right page or block view to the client side. The architectural overview described below will show the differences and similarities between rendering blocks and pages. Note that blocks will never be rendered by themselves outside of edit mode.

// Handling pages
DefaultPageController.cs
    DefaultPage/Index.cshtml
        DefaultPage.vue
            router-view (Vue.js)
                router.js
                    PageComponentSelector.vue (owns the model)
                        ArtistContainerPage/ArtistDetailsPage/LandingPage.vue

// Handling blocks
PreviewController.cs
    Preview/Index.cshtml
        Preview.vue (owns the model)
            BlockComponentSelector.vue
                BuyTicketBlock/ContentBlock/GenericBlock.vue

Handling pages

If all the pages are to be rendered on the client, you can avoid having boilerplate Razor files for each page by having a default page controller that will always load the same Razor view. That view will simply bootstrap the router view used by your client side framework.

DefaultPageController.cs See the whole source on github.

public class DefaultPageController : PageController<BasePage>
{
    public ViewResult Index(BasePage currentPage)
    {
        return View("~/Views/DefaultPage/Index.cshtml", currentPage);
    }
}

DefaultPage/Index.cshtml See the whole source on github.

@model MusicFestival.Template.Models.Pages.BasePage
@{ Layout = "~/Views/Shared/_BaseLayout.cshtml"; }

<default-page></default-page>

BasePage is just an empty abstract class inheriting from EPiServer.Core.PageData.

DefaultPage.vue See the whole source on github.

// example in Vue.js
<template>
    <router-view></router-view>
</template>

The router-view component is a part of the Vue.js router framework and will load the component that matches a route inside of it. Since the PageComponentSelector can render every page component we only need to register this one simple route on the client:

router.js See the whole source on github.

routes: [
    {
        path: '*',
        component: PageComponentSelector
    }
]

PageComponentSelector See the whole source on github.

The model holds a serialized version of the page data. The helper method getComponentTypeForPage iterates through all globally registered Vue.js components and returns the one with the same name as the contentType of the model. The <component> element is how Vue.js handles dynamic components (basically just a placeholder to load a real component).

<template>
    <component :is="getComponentTypeForPage(model)" :url="url" :model="model"></component>
</template>

<script>
export default {
    methods: {
        getComponentTypeForPage(model) {
            // this.$options.components will contain all globally registered components from main.js
            return getComponentTypeForContent(model, this.$options.components);
        }
    }
};
</script>

See the full source for the getComponentTypeForContent.js here.

Handling blocks

As with pages, the CMS UI is built with the concept of blocks and it’s easier to make a coherent editing experience for your users if your client side components have an equivalent concept. It’s also recommended to have a preview for blocks so your users can still enjoy OPE.

The preview page can be done similarly to the default page, and even when it’s a regular ASP.NET MVC application, it’s only one Razor view for the block preview.

To get it to work with blocks, we need a similar concept to select the block component in the SPA as we did for pages, but with some differences. First, you’ll need to figure out the content-link, as blocks don’t have a public URL, and your API will need to return the block type name. The Razor view for the Preview controller will set the content-link. The MusicFestival site uses the ContentDeliveryAPI that will include the block type name in the JSON response so we can use a very similar approach as the PageComponentSelector in our BlockComponentSelector.

PreviewController.cs See the whole source on github.

Remember to use the RequireClientResourcesAttribute as described in this blog post.

[RequireClientResources]
public class PreviewController : ActionControllerBase, IRenderTemplate<BlockData>
{
    public ActionResult Index(IContent currentContent)
    {
        var startPage = _contentRepository.Get<PageData>(ContentReference.StartPage);
        var model = new BlockEditPageViewModel(startPage, currentContent);

        return View(model);
    }
}

PreviewBlock.cs See the whole source on github.

public class PreviewBlock : PageData
{
    public IContent PreviewContent { get; }
}

Preview/Index.cshtml See the whole source on github.

For the razor view for the block preview, we need to pass the content link because the client side cannot just look at the URL to load the correct content (as the page can).

@model MusicFestival.Template.Models.Preview.BlockEditPageViewModel
@{ Layout = "~/Views/Shared/_BaseLayout.cshtml"; }

<Preview content-link="@Model.PreviewBlock.PreviewContent.ContentLink"></Preview>

Preview.vue See the whole source on github.

<template>
    <BlockComponentSelector :model="model"></BlockComponentSelector>
</template>
<script>
export default {
    props: ['contentLink'],
    mixins: [EpiDataModelMixin],
    components: {
        BlockComponentSelector
    }
};
</script>

BlockComponentSelector.vue See the whole source on github.

Finally, we come to the BlockComponentSelector that, as you can see, is very similar to the PageComponentSelector that we have shown before. A notable difference is that the BlockComponentSelector does not “own” the model. The reason for that is that the Preview component will render multiple BlockComponentSelector for the same model.

<template>
    <component :is="getComponentTypeForBlock(model)" :model="model"></component>
</template>

<script>
export default {
    props: ['model'],
    methods: {
        getComponentTypeForBlock: function (block) {
            // this.$options.components will contain all globally registered components from main.js
            // Load the "GenericBlock" in the case that no block is found.
            return getComponentTypeForContent(block, this.$options.components) || 'GenericBlock';
        }
    }
};
</script>

See the full source for the getComponentTypeForContent.js here.

I hope you can see that there are a lot of advantages to keeping to the concept of one content (page, block, or even media) representing one component on the client side in Episerver.

Related links

Blog series about the template site and SPA sites in OPE https://world.episerver.com/blogs/john-philip-johansson/dates/2018/10/introducing-a-new-template-site-for-spas-musicfestival/

Template site using the techniques discussed in this blog: https://github.com/episerver/musicfestival-vue-template/

Documentation on client side rendering in Episerver: https://world.episerver.com/documentation/developer-guides/CMS/editing/on-page-editing-with-client-side-rendering/

Please login to comment.