Module Extensibility Framework

Return to previous page

  • 4/7/2015
  • 1473 Views

Comments

1473 Views

Module Extensibility Framework

Last updated 4 years ago

Comments

Common

(Enter the content of this article below)

Advanced

 

Introduction to the framework

Module Extensibility Framework is newly added to the DNN 7.1 release, the code of which can be found under the /Community/DotNetNuke.Library/ExtensionPoints folder.

This framework will allow developers to design module features than can be extended or modified after installing a different version or edition of their module. Although MEF has been thought to work with modules, there are no impediments to use it to work with another DNN component

In other words, using MEF, modules can be extended with the installation of a different package from the original Module Package.

In practice, DNN has used MEF to enhance the new Digital Assets Management (DAM) module and also to add some special site settings related with the new Advanced URL Management (AUM) feature.

In the first case, MEF is used to add additional capabilities to the commercial version of DAM, and in the case of AUM it is used to expose additional UI and configuration capabilities. All these extra functionalities are thanks to MEF.

Under the hood, this capability of extend modules is based on the Microsoft Managed Extensibility Framework (MMEF) – more commonly known as M.E.F. – A component published by Microsoft on 2010. Currently it is in version #2 and it is included in .NET Framework 4.5.

Microsoft Managed Extensibility Framework is a library to discover and allow application extensions. This library is able to detect new components implicitly, via composition, and expose them in accessible collections.

More info about M.E.F can be found in the following links:
  • Msdn – Managed Extensibility Framework - http://msdn.microsoft.com/en-us/library/dd460648.aspx
  • CodePlex – MEF http://mef.codeplex.com/
  • MSDN Magazine - Building Composable Apps in .NET 4 with the Managed Extensibility Framework
    http://msdn.microsoft.com/en-us/magazine/ee291628.aspx

Framework Overview

The main idea is to add into the community core a new framework to allow module extensibility. To allow a module to define some functionality that can be extended (called extension point) and also to provide to the developers the appropriate tools to add these extensions.

The following are the principal components:

a) Extension Point: define a type of extension:
• IXXXExtensionPoint: These are interfaces. They define the structure (or contract) of an extension point type (i.e. IToolbarExtensionPoint, IContextMenuExtensionPoint, etc...).
• Implementations of IXXXExtensionPoint: These classes are specific implementations of an IXXXExtensionPoint interface in order to provide the appropriate functionality for the extension into the module.

b) Extension Point Manager: This component allows developers to search, find and process dynamically the defined extensions for the module.

c) Extensible Module: is a module that could be extended. To allow this behaviour that module should use Extension Controls to define which part of it extends or it should usethe Extension Point Manager to retrieve dynamically the extension from him or third party code (Module Extension).

d) Module Extension: A package that adds a set of implementation of Extension Point. When the package is installed, the Extension Point Manager will provide to the Extensible Module this set of Extensions if they match specific criteria or conventions.


Framework Components

Components- IXXXExtensionPoint

All these are interfaces (or abstract class). The main goal of that is to define the structure(or contract) that defines an extension point type.

The framework uses a base interface which it can obtain more design flexibility. It is called IExtensionPoint.
From above interface, MEF has allocated a set of Extension point interfaces:

Extension Point Interface Description Custom Properties
- IExtensionPoint The basic interface Text, Icon, Order
o IContextMenuItemExtensionPoint Menu item CtxMenuItemId, CssClass, Action, AltText, ShowText, ShowIcon
o IUserControlExtensionPoint User control (ascx) UserControlSrc, Visible
o IEditPagePanelExtensionPoint A panel into a page EditPagePanelId, CssClass
o IEditPageTabExtensionPoint A tab into a page. Includes a panel with content EditPageTabId, CssClass, Permission
- IGridColumnExtensionPoint A grid column ColumnAt, UniqueName, DataField, HeaderText, HeaderStyleWidth, ReadOnly, Reorderable, SortExpression
- IToolBarButtonExtensionPoint A button in a tool bar ButtonId, CssClass, Action, AltText, ShowText, ShowIcon
o IToolBarMenuButtonExtensionPoint A button in a tool bar with dropdown options Items, MenuCssClass
- IMenuButtonItemExtensionPoint A selectable option of a menu button ItemId, Attributes, Type, CssClass, Action
- IMenuItemExtensionPoint Menu item Value, CssClass, EnabledOnHost
- IScriptItemExtensionPoint Script reference ScriptName
Table 1: List of all extension point interfaces defined in MEF

