Documentation

Modules for Developers

How to Install a Module

Place the module DLL file into App_Data/BetterCMS/Modules folder and it will be loaded dynamically at run time, or add the module assembly as a reference.

How to Create a Module in Visual Studio

Currently, there is no simple way to setup a new project for a Better CMS module implementation in Visual Studio. Follow the instructions below to prepare it manually. And because there are two types of modules, with or without GUI, lets start with simpler one: without GUI:

  1. Add a new Class Library (for module without GUI) or ASP.NET Empty Web Application (for module with GUI) project to the solution.
  2. Install the Better CMS NuGet package to this project.
  3. Add module descriptor class (note: a new ModuleId Guid must be generated for each new module):
using BetterCms.Core.Modules;
using System;
namespace BetterCms.Module.DemoNewsletter
{
    public class DemoNewsletterDescriptor : ModuleDescriptor
    {
        internal const string ModuleName = "DemoNewsletter";

        internal const string ModuleId = "bb29419b-55fd-4521-a351-99c43c86c58e";

        internal const string ModuleAreaName = "bcms-demonewsletter";

        public DemoNewsletterDescriptor(ICmsConfiguration configuration) : base(configuration) { }

        public override Guid Id
        {
            get { return new Guid(ModuleId); }
        }

        public override string Name
        {
            get { return ModuleName; }
        }

        public override string Description
        {
            get { return "Demo newsletter module short description goes here."; }
        }
    }
}
  1. (Optional) If the website and module are on the same solution, add a post-build event into the module project properties to copy module DLL into the website App_Data folder. Example:
copy "$(TargetDir)$(TargetName).dll" "$(SolutionDir)BetterCmsDemoProject\App_Data\BetterCms\Modules" /Y
copy "$(TargetDir)$(TargetName).pdb" "$(SolutionDir)BetterCmsDemoProject\App_Data\BetterCms\Modules" /Y

How to Create Data Model

  1. Create the database migration scripts:
using FluentMigrator;
using BetterCms.Core.DataAccess.DataContext.Migrations;
using BetterCms.Core.Models;
namespace BetterCms.Module.DemoNewsletter.Models.Migrations
{
    [Migration(201305141100)]
    public class InitialSetup : DefaultMigration
    {
        public InitialSetup() : base(DemoNewsletterDescriptor.ModuleName) { }

        public override void Up()
        {
            Create.Table("Subscribers").InSchema(SchemaName)
                  .WithCmsBaseColumns()
                  .WithColumn("Email").AsString(MaxLength.Email).NotNullable();
        }

        public override void Down()
        {
            Delete.Table("Subscribers").InSchema(SchemaName);
        }
    }
}
  1. Add a class for the migration versions meta data:
using FluentMigrator.VersionTableInfo;
namespace BetterCms.Module.DemoNewsletter.Models.Migrations
{
    [VersionTableMetaData]
    public class MigrationVersioning : IVersionTableMetaData
    {
        public string SchemaName { get { return "bcms_" + DemoNewsletterDescriptor.ModuleName; } }

        public string TableName { get { return "VersionInfo"; } }

        public string ColumnName { get { return "Version"; } }

        public string UniqueIndexName { get { return "uc_VersionInfo_Version_" + DemoNewsletterDescriptor.ModuleName; } }
    }
}
  1. Create serialize-able data entity classes in Models as the example:
using BetterCms.Core.Models;
using System;
namespace BetterCms.Module.DemoNewsletter.Models
{
    [Serializable]
    public class Subscriber : EquatableEntity<Subscriber>
    {
        public virtual string Email { get; set; }
    }
}
  1. Add mappings:
using BetterCms.Core.Models;
namespace BetterCms.Module.DemoNewsletter.Models.Maps
{
    public class SubscriberMap : EntityMapBase<Subscriber>
    {
        public SubscriberMap() : base(DemoNewsletterDescriptor.ModuleName)
        {
            Table("Subscribers");
            Map(f => f.Email).Not.Nullable().Length(MaxLength.Email);
        }
    }
}

What About Multilingual Support?

Better CMS modules can support multiple languages. For this purpose, add a resource file DemoNewsletterGlobalization.resx to Content/Resources with a Public access modifier. For example:

