Skip to content

Modules for Developers

brad-wechter edited this page Sep 26, 2014 · 64 revisions

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:
using BetterCms.Core.Modules;
namespace BetterCms.Module.DemoNewsletter
{
    public class DemoNewsletterDescriptor : ModuleDescriptor
    {
        internal const string ModuleName = "DemoNewsletter";

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

        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 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

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:

[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;
        }
    }
}

And now just 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;
namespace BetterCms.Module.DemoNewsletter.Controllers
{
    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. For example, List.cshtml:

@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 shoul be marked as embedded resources. This can be done in Visual Studio by selecting file properties and changing Build Action type to Embedded resource:

Embedded resources

Where to add CSS files for module?

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

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

How to add client side functionality?

Talking about java scripts - you need to add them as an embedded resources in to Scripts folder, create java script modules descriptors and register them in module descriptor. bcms.demonewsletter.js script as 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;
    });

Java script modules descriptors are used to provide links and globalization strings that are in the server (C# side). Java script module descriptor example:

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 module descriptor needs to be initialized in module descriptor. By initializing in constructor and overriding RegisterJsIncludes() method. In our example cycle - to module descriptor add usages:

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 default cms configuration java scripts resources are taken from the CDN (for better performance). But in our case, when custom module is used, please updated cms.config file from:

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

to:

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

Other wise, java script errors will be seen in the browser console - java scripts will be not found.

Alternatively - instead of updating cms.config update module descriptor with the source code bellow:

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 recourses for the default modules will be loaded from the CDN and your module resources from the local server.

How to integrate into site settings?

To add module to site settings - update module descriptor by overriding RegisterSiteSettingsProjections() method:

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 button to site menu - update module descriptor by overriding RegisterSidebarMainProjections() method:

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 user?

To grant access for specific user roles in controller, please use BcmsAuthorize attribute for controller action as follow:

[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.

Additionaly, 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

Clone this wiki locally