i.e.: If there is an extension definition of ToolBarButton, all implementations of this definition should implement the interface IToolBarButtonExtensionPoint.

Some features of theses interfaces are:
- They could be independents between themselves
- They could guide the behaviour of each type of extension

Below is the example of the IScriptItemExtesionPoint Extension Point:

public interface IScriptItemExtensionPoint : IExtensionPoint

{
string ScriptName { get; }
}

Extension Metadata

As we can see, developers can create as many Extension Points as they need. Therefore, we need an answer to the question “How does the application know where it must apply one or another one?”

The answer is metadata. MEF has a set of metadata that developers must add to each Extension Point. Using these metadata, they will can filter or select what extensions they need on each place.

The above Metadata is defined in the interface IExtensionPointData and developers must add them as attributes of the implementation class. Later in this document, an example of this implementation will be shown. Below is listed the IExtensionPointData interface:

public interface IExtensionPointData

{
string Module { get; }
string Name { get; }
string Group { get; }
int Priority { get; }
}

Component – Extension Point Manager

This class allows a module to search, find and process a set of possible extensions for that module.

The Extension Point Manager depends directly on Microsoft Managed Extensibility Framework (M.E.F.), in order to provide the dynamic detection mechanism.

Basically, the Extension Point Manager loads a catalog with all solution libraries and composes the Extension Point collections based on the information allocated in that catalog. This work is done in the base constructor.

Once the constructor is finished, the Extension Point collections will be ready to use. Therefore, the public methods of this component run a query over the appropriate collection to return the expected extension points.

Below is an example of a basic implementation of some Extension Point Managerpublic methods. In this example the methods retrieve a set of Extension Point Buttons using method parameters as filters to delimit the research for the Extension Points into a certain subset of implementations. The query uses the Extension Points metadata to match the filters. The Extension Point Manager has the responsibility to link the implementation with its defined metadata:

[ImportMany]

private IEnumerable<Lazy<IToolBarButtonExtensionPoint, IExtensionPointData>> _toolbarButtonExtensionPoints;

public IEnumerable<IToolBarButtonExtensionPoint> GetToolBarButtonExtensionPoints(string module)
{
return _toolbarButtonExtensionPoints.Where(e => e.Metadata.Module == module).OrderBy(e => e.Value.Order).Select(e => e.Value);
}

public IEnumerable<IToolBarButtonExtensionPoint> GetToolBarButtonExtensionPoints(string module, string group)
{
if (string.IsNullOrEmpty(group))
{
return GetToolBarButtonExtensionPoints(module);
}

return _toolbarButtonExtensionPoints.Where(e => e.Metadata.Module == module && e.Metadata.Group == @group).OrderBy(e => e.Value.Order).Select(e => e.Value);
}

The diagram below shows the current dependencies between M.E.F. and the Extension Point Manager, IXXXExtensionPoint and XXXExtensionPoint:



The Extension Point Manager is a wrapper to M.E.F. In this way we can change in a future the dynamic mechanism.

The Extension Point Manager depends on IXXXExtensionPoint in this first proposal but this dependency could be optional.

The implementation of the IXXXExtensionPoint depends on the Extension Point Manager component because it should be marked as exportable with a specific Attribute. Mark an Extension Point Implementation as exportable is a M.E.F. behavior but developers can wrap it. The example below shows the wrapper at the Export attribute of M.E.F.:

[MetadataAttribute]

public class ExportClientScriptAttribute : ExportAttribute
{
public ExportClientScriptAttribute()
: base(typeof(IClientScript))
{

}
}

Component – Implementations of an ExtensionPoint Interface

There will be a set of classes that will implement the above interface/s.
These classes will be specific implementations of the ExtensionPoint interfaces in order to provide the appropriate functionality into the module and will be added by the module developer or module extension developer.

The example of the implementation of the IUserControlExtensionPoint described in a previous point is below. This example is a real code introduced in the DAM module:

namespace DotNetNuke.Modules.DigitalAssets.Components.ExtensionPoint.UserControls