CreateSubscriber_CreatedSuccessfully_Message        Newsletter subscriber created successfully.
DeleteSubscriber_Confirmation_Message               Are you sure you want to delete newsletter subscriber {0}?
DeleteSubscriber_DeletedSuccessfully_Message        Newsletter subscriber deleted successfully.
EditSubscriber_IvalidEmail_Message                  Subscriber email is invalid.
SiteSettings_NewsletterSubscribers_Email_Title      Email Address
SiteSettings_NewsletterSubscribersMenuItem          Newsletter subscribers
SiteSettings_NewsletterSubscribers_Title            Newsletter subscribers

And now these resources can be used, for example in a ViewModels:

using System;
using System.ComponentModel.DataAnnotations;
using BetterCms.Core.Models;
using BetterCms.Module.DemoNewsletter.Content.Resources;
using BetterCms.Module.Root;
using BetterCms.Module.Root.Content.Resources;
using BetterCms.Module.Root.Mvc.Grids;
namespace BetterCms.Module.DemoNewsletter.ViewModels
{
    public class SubscriberViewModel : IEditableGridItem
    {
        [Required(ErrorMessageResourceType = typeof(RootGlobalization), ErrorMessageResourceName = "Validation_RequiredAttribute_Message")]
        public virtual Guid Id { get; set; }

        [Required(ErrorMessageResourceType = typeof(RootGlobalization), ErrorMessageResourceName = "Validation_RequiredAttribute_Message")]
        public virtual int Version { get; set; }

        [Required(ErrorMessageResourceType = typeof(RootGlobalization), ErrorMessageResourceName = "Validation_RequiredAttribute_Message")]
        [StringLength(MaxLength.Email, ErrorMessageResourceType = typeof(RootGlobalization), ErrorMessageResourceName = "Validation_StringLengthAttribute_Message")]
        [RegularExpression(RootModuleConstants.EmailRegularExpression, ErrorMessageResourceType = typeof(DemoNewsletterGlobalization), ErrorMessageResourceName = "EditSubscriber_IvalidEmail_Message")]
        public virtual string Email { get; set; }
    }
}

or in controller actions (note: the following code is from a controller that will be created later on in the tutorial):

[HttpPost]
[BcmsAuthorize(RootModuleConstants.UserRoles.Administration)]
public ActionResult DeleteSubscriber(string id, string version)
{
    var request = new SubscriberViewModel { Id = id.ToGuidOrDefault(), Version = version.ToIntOrDefault() };
    var success = GetCommand<DeleteSubscriberCommand>().ExecuteCommand(request);
    if (success)
    {
        if (!request.Id.HasDefaultValue())
        {
            Messages.AddSuccess(DemoNewsletterGlobalization.DeleteSubscriber_DeletedSuccessfully_Message);
        }
    }
    return WireJson(success);
}

Where to Place Module Business Logic

For this purpose, use commands that will be called from controller actions. For example:

