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.
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).
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/
Comments