{
[Export(typeof(IUserControlExtensionPoint))]
[ExportMetadata("Module", "DigitalAssets")]
[ExportMetadata("Name", "FileFieldsControlExtensionPoint")]
[ExportMetadata("Group", "ViewProperties")]
[ExportMetadata("Priority", 2)]
publicclassFileFieldsControlExtensionPoint : IUserControlExtensionPoint
{
publicstring UserControlSrc
{
get { return"~/DesktopModules/DigitalAssets/FileFieldsControl.ascx"; }
}

publicstring Text
{
get { return""; }
}

publicstring Icon
{
get { return""; }
}

publicint Order
{
get { return 1; }
}

publicbool Visible
{
get { returntrue; }
}
}
}

I’d like to highlight the following point related with the above example:
1. Every Extension Point implementation must have the Export attribute, indication in its parameter the type of the Extension Point interface that it implements. Inthe above case, the interface is IUserControlExtensionPoint
Export(typeof(IUserControlExtensionPoint))

2. Every Extension Point implementation must have the four metadata attributes. They are needed to both allow Extension Point Manager composes the Extension Point collections and also to provide the filter values for the Extension Point Manager methods. In the above example, the metadata value are:
a. Module = DigitalAssets
b. Name = FileFieldsControlExtensionPoint
c. Group = ViewProperties
d. Priority = 2
    [ExportMetadata("Module", "DigitalAssets")]

[ExportMetadata("Name", "FileFieldsControlExtensionPoint")]
[ExportMetadata("Group", "ViewProperties")]
[ExportMetadata("Priority", 2)]

Component – Extensible Module

The extensible module does not have any particular characteristic. However, it should have a dependency to the ExtensionPointManager in order to allow it could be extended.

Also, the Module Extensibility Framework provides some Server Controls to allow developers to define how to use the extension points in order to set where and how the module can be extended.

In the following example we can see how the ExtensionPoinManager is used by the module in the OnLoad method of an aspx.cs class to register the Script Extensions. Specifically, this code has been extracted from DAM Module and its View.ascx.cs file:

private readonly ExtensionPointManager epm = newExtensionPointManager();


protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);

int i = 1;
foreach (var script in epm.GetScriptItemExtensionPoints("DigitalAssets"))
{
ClientResourceManager.RegisterScript(Page, script.ScriptName, FileOrder.Js.DefaultPriority + i++);
}

}

Viewing the example, we can find how the module passes the “DigitalAssets” value to the Extension Point Manager method, to obtain all Script Extensions defined in the application for the DigitalAssets module. It doesn’t need to user the Name or Group metadata values.

Also, we can see that internally, the OnLoad method uses the “ScriptName” extension property to accomplish with its logic, and registering the scripts

Optionally, the extensible module could have some extensions implementations. It depends on the logic and purpose of the module.
Server Controls
We can approach a server control to indicate where the module is going to be extended and what type of extension it will have at that place.

The Module Extensibility Framework provides a set of Server control to help developers to work with extension points. Typically, every extension point of the same type is managed in the same way. Therefore, Server Controls have got embedded this way or logic and can be used directly to manage the desired extension points and reuse that logic.

All Server Controls have a reference to the Extension Point Manager and wrap the access and work with Extension Points. If the extensible module can work using Server Controls, then it will not need a direct reference to the Extension Point Manager, only references to the Server Controls will be needed.

Below is shown an example of how to use a Server Control:
<dnnext:EditPageTabExtensionControlrunat="server"Module="DigitalAssets"Group="LeftPaneTabs"TabControlId="LeftPaneTabsControl"PanelControlId="LeftPaneContents"></dnnext:EditPageTabExtensionControl>

The above example is added directly to the View.ascx file of DAM. That Server control indicates where the left pane tabs are going to be added. Later in this document that mechanism will be described


The Server Controls inherits from a base Server Control. Therefore, developers can create their own Server Control to add that logic to a module and reuse it

Finaly, the existing Server Controls are the following:
• DefaultExtensionControl. This is the base Server Control
• EditPagePanelExtensionControl
• EditPageTabExtensionControl
• ToolBarButtonExtensionControl
• UserControlExtensionControl
Component – Module Extension

A module extension will be a package and will be installed as a package.

Maybe, it could be a package composed by a library extension. Is necessary to ensure that it will not be available to be added to page – only the extensible module is available to be added.

A module extensions will contain the definition of one or more extensions (implementation of some Extension Point interfaces) and files (view, web controls, JavaScript, resource file, etc..) and assemblies (business logic controller, Web API, components, etc…) needed to let this extensions work.