using System.Linq;
using BetterCms.Core.DataAccess.DataContext;
using BetterCms.Core.Mvc.Commands;
using BetterCms.Module.DemoNewsletter.Models;
using BetterCms.Module.DemoNewsletter.ViewModels;
using BetterCms.Module.Root.Mvc;
using BetterCms.Module.Root.Mvc.Grids.Extensions;
using BetterCms.Module.Root.Mvc.Grids.GridOptions;
using BetterCms.Module.Root.ViewModels.SiteSettings;
namespace BetterCms.Module.DemoNewsletter.Commands
{
    public class GetSubscriberListCommand : CommandBase, ICommand<SearchableGridOptions, SearchableGridViewModel<SubscriberViewModel>>
    {
        public SearchableGridViewModel<SubscriberViewModel> Execute(SearchableGridOptions request)
        {
            request.SetDefaultSortingOptions("Email");
            var query = Repository.AsQueryable<Subscriber>();
            if (!string.IsNullOrWhiteSpace(request.SearchQuery))
            {
                query = query.Where(a => a.Email.Contains(request.SearchQuery));
            }
            var subscribers = query
                .Select(subscriber =>
                    new SubscriberViewModel
                    {
                        Id = subscriber.Id,
                        Version = subscriber.Version,
                        Email = subscriber.Email
                    });

            var count = query.ToRowCountFutureValue();
            subscribers = subscribers.AddSortingAndPaging(request);
            return new SearchableGridViewModel<SubscriberViewModel>(subscribers.ToList(), request, count.Value);
        }
    }
}
using BetterCms.Core.DataAccess.DataContext;
using BetterCms.Core.Mvc.Commands;
using BetterCms.Module.DemoNewsletter.Models;
using BetterCms.Module.DemoNewsletter.ViewModels;
using BetterCms.Module.Root.Mvc;
namespace BetterCms.Module.DemoNewsletter.Commands
{
    public class SaveSubscriberCommand : CommandBase, ICommand<SubscriberViewModel, SubscriberViewModel>
    {
        public SubscriberViewModel Execute(SubscriberViewModel request)
        {
            var isNew = request.Id.HasDefaultValue();
            var subscriber = isNew ? new Subscriber() : Repository.AsQueryable<Subscriber>(w => w.Id == request.Id).FirstOne();
            subscriber.Email = request.Email;
            subscriber.Version = request.Version;
            Repository.Save(subscriber);
            UnitOfWork.Commit();
            return new SubscriberViewModel
            {
                Id = subscriber.Id,
                Version = subscriber.Version,
                Email = subscriber.Email
            };
        }
    }
}
using BetterCms.Core.Mvc.Commands;
using BetterCms.Module.DemoNewsletter.Models;
using BetterCms.Module.DemoNewsletter.ViewModels;
using BetterCms.Module.Root.Mvc;
namespace BetterCms.Module.DemoNewsletter.Commands
{
    public class DeleteSubscriberCommand : CommandBase, ICommand<SubscriberViewModel, bool>
    {
        public bool Execute(SubscriberViewModel request)
        {
            Repository.Delete<Subscriber>(request.Id, request.Version);
            UnitOfWork.Commit();
            return true;
        }
    }
}

Use the commands in controller actions:

using System.Web.Mvc;
using BetterCms.Core.Security;
using BetterCms.Module.DemoNewsletter.Commands;
using BetterCms.Module.DemoNewsletter.Content.Resources;
using BetterCms.Module.DemoNewsletter.ViewModels;
using BetterCms.Module.Root;
using BetterCms.Module.Root.Mvc;
using BetterCms.Module.Root.Mvc.Grids.GridOptions;
using Microsoft.Web.Mvc;
namespace BetterCms.Module.DemoNewsletter.Controllers
{
    [ActionLinkArea(DemoNewsletterDescriptor.ModuleAreaName)]
    public class SubscriberController : CmsControllerBase
    {
        [BcmsAuthorize(RootModuleConstants.UserRoles.Administration)]
        public ActionResult ListTemplate()
        {
            var view = RenderView("List", null);
            var subscribers = GetCommand<GetSubscriberListCommand>().ExecuteCommand(new SearchableGridOptions());
            return ComboWireJson(subscribers != null, view, subscribers, JsonRequestBehavior.AllowGet);
        }

        [BcmsAuthorize(RootModuleConstants.UserRoles.Administration)]
        public ActionResult SubscribersList(SearchableGridOptions request)
        {
            var model = GetCommand<GetSubscriberListCommand>().ExecuteCommand(request);
            return WireJson(model != null, model);
        }

        [HttpPost]
        [BcmsAuthorize(RootModuleConstants.UserRoles.Administration)]
        public ActionResult SaveSubscriber(SubscriberViewModel model)
        {
            var success = false;
            SubscriberViewModel response = null;
            if (ModelState.IsValid)
            {
                response = GetCommand<SaveSubscriberCommand>().ExecuteCommand(model);
                if (response != null)
                {
                    if (model.Id.HasDefaultValue())
                    {
                        Messages.AddSuccess(DemoNewsletterGlobalization.CreateSubscriber_CreatedSuccessfully_Message);
                    }
                    success = true;
                }
            }
            return WireJson(success, response);
        }

