Leveraging Office PnP Core to Create Communication Sites with Saved Page Templates.

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 havent tested yet if a template done in a modern team site will work on a communication site and viceversa.

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

In the interface, when creating a communication site, the user will be able to select 1 or many page templates, then save all the standard communication site information, like Title, description, etc. And then from the backend it will create a communication sites with all pages selected, with all sections, columns and webparts, just as they were in the template, this is pretty amazing, I cant stress enough how much time our customers will save with this.

 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.