Optionally, the Module Extension could depend on the Extensible Module in order to reuse or extend some Business Logic Code.It depends on the logic and purpose of the module.
¬
For example, for DAM, the module extension is the project: /Professional/Modules/DotNetNuke.Professional.DigitalAssets although it is under Modules folder, that package is a library extension.

How it works
In this point we are going to show how has been used the Module Extensibility Framework in real scenarios

Extensible Buttons ToolBar

In DAM, we have a tool bar that shows allowed actions for selected items. This toolbar contain some actions (buttons) when the edition is CE and ones more when the edition is PE/XE. Therefore, DAM uses MEF to create this tool bar. The following are the steps:
1. Add on the View.ascx files the ToolBar Server Control in the place where the tool bar is going to be show:


2. Create an implementation of IToolBarButtonExtensionPoint for each button shown in the tool bar.
a. This the implementation of the Rename action
namespace DotNetNuke.Modules.DigitalAssets.Components.ExtensionPoint.ToolBarButton

{
[Export(typeof(IToolBarButtonExtensionPoint))]
[ExportMetadata("Module", "DigitalAssets")]
[ExportMetadata("Name", "DigitalAssetsRenameToolBarButton")]
[ExportMetadata("Group", "Selection")]
[ExportMetadata("Priority", 1)]
public class RenameToolBarButtonExtensionPoint : IToolBarButtonExtensionPoint
{
publicstring ButtonId
{
get { return"DigitalAssetsRenameBtnId"; }
}

publicstring CssClass
{
get { return"DigitalAssetsSelectionToolBar DigitalAssetsRename permission_MANAGE singleItem"; }
}

publicstring Action
{
get { return"dnnModule.digitalAssets.rename()"; }
}

publicstring AltText
{
get { returnLocalizationHelper.GetString("RenameToolBarButtonExtensionPoint.AltText"); }
}

publicbool ShowText
{
get { returnfalse; }
}

publicbool ShowIcon
{
get { returntrue; }
}

publicstring Text
{
get { returnLocalizationHelper.GetString("RenameToolBarButtonExtensionPoint.Text"); }
}

publicstring Icon
{
get { returnIconController.IconURL("FileRename", "16x16", "Gray"); }
}

publicint Order
{
get { return 2; }
}
}
}

The SelectionToolBar control will obtain all extensions in the application that have the metadata Module = “DigitalAssets” and Group=”Selection”, like the above example.

The final result is the following:

The highlighted red rectangle contains the Selection Tool Bar

This tool bar is prepared to be extended, because PE features are scheduled to be added in close releases
Extensible Page Tabs

The main view of DAM contains a left pane where the Folders TreeView is shown. In PE solutions this pane also shows the Tags list navigation. To select an option or another, the user clicks on a tab. Therefore, this left pane tabs are created via MEF.

The following are the steps:
1. Following the common DNN way to add tabs on a page, we need to add an element where the tabs will be shown and another element where the tab contents will be centralized. Then the page invokes the dnnTabs() plugin of JavaScript to render and add the tabs behaviour.
These two elements must run at server. In our case they are:
Tabs element
<ulclass="dnnAdminTabNav dnnModuleDigitalAssetsTabNav buttonGroup"runat="server"id="LeftPaneTabsControl">

<liid="dnnModuleDigitalAssetsLeftPaneFilesTab">
<asp:HyperLinkhref="#dnnModuleDigitalAssetsLeftPaneFilesTabContent"runat="server"id="LeftPaneTabsFilesLink"resourcekey="LeftPaneFilesTab.Text"/>
</li>
</ul>

Contents element
<asp:Panelrunat="server"ID="LeftPaneContents"CssClass="dnnModuleDigitalAssetsLeftPaneContents">

<divclass="dnnModuleDigitalAssetsFilesView"id="dnnModuleDigitalAssetsLeftPaneFilesTabContent">

</div>
</asp:Panel>

2. AnEditPageTabExtensionControl control is added to the View.ascx file. It is configured with the appropriate filter values and control references
<dnnext:EditPageTabExtensionControlrunat="server"Module="DigitalAssets"Group="LeftPaneTabs"TabControlId="LeftPaneTabsControl"PanelControlId="LeftPaneContents"></dnnext:EditPageTabExtensionControl>