        [HttpPost]
        [BcmsAuthorize(RootModuleConstants.UserRoles.Administration)]
        public ActionResult DeleteSubscriber(string id, string version)
        {
            var request = new SubscriberViewModel { Id = id.ToGuidOrDefault(), Version = version.ToIntOrDefault() };
            var success = GetCommand<DeleteSubscriberCommand>().ExecuteCommand(request);
            if (success)
            {
                if (!request.Id.HasDefaultValue())
                {
                    Messages.AddSuccess(DemoNewsletterGlobalization.DeleteSubscriber_DeletedSuccessfully_Message);
                }
            }
            return WireJson(success);
        }
    }
}

What About Views?

To place HTML representation, use regular views. Create the view List.cshtml under the Views/Shared folder of the module root project:

@using System.Web.Mvc.Html
@using BetterCms.Module.DemoNewsletter.Content.Resources
@using BetterCms.Module.Root;
@using BetterCms.Module.Root.Mvc.Grids;
@using BetterCms.Module.Root.ViewModels.Shared;
@{
    var gridViewModel = new EditableGridViewModel
    {
        Columns = new List<EditableGridColumn>
            {
                new EditableGridColumn(DemoNewsletterGlobalization.SiteSettings_NewsletterSubscribers_Email_Title, "Email", "email")
                    {
                        AutoFocus = true
                    }
            }
    };
}
<div class="bcms-scroll-window">
    @Html.Partial(RootModuleConstants.EditableGridTemplate, gridViewModel)
</div>

Note: All the Views, JavaScript files and CSS files should be marked as embedded resources. This can be done in Visual Studio by selecting "file properties" and changing the "Build Action" type to Embedded resource:

Embedded resources

Where to Add CSS Files for Module

Place your CSS files into the Content/Styles folder and override the RegisterCssIncludes() method in the module descriptor:

public override IEnumerable<CssIncludeDescriptor> RegisterCssIncludes()
{
    return new[]
        {
            new CssIncludeDescriptor(this, "somemodulestyle.css"),
        };
}

How to Add Client Side Functionality

In regard to JavaScripts: you need to add them as an embedded resources in to the Scripts folder under the module project root, the create JavaScript modules descriptors and register them in the module descriptor. bcms.demonewsletter.js script is an example:

/*jslint unparam: true, white: true, browser: true, devel: true */
/*global bettercms */
bettercms.define('bcms.demonewsletter', ['bcms.jquery', 'bcms', 'bcms.modal', 'bcms.siteSettings', 'bcms.dynamicContent', 'bcms.ko.extenders', 'bcms.ko.grid'],
    function ($, bcms, modal, siteSettings, dynamicContent, ko, kogrid) {
        'use strict';
        var newsletter = {},
            selectors = {},
            links = {
                loadSiteSettingsSubscribersUrl: null,
                loadSubscribersUrl: null,
                saveSubscriberUrl: null,
                deleteSubscriberUrl: null
            },
            globalization = {
                subscriberDialogTitle: null,
                deleteSubscriberDialogTitle: null
            };
        
        newsletter.links = links;
        newsletter.globalization = globalization;
        newsletter.selectors = selectors;

        var SubscribersListViewModel = (function (_super) {
            bcms.extendsClass(SubscribersListViewModel, _super);
            function SubscribersListViewModel(container, items, gridOptions) {
                _super.call(this, container, links.loadSubscribersUrl, items, gridOptions);
            }
            SubscribersListViewModel.prototype.createItem = function (item) {
                return new SubscriberViewModel(this, item);
            };
            return SubscribersListViewModel;
        })(kogrid.ListViewModel);

        var SubscriberViewModel = (function (_super) {
            bcms.extendsClass(SubscriberViewModel, _super);
            function SubscriberViewModel(parent, item) {
                _super.call(this, parent, item);
                var self = this;
                self.email = ko.observable().extend({ required: "", email: "", maxLength: { maxLength: ko.maxLength.email } });
                self.registerFields(self.email);
                self.email(item.Email);
            }
            SubscriberViewModel.prototype.getDeleteConfirmationMessage = function () {
                return $.format(globalization.deleteSubscriberDialogTitle, this.email());
            };
            SubscriberViewModel.prototype.getSaveParams = function () {
                var params = _super.prototype.getSaveParams.call(this);
                params.Email = this.email();
                return params;
            };
            return SubscriberViewModel;
        })(kogrid.ItemViewModel);

        function initializeSiteSettingsNewsletterSubscribers(container, json) {
            var data = (json.Success == true) ? json.Data : {};
            var viewModel = new SubscribersListViewModel(container, data.Items, data.GridOptions);
            viewModel.deleteUrl = links.deleteSubscriberUrl;
            viewModel.saveUrl = links.saveSubscriberUrl;
            ko.applyBindings(viewModel, container.get(0));
        }

        newsletter.loadSiteSettingsNewsletterSubscribers = function () {
            dynamicContent.bindSiteSettings(siteSettings, links.loadSiteSettingsSubscribersUrl, {
                contentAvailable: function (json) {
                    var container = siteSettings.getModalDialog().container.find('.bcms-rightcol');
                    initializeSiteSettingsNewsletterSubscribers(container, json);
                }
            });
        };

        newsletter.loadDialogNewsletterSubscribers = function () {
            modal.edit({
                title: newsletter.globalization.subscriberDialogTitle,
                disableSaveDraft: true,
                isPreviewAvailable: false,
                disableSaveAndPublish: true,
                onLoad: function(dialog) {
                    dynamicContent.bindDialog(dialog, links.loadSiteSettingsSubscribersUrl, {
                        contentAvailable: function (dialog, json) {
                            var container = dialog.container.find('.bcms-scroll-window');
                            initializeSiteSettingsNewsletterSubscribers(container, json);
                        }
                    });
                }
            });
        };

        newsletter.init = function () {
            console.log('Initializing bcms.demonewsletter module.');
        };

        bcms.registerInit(newsletter.init);
        return newsletter;
    });

