Series:
1. Securing React App with Azure AD
2. Setting Up Azure Key Vault with an Azure Website (Web API)
3. Leveraging Office Pnp Core to run multi tenant specific operations (Create modern site, etc)
4. Creating communication sites templates and applying them to new sites in different tenants. *this post
This is probably the most exciting post of all series, because it took me a few days before I finally got it working.
The entire idea of the blog series, its to be able to create modern team sites or communication sites, based on pages that already exist in another tenant, yeah, cool? isnt it. So basically in our web application, we register a tenant, then we take a list of the sites, and we can save in our CosmosDB the template of a page, so we can save 100 page templates, and then at a later stage, our customers will be able to create a communication site or modern team site, and select the pages they want to be provisioned on that new site, amazing isnt it? Keep in mind that this is work in progress, so it might not be perfect, but it works 100% with all the Out of the box SPFX webparts out there.
My next step, is to make this future proof, so that it works also with Custom Webparts, but thats going to be a little more difficult, because if there is a custom webpart in one tenant, then I need to deploy it in another tenant in order to get the template working fine wherever I need it.
Entities
In order to save a page template, we have to represent a Page with sections, columns and webparts, and then save that information into our CosmosDB database, in order to do that, I created the following set of entities:
[SharedCosmosCollection("shared")]
public class PageTemplate : ISharedCosmosEntity
{
[JsonProperty("Id")]
public string Id { get; set; }
[CosmosPartitionKey]
public string CosmosEntityName { get; set; }
public string Name { get; set; }
public string SiteType { get; set; }
public List<Section> Sections { get; set; }
public List<string> Tags { get; set; }
}
public class Section
{
public string Name { get; set; }
public float Order { get; set; }
public List<Column> Columns { get; set; }
}
public class Column
{
public string ColumnFactor { get; set; }
public float Order { get; set; }
public List<Control> Controls { get; set; }
}
public class Control
{
public int ControlType { get; set; }
public string CanvasControlData { get; set; }
public string DataVersion { get; set; }
public Guid InstanceId { get; set; }
public string JsonControlData { get; set; }
public string JsonWebPartData { get; set; }
public JObject Properties{ get; set; }
public string PropertiesJson { get; set; }
public JObject ServerProcessedContent { get; set; }
public int Order { get; set; }
public string Type { get; set; }
public string clientSideText { get; set; }
public string clientSideWebPart { get; set; }
public string Description { get; set; }
public string HtmlProperties { get; set; }
public string HtmlPropertiesData { get; set; }
public string Title { get; set; }
public string WebPartData { get; set; }
public string WebPartId { get; set; }
public string PreviewText { get; set; }
public string Text { get; set; }
public string Rte { get; set; }
}
The code is self explanatory, SiteType can either be communication site or modern team sites, I have not tested yet if a template done in a modern team site will work on a communication site and vice-versa.
The section has a list of columns and each column has a list of webparts or Controls.
The control class has a lot of properties, that I took from the existing control class from Office PnP Core.
At this point you might wonder, why didnt I use the existing Sections, Columns, Page classes, well the reason is very simple, on those classes a Page has Sections, but each Section also points to a Page, a section has Columns but each column points to a section, and the same happens with Webparts, the problem with this, is that when saving this to CosmosDB you get Json Circular Reference Exceptions, I know you can overcome this by ignoring the circular reference when serializing to json, but I didnt like it, and I felt it was a dirty approach, so I decided to do it my own way.
Extract page templates
From my user interface I will be able to extract a page template based on the class outlined above, and then save that information into CosmosDB, basically the user selects a SiteCollection, then selects a page from that SiteCollection, and then my business logic will save it correctly into the database. Please note that in the Page level I added an array of Tags, why? because the idea is that later I can search my template collection based on tags, like HR, Finance, Short Page, Long Page, Marketing, Dashboard, etc,etc, this will save time for our users to search for existing templates.
Below the controller method to extract a page template
public class PageTemplateCreationModel
{
[JsonProperty("Id")]
public string Id { get; set; }
public string SiteCollectionUrl { get; set; }
public string PageName{ get; set; }
public string Description { get; set; }
public List<string> Tags { get; set; }
//...
}
[HttpPost]
[Route("CreatePageTemplate")]
public async Task<IHttpActionResult> CreatePageTemplate([FromBody]PageTemplateCreationModel model)
{
if (ModelState.IsValid)
{
var tenant = await TenantHelper.GetActiveTenant();
var siteCollectionStore = CosmosStoreHolder.Instance.CosmosStoreSiteCollection;
await siteCollectionStore.RemoveAsync(x => x.Title != string.Empty); // Removes all the entities that match the criteria
string domainUrl = tenant.TestSiteCollectionUrl;
string tenantName = domainUrl.Split('.')[0];
string tenantAdminUrl = tenantName + "-admin.sharepoint.com";
KeyVaultHelper keyVaultHelper = new KeyVaultHelper();
await keyVaultHelper.OnGetAsync(tenant.SecretIdentifier);
using (var context = new OfficeDevPnP.Core.AuthenticationManager().GetSharePointOnlineAuthenticatedContextTenant(model.SiteCollectionUrl, tenant.Email, keyVaultHelper.SecretValue))
{
try
{
var pageTemplateStore = CosmosStoreHolder.Instance.CosmosStorePageTemplate;
var page = OfficeDevPnP.Core.Pages.ClientSidePage.Load(context, model.PageName);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
Web web = context.Web;
context.Load(web);
context.ExecuteQuery();
string name = context.Web.WebTemplate;
string Id = name + "#" + context.Web.Configuration.ToString();
string strTemplate = string.Empty;
if (Id.Contains("SITEPAGEPUBLISHING#0"))
{
strTemplate = "CommunicationSite";
};
if (Id.Contains("GROUP#0"))
{
strTemplate = "Modern Team Site";
};
PageTemplate pageTemplate = new PageTemplate();
pageTemplate.Name = model.Description;
pageTemplate.SiteType = strTemplate;
pageTemplate.Tags = model.Tags;
pageTemplate.Sections = new List<Section>();
//Lets go through each section
foreach (var section in page.Sections)
{
//Lets create our own section object, as I cant serialize an object because it has circular references,
//so this method its actally easier
var pageSection = new Section()
{
Order = section.Order,
Name = section.Type.ToString()
};
pageSection.Columns = new List<Column>();
//After instantiating each pagesection, lets go through each column on the existing sections
foreach (var column in section.Columns)
{
///Lets instatiate our own section object
var sectionColummn = new Column()
{
ColumnFactor = column.ColumnFactor.ToString(),
};
sectionColummn.Controls = new List<Control>();
//Then lets go into each control for each column
foreach (var control in column.Controls)
{
//Lets create a control object of our own
var columnControl = new Control()
{
Type = control.Type.ToString()
};
if (control.Type == typeof(ClientSideWebPart))
{
ClientSideWebPart cpWP = control as ClientSideWebPart;
columnControl.ControlType = cpWP.ControlType;
columnControl.DataVersion = cpWP.DataVersion;
columnControl.Description = cpWP.Description;
columnControl.HtmlProperties = cpWP.HtmlProperties;
columnControl.HtmlPropertiesData = cpWP.HtmlPropertiesData;
columnControl.InstanceId = cpWP.InstanceId;
columnControl.JsonControlData = cpWP.JsonControlData;
columnControl.JsonWebPartData = cpWP.JsonWebPartData;
columnControl.Order = cpWP.Order;
columnControl.Properties = cpWP.Properties;
columnControl.PropertiesJson = cpWP.PropertiesJson;
columnControl.ServerProcessedContent = cpWP.ServerProcessedContent;
columnControl.Title = cpWP.Title;
columnControl.WebPartData = cpWP.WebPartData;
columnControl.WebPartId = cpWP.WebPartId;
}
else if (control.Type == typeof(ClientSideText))
{
ClientSideText cpWP = control as ClientSideText;
columnControl.PreviewText = cpWP.PreviewText;
columnControl.Text = cpWP.Text;
columnControl.Rte = cpWP.Rte;
columnControl.ControlType = cpWP.ControlType;
columnControl.DataVersion = cpWP.DataVersion;
columnControl.InstanceId = cpWP.InstanceId;
columnControl.JsonControlData = cpWP.JsonControlData;
columnControl.Order = cpWP.Order;
}
//Then we add each control to its corresponding section
sectionColummn.Controls.Add(columnControl);
}
//Then we add column to each section
pageSection.Columns.Add(sectionColummn);
}
//Then we add each section into the page
pageTemplate.Sections.Add(pageSection);
}
var added = await pageTemplateStore.AddAsync(pageTemplate);
return StatusCode(HttpStatusCode.NoContent);
}
catch (System.Exception ex)
{
throw ex;
}
}
}
As you can see, I manually iterate over sections, over columns on each section, and over controls on each column, and then with my mentioned entities above, I save all this information into a template entity on my CosmosDB, cool? isnt it?
Create a communication site with page templates
public class CommunicationSite
{
[Required]
public string Title { get; set; }
[Required]
public string Url { get; set; }
public string Description { get; set; }
public string Owner { get; set; }
//public bool AllowFileSharingForGuestUsers { get; set; }
public uint Lcid { get; set; }
public string Classification { get; set; }
public string SiteDesign { get; set; }
public List<string> PageTemplateIds { get; set; }
//...
}
[HttpPost]
[Route("api/SiteCollection/CreateCommunicationSite")]
public async Task<IHttpActionResult> CreateCommunicationSite([FromBody]CommunicationSite model)
{
if (ModelState.IsValid)
{
var tenant = await TenantHelper.GetActiveTenant();
var siteCollectionStore = CosmosStoreHolder.Instance.CosmosStoreSiteCollection;
await siteCollectionStore.RemoveAsync(x => x.Title != string.Empty); // Removes all the entities that match the criteria
string domainUrl = tenant.TestSiteCollectionUrl;
string tenantName = domainUrl.Split('.')[0];
string tenantAdminUrl = tenantName + "-admin.sharepoint.com";
KeyVaultHelper keyVaultHelper = new KeyVaultHelper();
await keyVaultHelper.OnGetAsync(tenant.SecretIdentifier);
using (var context = new OfficeDevPnP.Core.AuthenticationManager().GetSharePointOnlineAuthenticatedContextTenant(tenant.TestSiteCollectionUrl, tenant.Email, keyVaultHelper.SecretValue))
{
try
{
CommunicationSiteCollectionCreationInformation communicationSiteInfo = new CommunicationSiteCollectionCreationInformation
{
Title = model.Title,
Url = model.Url,
SiteDesign = EnumHelper.ParseEnum<CommunicationSiteDesign>(model.SiteDesign),
Description = model.Description,
Owner = model.Owner,
AllowFileSharingForGuestUsers = false,
// Classification = model.Classification,
Lcid = model.Lcid
};
var createCommSite = await context.CreateSiteAsync(communicationSiteInfo);
var pageTemplateStore = CosmosStoreHolder.Instance.CosmosStorePageTemplate;
//Create one page for each associated template id
foreach (string id in model.PageTemplateIds)
{
//Get the page template from CosmosDB
var pageTemplate = await pageTemplateStore.FindAsync(id, "pagetemplates");
//Create a client side page with the name from the template
var page = createCommSite.Web.AddClientSidePage(pageTemplate.Name + ".aspx", true);
//Create the sections first
foreach (var section in pageTemplate.Sections)
{
//Create a section template enum type to be able to create the CanvasSection object
CanvasSectionTemplate canvasSectionTemplate = EnumHelper.ParseEnum<CanvasSectionTemplate>(section.Name);
//Create a canvas section object based on section template and order we have in CosmosDB
// Then add the section to the page and save the page.
CanvasSection sec = new CanvasSection(page, canvasSectionTemplate, section.Order);
page.AddSection(sec);
page.Save();
//Lets go through each column from the added section
foreach (var column in section.Columns)
{
//Lets find the canvas column based on the order
CanvasColumn canvasColumn = sec.Columns[Convert.ToInt32(column.Order)];
foreach (var control in column.Controls)
{
var webPart = page.InstantiateDefaultWebPart(ClientSidePage.NameToClientSideWebPartEnum(control.WebPartId));
webPart.PropertiesJson = control.PropertiesJson;
//Lets add the webpart to the speficic column, the column has a reference to the section
page.AddControl(webPart, canvasColumn);
page.Save();
}
page.Save();
}
page.Save();
}
page.Save();
page.Publish();
}
return Ok();
}
catch (System.Exception ex)
{
throw ex;
}
}
}
return BadRequest(ModelState);
}
Here I get a list of page templates via a List from the front end, then I iterate over each page and basically I do reverse engineer to what I did in the previous step, I iterate over each Section, each column each control in the corresponding order, and I add them to the page, and at the end I publish the page.
And the best thing it works perfectly fine, looking forward to demo this some time 🙂
This is the last part of the series, but I will keep creating posts related to this with other operations.
On this post, I didnt add the Front End, at the end if you use the backend I created and that I will publish soon, then you will need to create your own UI either in react, angular or whatever you want to chose.
About the Author:
Luis Valencia, CTO at Software Estrategico, Medellin, Colombia, independent blogger and still a coder, after 17 years of experience in the field and regardless of my position, and mostly with SharePoint/Office Products, I still love to code, open Visual Studio and bring solutions to users and to the community its what makes me wake up every morning.
Feel free to contact me via twitter direct messages, @levalencia
Reference:
Valencia, L. (2019). Leveraging Office PnP Core to Create Communication Sites with Saved Page Templates. Available at: http://www.luisevalencia.com/2019/03/03/leveraging-office-pnp-core-to-create-communication-sites-with-saved-page-templates/ [Accessed 15th April 2019]