The filters to obtain the appropriate extensions are Module = “DigitalAssets” and Group = “LeftPaneTabls” and the control references in order to know where is the tabs rendered are TabControlId = “LeftPanelTabsControl” (where the tabs are painted) and PanelControlId = “LeftPaneContents” (where the contents are centralized)
3. Create a .ascx user control to render the content of the tab. In this case the Tags content is defined with the user control: /Professional/Modules/DotNetNuke.Professional.DigitalAssets/TagsTabContentControl.ascx
4. Create the IEditPageTabExtensionPoint implementation. In this case, the implementation is the file /Professional/Modules/DotNetNuke.Professional.DigitalAssets/Components/ExtensionPoint/LeftPaneTabs/LeftPaneTagsTabExtensionPoint.cs
namespace DotNetNuke.Professional.DigitalAssets.Components.ExtensionPoint.LeftPaneTabs

{
[Export(typeof(IEditPageTabExtensionPoint))]
[ExportMetadata("Module", "DigitalAssets")]
[ExportMetadata("Name", "DigitalAssetsTagsTabExtensionPoint")]
[ExportMetadata("Group", "LeftPaneTabs")]
[ExportMetadata("Priority", 1)]
publicclassLeftPaneTagsTabExtensionPoint : IEditPageTabExtensionPoint
{
publicstring EditPageTabId
{
get { return"dnnModuleDigitalAssetsLeftPaneTagsTab"; }
}

publicstring CssClass
{
get { return""; }
}

publicstring UserControlSrc
{
get { return"~/DesktopModules/DigitalAssets/TagsTabContentControl.ascx"; }
}

publicstring Text
{
get { returnLocalizationHelper.GetString("TagsTabName.Text"); }
}

publicstring Icon
{
get { return""; }
}

publicint Order
{
get { return 1; }
}

publicstring Permission
{
get
{
returnstring.Empty;
}
}

publicbool Visible
{
get { returntrue; }
}
}
}

The EditPageTabExtensionControl read the above implementation thanks to the filters Module = “DigitalAssets” and Group = “LeftPaneTabls” are equal to the implementation metadataand uses the implementation data to add the Tags tab on the LeftPaneTabsControl and also to add and load the TabsTabContentControl on the LeftPaneContent panel

The result of this extension point is:

Register extension scripts

To add script to a page is typical action when we want to extend a module. This action hasn’t got its corresponding server control and we need to use the Extension Point Manager to register the extension scripts. The following are the steps:
1. Create the JavaScript file in the extension project. In our case the file is Professional/Modules/DigitalAssets/ClientScripts/dnn.DigitalAssetsControllerPro.js
2. In the control where we are going to need the script, we need to register the file. In our case, the View.ascx is the place where we need the JavaScript. In the OnInit method the Extension Point Manager is used to load all scripts and register them
protectedoverridevoid OnInit(EventArgs e)

{
try
{
base.OnInit(e);


ClientResourceManager.RegisterScript(Page, "~/DesktopModules/DigitalAssets/ClientScripts/dnn.DigitalAssetsController.js", FileOrder.Js.DefaultPriority);

int i = 1;
foreach (var script in epm.GetScriptItemExtensionPoints("DigitalAssets"))
{
ClientResourceManager.RegisterScript(Page, script.ScriptName, FileOrder.Js.DefaultPriority + i++);
}

}
catch (Exception exc) //Module failed to load
{
Exceptions.ProcessModuleLoadException(this, exc);
}
}

Note that the method also registers another scripts it knows, but the extension scripts are unknown and it needs MEF to register them. It uses the Extension Point Manager (epm object) to read all scripts defined for “DigitalAssets” module

Toggle a UserControl

Another typical action is to use one User Control in CE solutions and replace that control by another different when the solution is upgraded to a commercial edition.

MEF has a special mechanism to allow this action. The Extension Point Manager has methods like GetXXXFirstByPriority. This kind of methods always returns only one extension, and it is depending on the priority metadata.

The File Properties dialog uses this mechanism to show the file fields in the General Tab. The following are the steps:
1. Create a User Control to render the File Fields in the CE solutions. In our case, this control is Community/Modules/DigitalAssets/FileFieldsControl.ascx
2. Create an implementation of an IUserControlExtensionPoint referencing the above control. This implementation is the file: Community/Modules/DigitalAssets/Components/ExtensionPoint/UserControls/FileFieldsControlExtensionPoint.cs
namespace DotNetNuke.Modules.DigitalAssets.Components.ExtensionPoint.UserControls