JavaScript modules descriptors are used to provide links and globalization strings that are in the server (C# side). An example JavaScript module descriptor created in the Registration folder under the module project root DemoNewsletterJsModuleIncludeDescriptor.cs:

using BetterCms.Core.Modules;
using BetterCms.Core.Modules.Projections;
using BetterCms.Module.DemoNewsletter.Content.Resources;
using BetterCms.Module.DemoNewsletter.Controllers;
namespace BetterCms.Module.DemoNewsletter.Registration
{
    public class DemoNewsletterJsModuleIncludeDescriptor : JsIncludeDescriptor
    {
        public DemoNewsletterJsModuleIncludeDescriptor(ModuleDescriptor module) : base(module, "bcms.demonewsletter")
        {
            Links = new IActionProjection[]
                {
                    new JavaScriptModuleLinkTo<SubscriberController>(this, "loadSiteSettingsSubscribersUrl", c => c.ListTemplate()),
                    new JavaScriptModuleLinkTo<SubscriberController>(this, "loadSubscribersUrl", c => c.SubscribersList(null)),
                    new JavaScriptModuleLinkTo<SubscriberController>(this, "saveSubscriberUrl", c => c.SaveSubscriber(null)),
                    new JavaScriptModuleLinkTo<SubscriberController>(this, "deleteSubscriberUrl", c => c.DeleteSubscriber(null, null)),
                };
            Globalization = new IActionProjection[]
                {
                    new JavaScriptModuleGlobalization(this, "subscriberDialogTitle", () => DemoNewsletterGlobalization.SiteSettings_NewsletterSubscribers_Title), 
                    new JavaScriptModuleGlobalization(this, "deleteSubscriberDialogTitle", () => DemoNewsletterGlobalization.DeleteSubscriber_Confirmation_Message), 
                };
        }
    }
}

Additionally, the module descriptor needs to be initialized in module descriptor. Do so by initializing in constructor and overriding RegisterJsIncludes() method. In our example cycle, add the following usages to module descriptor:

using BetterCms.Module.DemoNewsletter.Registration;
using System.Collections.Generic;

Add local variable, update constructor and override RegisterJsIncludes:

private readonly DemoNewsletterJsModuleIncludeDescriptor newsletterJsModuleIncludeDescriptor;
public DemoNewsletterDescriptor(ICmsConfiguration configuration) : base(configuration)
{
    newsletterJsModuleIncludeDescriptor = new DemoNewsletterJsModuleIncludeDescriptor(this);
}
public override IEnumerable<JsIncludeDescriptor> RegisterJsIncludes()
{
    return new[] { newsletterJsModuleIncludeDescriptor };
}

In the default CMS configuration, JavaScripts resources are taken from the CDN for better performance. However in this case, when a custom module is used, you must update the cms.config file from:

useMinifiedResources="true"
resourcesBasePath="//d3hf62uppzvupw.cloudfront.net/{bcms.version}/"

to:

useMinifiedResources="false"
resourcesBasePath="(local)"

Otherwise, JavaScript errors will be seen in the browser console - JavaScripts will be not found.

Alternatively, instead of updating cms.config, update the module descriptor with the source code below:

using BetterCms.Core.Mvc.Extensions;
[...]

private string minJsPath;
private string minCssPath;

public override string BaseModulePath
{
    get { return VirtualPath.Combine("/", "file", AreaName); }
}
public override string MinifiedJsPath
{
    get { return minJsPath ?? (minJsPath = VirtualPath.Combine(JsBasePath, string.Format("bcms.{0}.js", Name.ToLowerInvariant()))); }
}
public override string MinifiedCssPath
{
    get { return minCssPath ?? (minCssPath = VirtualPath.Combine(CssBasePath, string.Format("bcms.{0}.css", Name.ToLowerInvariant()))); }
}

Now, all the resources for default modules will be loaded from the CDN and your module resources from the local server.

How to Integrate into Site Settings

To add a module to Site Settings, update the module descriptor by overriding RegisterSiteSettingsProjections() method:

using BetterCms.Module.DemoNewsletter.Content.Resources;
using BetterCms.Core.Modules.Projections;
using BetterCms.Module.Root;
using Autofac;
[...]

public override IEnumerable<IPageActionProjection> RegisterSiteSettingsProjections(ContainerBuilder containerBuilder)
{
    return new IPageActionProjection[]
        {
            new SeparatorProjection(9999), 
            new LinkActionProjection(newsletterJsModuleIncludeDescriptor, page => "loadSiteSettingsNewsletterSubscribers")
                {
                    Order = 9999,
                    Title = page => DemoNewsletterGlobalization.SiteSettings_NewsletterSubscribersMenuItem,
                    CssClass = page => "bcms-sidebar-link",
                    AccessRole = RootModuleConstants.UserRoles.MultipleRoles(RootModuleConstants.UserRoles.Administration)
                }                                      
        };
}

How to Integrate Into Side Menu

To add a button to the site menu, update the module descriptor by overriding RegisterSidebarMainProjections() method:

using BetterCms.Module.DemoNewsletter.Content.Resources;
using BetterCms.Core.Modules.Projections;
using BetterCms.Module.Root;
using Autofac;
[...]

public override IEnumerable<IPageActionProjection> RegisterSidebarMainProjections(ContainerBuilder containerBuilder)
{
    return new IPageActionProjection[]
        {
            new SeparatorProjection(40) { CssClass = page => "bcms-sidebar-separator" }, 

            new ButtonActionProjection(newsletterJsModuleIncludeDescriptor, page => "loadDialogNewsletterSubscribers")
                {
                    Order = 900,
                    Title = page => DemoNewsletterGlobalization.SiteSettings_NewsletterSubscribersMenuItem,
                    CssClass = page => "bcms-sidemenu-btn",
                    AccessRole = RootModuleConstants.UserRoles.Administration
                },
        };
}

How to Authorize a User

To grant access for specific user roles in controller, use the "BcmsAuthorize" attribute for controller action as follows:

[BcmsAuthorize(RootModuleConstants.UserRoles.Administration)]
public ActionResult ListTemplate()
{
    var view = RenderView("List", null);
    var subscribers = GetCommand<GetSubscriberListCommand>().ExecuteCommand(new SearchableGridOptions());
    return ComboWireJson(subscribers != null, view, subscribers, JsonRequestBehavior.AllowGet);
}

If you need to ensure user access rights in the command, CommandBase has DemandAccess method that will raise SecurityException if user is not in role.

Additionally, if you need user role specific functionality in java script. Include 'bcms.security' module and:

if (!security.IsAuthorized(["BcmsEditContent", "BcmsPublishContent"])) {
    [...]
}

Currently there are 4 roles defined in BetterCms.Module.Root.UserRoles:

  • BcmsEditContent
  • BcmsPublishContent
  • BcmsDeleteContent
  • BcmsAdministration