{
[Export(typeof(IUserControlExtensionPoint))]
[ExportMetadata("Module", "DigitalAssets")]
[ExportMetadata("Name", "FileFieldsControlExtensionPoint")]
[ExportMetadata("Group", "ViewProperties")]
[ExportMetadata("Priority", 2)]
publicclassFileFieldsControlExtensionPoint : IUserControlExtensionPoint
{
publicstring UserControlSrc
{
get { return"~/DesktopModules/DigitalAssets/FileFieldsControl.ascx"; }
}

publicstring Text
{
get { return""; }
}

publicstring Icon
{
get { return""; }
}

publicint Order
{
get { return 1; }
}

publicbool Visible
{
get { returntrue; }
}
}
}

3. Create another User Control to render the File Fields on commercial editions. In our case, this control is Professional/Modules/DigitalAssets/FileFieldsControlPro.ascx
4. Create the appropriate implementations to reference the above control. This implementation is Professional/Modules/DigitalAssets/Components/ExtensionPoint/UserControls/FileFieldsControlExtensionPoint.cs
namespace DotNetNuke.Professional.DigitalAssets.Components.ExtensionPoint.UserControls

{
[Export(typeof(IUserControlExtensionPoint))]
[ExportMetadata("Module", "DigitalAssets")]
[ExportMetadata("Name", "FileFieldsControlExtensionPoint")]
[ExportMetadata("Group", "ViewProperties")]
[ExportMetadata("Priority", 1)]
publicclassFileFieldsControlExtensionPoint: IUserControlExtensionPoint
{
publicstring UserControlSrc
{
get { return"~/DesktopModules/DigitalAssets/FileFieldsControlPro.ascx"; }
}

publicstring Text
{
get { return""; }
}

publicstring Icon
{
get { return""; }
}

publicint Order
{
get { return 1; }
}

publicbool Visible
{
get { returntrue; }
}
}
}

5. In this case, we cannot approach the standard UserControlExtensionControl because we need a more specific logic to prepare the User Control. Then we have used directly the Enterprise Point Manager to load the appropriate User Control
protectedoverridevoid OnInit(EventArgs e)

{

var fileFieldsExtension = mef.GetUserControlExtensionPointFirstByPriority("DigitalAssets", "FileFieldsControlExtensionPoint");
fileFieldsControl = Page.LoadControl(fileFieldsExtension.UserControlSrc);
fileFieldsControl.ID = fileFieldsControl.GetType().BaseType.Name;
FileFieldsContainer.Controls.Add(fileFieldsControl);


PrepareFileFieldsControl();

}

The above code is extracted from the method OnInit of the file Community/Modules/DigitalAssets/FileProperties.ascx.cs.
6. In particular, in this class we have had to configure the control and propagate some data. These actions are specifics for this control regardless of it is the CE or PE control. They are in the method PrepareFileFieldsControl()
privatevoid PrepareFileFieldsControl()

{
var fileFieldsIntance = (FileFieldsControl)fileFieldsControl;
fileFieldsIntance.SetController(controller);
fileFieldsIntance.SetItemViewModel(fileItem);
fileFieldsIntance.SetFileInfo(file);
}

We can highlight that the FileProperties class executes the method GetUserControlExtensionPointFirstByPriority() of the Extension Point Manager to obtain the appropriate User Control and also, we can see that both implementations, CE and PE, have a Priority metadata but with different value. The PE implementation has a more important priority

Therefore, when the CE solution is the only one installed, the GetUserControlExtensionPointFirstByPriority() method returns a reference to the CE implementation. But when PE is installed, the above method finds both implementations CE and PE, because they match with the filter values, then it orders them by priority and returns the first one. This is the one with the lowest number: the PE implementation.

The result, when only CE is installed is:

The highlighted red rectangle contains the CE User Control

The result in the PE is:

The highlighted red rectangle contains the PE User Control
Contents
No sections defined

DNN Digest is our monthly email newsletter. It highlights news and content from around the DNN ecosystem, such as new modules and themes, messages from leadership, blog posts and notable tweets. Keep your finger on the pulse of the ecosystem by subscribing.  


Copyright 2017 by DNN Corp Terms of Use Privacy
What is Liquid Content?
Find Out
What is Liquid Content?
Find Out
What is Liquid Content?
Find Out