Register Templates folder to support custom page templates

Office 365 provides nice and fast experience to create new pages. It allows the end user to pick template, based on which the page is going to be created. The structure, content type and assigned metadata are going to be copied from the base page. By default, SP offers three predefined templates:

  • Blank
  • Visual
  • Basic text

This is already something, but not really useful for content editors. Naturally, every company will have its own templates with custom pictures, structure and default metadata. To cope with that scenario, SharePoint allows to create own templates based on the existing page. So far so good but the real problem is that the template is available only on the single site collection. What it it needs to be used over several sites? Doing it manually doesn’t make any sense.

PnP Templates for the rescue

The easiest way to handle the task would be to create PnP provisioning template that contains the required pages and apply it to all the sites where the templates need to be available. The problem is that after doing that, the pages are still not visible in the panel.

Register folder to be used as templates source

To complete the task, there is still one thing to do. We need to tell SharePoint to use the folder where the pages are created, as a templates source. By default, SharePoint creates and registers ‘Templates’ folder when the first template is created from UI.

Below, you can find the script that creates ‘Templates’ folder and sets it as a templates folder. To do it, ‘vti_TemplatesFolderGuid’ with UniqueId of the selected folder property needs to be assigned to root folder of Site Pages library:

$templatesFolder = Resolve-PnPFolder -SiteRelativePath "SitePages/Templates"
Set-PnPPropertyBagValue -Folder "SitePages" -Key "vti_TemplatesFolderGuid" -Value $templatesFolder.UniqueId

Thanks to that, your custom templates will be automatically visible in the page creation pane.

Organisation Assets Library REST API

SharePoint Online provides a functionality called ‘Organisational Assets Library’ that based on the documentation allows the organisation to store assets like images or photos in easily accessible place. There is a possibility to configure one or more Document Libraries to play a role of the ‘Organisational Assets Library’. The only restriction is that all the libraries need to exist in the same site.

You can manage your organisational assets libraries using Powershell or CSOM – more information in the docs .

Note: to be able to work with Organisational Assets Libraries, first it’s required to enable Tenant CDN.

REST API

However, recently I had requirement to obtain the information about those from SPFx WebPart, so my first steps where to find out how to get those information with REST calls.

To work with the web service, these endpoints need to be used:

    • /_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/SetOrgAssetsLib
    • /_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/GetOrgAssets
    • /_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/AddToOrgAssetsLibAndCdn
    • /_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/RemoveFromOrgAssets

Based on this information I have created a OrgAssets service class that helps work with the endpoints:

import { WebPartContext } from "@microsoft/sp-webpart-base";
import { SPHttpClient } from "@microsoft/sp-http";
import { OrgAssetsData, CdnType, OrgAssetType, AddOrgAssetCdnRequest, ResourcePath } from "./OrgAssetsService.types";
import { Guid } from "@microsoft/sp-core-library";
export class OrgAssetsService {
private context: WebPartContext;
constructor(context: WebPartContext) {
this.context = context;
}
/**
* Gets information about available Organisation Assets Library
*/
public async getOrgAssets(): Promise<OrgAssetsData> {
try {
const apiUrl = `${this.context.pageContext.web.absoluteUrl}/_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/GetOrgAssets`;
const orgAssetsDataResponse = await this.context.spHttpClient.get(apiUrl, SPHttpClient.configurations.v1);
if (!orgAssetsDataResponse || !orgAssetsDataResponse.ok) {
throw new Error(`Something went wrong when obtaining OrgAssets. Status='${orgAssetsDataResponse.statusText}'`);
}
const orgAssetsData : OrgAssetsData = await orgAssetsDataResponse.json();
return orgAssetsData;
} catch (err) {
console.error(`[OrgAssetsService.getOrgAssets]: Err='${err.message}'`);
return null;
}
}
/**
* Adds new Organisation Assets Library
* @param libUrl Absolute List URL
* @param cdnType CDN Type
* @param orgAssetType Organisation Assets Library Type
* @param thumbnailUrl Absolute Thumbnail URL
*/
public async addOrgAssetsLib(libUrl: string, cdnType: CdnType, orgAssetType: OrgAssetType = OrgAssetType.ImageDocumentLibrary, thumbnailUrl?: string): Promise<boolean> {
try {
const apiUrl = `${this.context.pageContext.web.absoluteUrl}/_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/AddToOrgAssetsLibAndCdn`;
const thumbnailResPath: ResourcePath = thumbnailUrl ? {
DecodedUrl: thumbnailUrl
} : null;
const reqData: AddOrgAssetCdnRequest = {
cdnType,
libUrl: {
DecodedUrl: libUrl
},
orgAssetType,
thumbnailUrl: thumbnailResPath
}
const orgAssetsDataResponse = await this.context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, {
body: JSON.stringify(reqData)
});
if (!orgAssetsDataResponse) {
throw new Error(`Something went wrong when adding OrgAssetsLib. Status='${orgAssetsDataResponse.statusText}'`);
}
const result : boolean = await orgAssetsDataResponse.ok;
return result;
} catch(err) {
console.error(`[OrgAssetsService.setOrgAssets]: Err='${err.message}'`);
}
}
/**
* Updates Organisation Assets Library
* @param libUrl
* @param thumbnailUrl
*/
public async updateOrgAssetsLib(libUrl: string, thumbnailUrl?: string, orgAssetType?: OrgAssetType) : Promise<boolean> {
try {
const apiUrl = `${this.context.pageContext.web.absoluteUrl}/_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/SetOrgAssetsLib`;
const thumbnailResPath: ResourcePath = thumbnailUrl ? {
DecodedUrl: thumbnailUrl
} : null;
const orgAssetTypeData = orgAssetType ? orgAssetType : null;
const requestData = {
libUrl: {
DecodedUrl: libUrl
}
}
if (thumbnailResPath) {
requestData["thumbnailUrl"] = thumbnailResPath;
}
if (orgAssetType) {
requestData["orgAssetType"] = orgAssetTypeData;
}
const orgAssetsDataResponse = await this.context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, {
body: JSON.stringify(requestData)
});
if (!orgAssetsDataResponse) {
throw new Error(`Something went wrong when obtaining OrgAssets. Status='${orgAssetsDataResponse.statusText}'`);
}
const result : boolean = await orgAssetsDataResponse.ok;
return result;
} catch(err) {
console.error(`[OrgAssetsService.setOrgAssets]: Err='${err.message}'`);
}
}
/**
* Removes Organisation Assets Library by List ID.
* @param listId Library ID
*/
public async removeOrgAssetsLibById(listId: string) : Promise<boolean> {
try {
const apiUrl = `${this.context.pageContext.web.absoluteUrl}/_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/RemoveFromOrgAssets`;
const requestData = {
listId: Guid.parse(listId)
}
const orgAssetsDataResponse = await this.context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, {
body: JSON.stringify(requestData)
});
if (!orgAssetsDataResponse) {
throw new Error(`Something went wrong when deleting OrgAssets. Status='${orgAssetsDataResponse.statusText}'`);
}
const result : boolean = await orgAssetsDataResponse.ok;
return result;
} catch(err) {
console.error(`[OrgAssetsService.removeOrgAssetsLib]: Err='${err.message}'`);
}
}
/**
* Removes Organisation Assets Library by absolute URL
* @param libUrl Absolute Library URL
*/
public async removeOrgAssetsLibByUrl(libUrl: string) : Promise<boolean> {
try {
const apiUrl = `${this.context.pageContext.web.absoluteUrl}/_api/Microsoft.Online.SharePoint.TenantManagement.Office365Tenant/RemoveFromOrgAssets`;
const requestData = {
libUrl: {
DecodedUrl: libUrl
}
}
const orgAssetsDataResponse = await this.context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, {
body: JSON.stringify(requestData)
});
if (!orgAssetsDataResponse) {
throw new Error(`Something went wrong when deleting OrgAssets. Status='${orgAssetsDataResponse.statusText}'`);
}
const result : boolean = await orgAssetsDataResponse.ok;
return result;
} catch(err) {
console.error(`[OrgAssetsService.removeOrgAssetsLib]: Err='${err.message}'`);
}
}
}
view raw OrgAssetsService.ts hosted with ❤ by GitHub

Types used by the service:
export interface ResourcePath {
DecodedUrl: string;
}
export enum CdnType {
Public = 0,
Private = 1
}
export enum OrgAssetType {
Undefined = 0,
ImageDocumentLibrary = 1,
OfficeTemplateLibrary = 2
}
export interface AddOrgAssetCdnRequest {
/**
* Absolute Site URL
*/
libUrl: ResourcePath;
cdnType: CdnType;
orgAssetType: OrgAssetType;
/**
* Absolute Thumbnail URL. It must exist in the same Document Library.
*/
thumbnailUrl?: ResourcePath;
}
export interface OrgAssetsItem {
/**
* Display name of the document library
*/
DisplayName: string;
/**
* Server relative library resource path
*/
LibraryUrl: ResourcePath,
ListId: string;
/**
* Undefined = 0,
* ImageDocumentLibrary = 1,
* OfficeTemplateLibrary = 2
*/
OrgAssetType: OrgAssetType,
/**
* Site relative thumbnail resource path
*/
ThumbnailUrl: ResourcePath,
/**
* Unique ID
*/
UniqueId: string;
FileType: string;
}
export interface OrgAssetsData {
/**
* Organisation Assets Libraries information
*/
Items: OrgAssetsItem[];
/**
* Site ID.
*/
SiteId: string;
/**
* Server relative site resource path
*/
Url: ResourcePath;
/**
* WebID
*/
WebId: string;
}

Note: it might take couple of seconds to view the changes applied after the execution of the request.

Hope it’s going o be useful for you!

Office 365: Taxonomy fields on newly created site

Site creation

Office 365 provides great and really fast way to create a new modern site collection (do you remember how long it took to provision a classic site?). It helps a lot to boost users experience as they can see almost immediate results.

However, in most organizations there is a requirement to execute provisioning of additional assets based on ‘site designes’ or ‘pnp templates’. Recently, I have faced a situation when wanted to apply PnP provisioning template to a newly created site that contained content types with taxonomy fields.

SP Taxonomy fields

Just as a quick reminder, to use taxonomy fields it is required to add 2 special fields to a list/content type. These are:

  • TaxCatchAll
  • TaxCatchAllLabel

Which are lookup fields and point to the TaxonomyHiddenList. You can find deep explanation of those roles in a fantastic article of Andrew Connel Managed Metadata: In Depth Look into the Taxonomy Parts.

In fact, these fields are not created immediately after the sites creation, but are added later. Most probably by a timer job, as they are available on the site more or less after 5-6 minutes after site creation. But why do we have to wait that time to get what we really need?

Taxonomy hidden fields provisioning

After trying few other times to force O365 to provision those fields for me, I have decided – OK, they are not there, so let’s create them. However, to execute provisioning of those fields – we need one prerequisite. As we have already said, these are lookup columns and point to taxonomy hidden list, so we have to be sure that it exists before the provisioning.

We can easily ensure it by activating the taxonomy site scope feature:

Enable-PnPFeature -Identity “73ef14b1-13a9-416b-a9b5-ececa2b0604c” -Scope Site

After that, we need to get it’s Id and pass it as a parameter to our provisioning template as a parameter. After we can execute the provisioning of required fields:

$list = Get-PnPList -Identity "TaxonomyHiddenList";
$web = Get-PnPWeb;
$params = @{ "TaxonomyHiddenListId" = $list.Id; "WebId" = $web.Id }
Apply-PnPProvisioningTemplate -Path $templatePath -Parameters $params

Here is the content of the provisioning template:

<?xml version="1.0"?>
<pnp:Provisioning
xmlns:pnp="http://schemas.dev.office.com/PnP/2018/01/ProvisioningSchema">
<pnp:Preferences Generator="OfficeDevPnP.Core, Version=2.20.1711.0, Culture=neutral, PublicKeyToken=3751622786b357c2" />
<pnp:Templates ID="TaxonomyPrerequisite-TEMPLATES">
<pnp:ProvisioningTemplate ID="ValoPrerequisite-v-1.0" Version="1" Scope="RootSite">
<pnp:SiteFields>
<Field Type="LookupMulti" DisplayName="Taxonomy Catch All Column" StaticName="TaxCatchAll" Name="TaxCatchAll" ID="{f3b0adf9-c1a2-4b02-920d-943fba4b3611}" ShowInViewForms="FALSE" List="{parameter:TaxonomyHiddenListId}" WebId="{parameter:WebId}" Required="FALSE" CanToggleHidden="TRUE" ShowField="CatchAllData" SourceID="{484c8c59-755d-4516-b8d2-1621b38262b4}" Mult="TRUE" Sortable="FALSE" AllowDeletion="TRUE" Sealed="TRUE" Hidden="TRUE" />
<Field Type="LookupMulti" DisplayName="Taxonomy Catch All Column1" StaticName="TaxCatchAllLabel" Name="TaxCatchAllLabel" ID="{8f6b6dd8-9357-4019-8172-966fcd502ed2}" ShowInViewForms="FALSE" List="{parameter:TaxonomyHiddenListId}" WebId="{parameter:WebId}" Required="FALSE" Hidden="TRUE" CanToggleHidden="TRUE" ShowField="CatchAllDataLabel" FieldRef="{F3B0ADF9-C1A2-4b02-920D-943FBA4B3611}" SourceID="{484c8c59-755d-4516-b8d2-1621b38262b4}" ReadOnly="TRUE" Mult="TRUE" Sortable="FALSE" AllowDeletion="TRUE" Sealed="TRUE" />
</pnp:SiteFields>
</pnp:ProvisioningTemplate>
</pnp:Templates>
</pnp:Provisioning>

Thanks to this approach, you won’t need to wait till O365 will provision required fields to deploy your stuff. Enjoy!

SP2019 : Upload ClientSidePages

PnP ClientSidePages

PnP Provisioning engine provides a great functionality that allows to create Client Side Pages (modern pages) in PnP Templates. It supports storing in the XML structure the configured page containing web parts and assigned metadata.

The example presented below creates a page containing a two columns with web parts: Text and People.

<?xml version="1.0"?>
<pnp:Provisioning xmlns:pnp="http://schemas.dev.office.com/PnP/2018/05/ProvisioningSchema">
<pnp:Preferences Generator="OfficeDevPnP.Core, Version=2.26.1805.1, Culture=neutral, PublicKeyToken=5e633289e95c321a" />
<pnp:Templates ID="CONTAINER-DEFAULTPAGETEMPLATES">
<pnp:ProvisioningTemplate ID="DEFAULTPAGETEMPLATES" Version="0" Scope="RootSite">
<pnp:ClientSidePages>
<pnp:ClientSidePage Title="{parameter:PageTitle}" PageName="{parameter:PageName}" PromoteAsNewsArticle="false" Overwrite="true" EnableComments="false">
<pnp:Sections>
<pnp:Section Order="1" Type="TwoColumnLeft">
<pnp:Controls>
<pnp:CanvasControl WebPartType="Text" ControlId="7aca70c6-02bc-4b46-a27e-093e34a7a4dc" Order="1" Column="1">
<pnp:CanvasControlProperties>
<pnp:CanvasControlProperty Key="Text" Value="&lt;p&gt;This is a paragraph of text in a Text web part. It's a good place to introduce yourself, describe your project or team, or add any information you need on your page. Just click within this text and replace it with your own.&lt;/p&gt;&#xA;&#xA;&lt;p&gt;You can use the formatting bar above to&lt;/p&gt;&#xA;&#xA;&lt;ul&gt;&#xA; &lt;li&gt;change styles&lt;/li&gt;&#xA; &lt;li&gt;add bullets&lt;/li&gt;&#xA; &lt;li&gt;align text&lt;/li&gt;&#xA; &lt;li&gt;add links&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&#xA;&lt;p&gt;You can delete the whole Text web part by clicking the Delete web part button on the left, or move it by clicking the Move web part button on the left.&lt;/p&gt;&#xA;" />
</pnp:CanvasControlProperties>
</pnp:CanvasControl>
<pnp:CanvasControl WebPartType="People" JsonControlData="{ &quot;serverProcessedContent&quot;: {&quot;htmlStrings&quot;:{},&quot;searchablePlainTexts&quot;:{},&quot;imageSources&quot;:{},&quot;links&quot;:{}}, &quot;properties&quot;: {&quot;persons&quot;:[],&quot;layout&quot;:1}}" ControlId="7f718435-ee4d-431c-bdbf-9c4ff326f46e" Order="1" Column="2" />
</pnp:Controls>
</pnp:Section>
</pnp:Sections>
<pnp:Header Type="None" />
</pnp:ClientSidePage>
</pnp:ClientSidePages>
</pnp:ProvisioningTemplate>
</pnp:Templates>
</pnp:Provisioning>

SharePoint 2019

However, there is a challenge to use this approach when working wish SharePoint 2019 as the Client Side Pages unfortunately are not supported. It can be checked in the implementation of the ClientSidePages handler – the code is not available in on premises build: GitHub: ClientSidePages handler

Recently, I had a task to automate upload of few pages to SharePoint 2019 environment. In case I was not able to use PnP templates – I have decided to use Add-PnPFile:

Add-PnPFile -Path <> -Folder “SitePages”

Below, I present the content of the ClientSidePage that can be uploaded to SitePages library. The page contains 2 webparts – Text and Twitter.

<%@ Page language="C#" Inherits="Microsoft.SharePoint.WebControls.ClientSidePage, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %><%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<html xmlns:mso="urn:schemas-microsoft-com:office:office" xmlns:msdt="uuid:C2F41010-65B3-11d1-A29F-00AA00C14882"><head>
<!--[if gte mso 9]><SharePoint:CTFieldRefs runat=server Prefix="mso:" FieldList="PnPTextField,PnPChoiceField,FileLeafRef,ClientSideApplicationId,PageLayoutType,CanvasContent1,BannerImageUrl,BannerImageOffset,PromotedState,FirstPublishedDate,LayoutWebpartsContent,mb5ae19ff37c4effb60b89ff70e494c2,TaxCatchAllLabel"><xml>
<mso:CustomDocumentProperties>
<mso:PageLayoutType msdt:dt="string">Article</mso:PageLayoutType>
<mso:CanvasContent1 msdt:dt="string">&lt;div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.0&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;controlType&amp;quot;&amp;#58;4,&amp;quot;displayMode&amp;quot;&amp;#58;2,&amp;quot;id&amp;quot;&amp;#58;&amp;quot;8aff88ef-5efc-4fe3-b01f-a131c381854f&amp;quot;,&amp;quot;position&amp;quot;&amp;#58;&amp;#123;&amp;quot;zoneIndex&amp;quot;&amp;#58;1,&amp;quot;sectionIndex&amp;quot;&amp;#58;1,&amp;quot;controlIndex&amp;quot;&amp;#58;1,&amp;quot;sectionFactor&amp;quot;&amp;#58;6,&amp;quot;layoutIndex&amp;quot;&amp;#58;1&amp;#125;,&amp;quot;emphasis&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;anchorComponentId&amp;quot;&amp;#58;&amp;quot;8aff88ef-5efc-4fe3-b01f-a131c381854f&amp;quot;,&amp;quot;addedFromPersistedData&amp;quot;&amp;#58;true,&amp;quot;editorType&amp;quot;&amp;#58;&amp;quot;CKEditor&amp;quot;&amp;#125;&quot;&gt;&lt;div data-sp-rte=&quot;&quot;&gt;&lt;p&gt;Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer condimentum, lectus non vestibulum finibus, mauris ante iaculis mi, nec ultrices quam ipsum sit amet ex. Nunc aliquet, nisi id iaculis fermentum, leo dolor viverra arcu, et euismod magna enim vel mi. Phasellus rutrum orci ac lorem vulputate, nec elementum felis pharetra. Vivamus molestie malesuada metus at consectetur. Nullam lacus arcu, ultricies eu risus at, consequat pellentesque augue. Nullam risus eros, volutpat in tristique nec, fermentum et ipsum. Vivamus vestibulum velit eget tortor eleifend iaculis. Aliquam ac dui nec lectus ullamcorper scelerisque eget sed leo. Praesent nec arcu eu velit consectetur finibus in id sapien.&lt;/p&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.0&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;controlType&amp;quot;&amp;#58;3,&amp;quot;displayMode&amp;quot;&amp;#58;2,&amp;quot;id&amp;quot;&amp;#58;&amp;quot;3ecf51bd-daa6-4f4e-adaa-cc1ac334de92&amp;quot;,&amp;quot;position&amp;quot;&amp;#58;&amp;#123;&amp;quot;zoneIndex&amp;quot;&amp;#58;1,&amp;quot;sectionIndex&amp;quot;&amp;#58;2,&amp;quot;controlIndex&amp;quot;&amp;#58;1,&amp;quot;sectionFactor&amp;quot;&amp;#58;6,&amp;quot;layoutIndex&amp;quot;&amp;#58;1&amp;#125;,&amp;quot;webPartId&amp;quot;&amp;#58;&amp;quot;f6fdf4f8-4a24-437b-a127-32e66a5dd9b4&amp;quot;,&amp;quot;emphasis&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;reservedHeight&amp;quot;&amp;#58;1710,&amp;quot;reservedWidth&amp;quot;&amp;#58;574,&amp;quot;addedFromPersistedData&amp;quot;&amp;#58;true&amp;#125;&quot;&gt;&lt;div data-sp-webpart=&quot;&quot; data-sp-webpartdataversion=&quot;1.0&quot; data-sp-webpartdata=&quot;&amp;#123;&amp;quot;id&amp;quot;&amp;#58;&amp;quot;f6fdf4f8-4a24-437b-a127-32e66a5dd9b4&amp;quot;,&amp;quot;instanceId&amp;quot;&amp;#58;&amp;quot;3ecf51bd-daa6-4f4e-adaa-cc1ac334de92&amp;quot;,&amp;quot;title&amp;quot;&amp;#58;&amp;quot;Twitter&amp;quot;,&amp;quot;description&amp;quot;&amp;#58;&amp;quot;Display a Twitter feed&amp;quot;,&amp;quot;serverProcessedContent&amp;quot;&amp;#58;&amp;#123;&amp;quot;htmlStrings&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;searchablePlainTexts&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;imageSources&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;links&amp;quot;&amp;#58;&amp;#123;&amp;#125;&amp;#125;,&amp;quot;dataVersion&amp;quot;&amp;#58;&amp;quot;1.0&amp;quot;,&amp;quot;properties&amp;quot;&amp;#58;&amp;#123;&amp;quot;displayAs&amp;quot;&amp;#58;&amp;quot;list&amp;quot;,&amp;quot;displayHeader&amp;quot;&amp;#58;false,&amp;quot;displayFooter&amp;quot;&amp;#58;false,&amp;quot;displayBorders&amp;quot;&amp;#58;true,&amp;quot;displayLightTheme&amp;quot;&amp;#58;true,&amp;quot;limit&amp;quot;&amp;#58;&amp;quot;3&amp;quot;,&amp;quot;term&amp;quot;&amp;#58;&amp;quot;@microsoft&amp;quot;,&amp;quot;widthSlider&amp;quot;&amp;#58;100,&amp;quot;title&amp;quot;&amp;#58;&amp;quot;&amp;quot;,&amp;quot;tweetSourceType&amp;quot;&amp;#58;1&amp;#125;&amp;#125;&quot;&gt;&lt;div data-sp-componentid=&quot;&quot;&gt;f6fdf4f8-4a24-437b-a127-32e66a5dd9b4&lt;/div&gt;&lt;div data-sp-htmlproperties=&quot;&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.0&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;displayMode&amp;quot;&amp;#58;2,&amp;quot;position&amp;quot;&amp;#58;&amp;#123;&amp;quot;sectionIndex&amp;quot;&amp;#58;1,&amp;quot;zoneIndex&amp;quot;&amp;#58;3&amp;#125;&amp;#125;&quot;&gt;&lt;/div&gt;&lt;/div&gt;</mso:CanvasContent1>
<mso:ClientSideApplicationId msdt:dt="string">b6917cb1-93a0-4b97-a84d-7cf49975d4ec</mso:ClientSideApplicationId>
<mso:PromotedState msdt:dt="string">0</mso:PromotedState>
<mso:LayoutWebpartsContent msdt:dt="string">&lt;div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.4&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;id&amp;quot;&amp;#58;&amp;quot;cbe7b0a9-3504-44dd-a3a3-0e5cacd07788&amp;quot;,&amp;quot;instanceId&amp;quot;&amp;#58;&amp;quot;cbe7b0a9-3504-44dd-a3a3-0e5cacd07788&amp;quot;,&amp;quot;title&amp;quot;&amp;#58;&amp;quot;Title area&amp;quot;,&amp;quot;description&amp;quot;&amp;#58;&amp;quot;Title Region Description&amp;quot;,&amp;quot;serverProcessedContent&amp;quot;&amp;#58;&amp;#123;&amp;quot;htmlStrings&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;searchablePlainTexts&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;imageSources&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;links&amp;quot;&amp;#58;&amp;#123;&amp;#125;&amp;#125;,&amp;quot;dataVersion&amp;quot;&amp;#58;&amp;quot;1.4&amp;quot;,&amp;quot;properties&amp;quot;&amp;#58;&amp;#123;&amp;quot;title&amp;quot;&amp;#58;&amp;quot;TestPage&amp;quot;,&amp;quot;imageSourceType&amp;quot;&amp;#58;4,&amp;quot;layoutType&amp;quot;&amp;#58;&amp;quot;FullWidthImage&amp;quot;,&amp;quot;textAlignment&amp;quot;&amp;#58;&amp;quot;Left&amp;quot;,&amp;quot;showTopicHeader&amp;quot;&amp;#58;false,&amp;quot;showPublishDate&amp;quot;&amp;#58;false,&amp;quot;topicHeader&amp;quot;&amp;#58;&amp;quot;&amp;quot;,&amp;quot;authors&amp;quot;&amp;#58;[],&amp;quot;authorByline&amp;quot;&amp;#58;null&amp;#125;&amp;#125;&quot;&gt;&lt;/div&gt;&lt;/div&gt;</mso:LayoutWebpartsContent>
<mso:PnPTextField msdt:dt="string">TextFieldValue</mso:PnPTextField>
<mso:PnPChoiceField msdt:dt="string">Choice2</mso:PnPChoiceField>
<mso:PnPLookupField msdt:dt="string">1</mso:PnPLookupField>
<mso:PnPTaxonomyField msdt:dt="string">1;#Term1|aa98e860-de42-4693-9f71-7b0fc407ca3b</mso:PnPTaxonomyField>
<mso:TaxCatchAll msdt:dt="string">1;#Term1|aa98e860-de42-4693-9f71-7b0fc407ca3b</mso:TaxCatchAll>
</mso:CustomDocumentProperties>
</xml></SharePoint:CTFieldRefs><![endif]-->
<title>TestPage</title></head>

In the content, you can also find the example – how to assign the metadata directly in the file.

I hope the ClientSidePages handler will be supported in SharePoint on premises environment soon, but you can always use the presented approach as a workaround.

Themes REST API

Office 365 provides more and more extension points available as REST services. Recently, I was working with the control that allows to manage the themes. The official documentation presents 3 endpoints that allow to list, add and delete theme (https://docs.microsoft.com/en-us/sharepoint/dev/declarative-customization/site-theming/sharepoint-site-theming-rest-api), the endpoints are:

  • siteURL/_api/thememanager/AddTenantTheme

Method allows to add tenant theme. As an argument, it accepts JSON with 2 properties: theme name and palette specifies the theme’s colors.

  • siteURL/_api/thememanager/DeleteTenantTheme

Method allows to delete theme. As an argument, it accepts the theme name.

  • siteURL/_api/thememanager/GetTenantThemingOptions

Method lists all available themes and returns them in the form of ThemingOptions object.

Additional methods

Below I present two additional endpoints that are not officialy announced in the docs (you have to consider using theme on your production environment), but can really useful to bring more possibilities to your solutions:

  • siteURL/_api/thememanager/ApplyTheme

Method allows to apply theme to the site.

  • siteURL/_api/thememanager/UpdateTenantTheme

Method allows to update the existing theme defition.

 

[2019-01-15]: I have created a PR to update the official docs.

SPFx usage

I have created a service class that allows to work with O365 themes that can be used in SPFx projects.

import { SPHttpClient, SPHttpClientResponse } from "@microsoft/sp-http";
import { IPartialTheme } from "@uifabric/styling/lib";
export interface IThemingOptions {
'@odata.context': string;
hideDefaultThemes: boolean;
themePreviews: IThemePreview[];
}
export interface IThemePreview {
/**
* Name od the SP theme
*/
name: string;
/**
* JSON representing theme.
*/
themeJson: string;
}
export interface ISPThemeService {
addTheme(themeName: string, theme: IPartialTheme): Promise<boolean>;
getThemes(): Promise<IThemingOptions>;
deleteTheme(themeName: string): Promise<boolean>;
applyTheme(theme: IThemePreview, siteUrl: string): Promise<boolean>;
updateTheme(themeName: string, theme: IPartialTheme): Promise<boolean>
}
export class SPThemeService implements ISPThemeService {
private _siteUrl: string;
private _spHttpClient: SPHttpClient;
constructor(spHttpClient: SPHttpClient, siteUrl: string) {
this._spHttpClient = spHttpClient;
this._siteUrl = siteUrl;
}
/**
* Adds theme to the tenant.
* @param themeName Theme name.
* @param theme Theme partial definition containing palette property.
*/
public async addTheme(themeName: string, theme: IPartialTheme): Promise<boolean> {
try {
let url = `${this._siteUrl}/_api/thememanager/AddTenantTheme`;
const body = JSON.stringify({
name: themeName,
themeJson: JSON.stringify(theme)
});
let addThemeResponse: SPHttpClientResponse = await this._spHttpClient.post(url, SPHttpClient.configurations.v1, {
body: body
});
if (!addThemeResponse || !addThemeResponse.ok) {
throw new Error(`Something went wrong when add theme. ThemeName=${themeName}.`);
}
const addThemeJSONResult = await addThemeResponse.json();
if (!addThemeJSONResult) {
throw new Error(`Cannot read JSON result when add theme. ThemeName=${themeName}.`)
}
return addThemeJSONResult.value;
} catch (err) {
console.log(`[Error][SPThemeService.addTheme] : + ${err.message}`);
return false;
}
}
/**
* Gets available tenant themes.
* @param siteUrl Site URL which will be used to execute query.
*/
public async getThemes(): Promise<IThemingOptions> {
try {
let url = `${this._siteUrl}/_api/thememanager/GetTenantThemingOptions`;
let getThemesResponse: SPHttpClientResponse = await this._spHttpClient.get(url, SPHttpClient.configurations.v1);
if (!getThemesResponse || !getThemesResponse.ok) {
throw new Error(`Something went wrong when obtaining tenant themes.`);
}
const themesJSONResult = await getThemesResponse.json() as IThemingOptions;
if (!themesJSONResult) {
throw new Error("Cannot read JSON result when obtaining themes. ")
}
return themesJSONResult;
} catch (err) {
console.log(`[Error][SPThemeService.getThemes] : ${err.message}`);
return null;
}
}
/**
* Deletes theme.
* @param themeName Theme name to be deleted.
*/
public async deleteTheme(themeName: string): Promise<boolean> {
try {
let url = `${this._siteUrl}/_api/thememanager/DeleteTenantTheme`;
let deleteResult: SPHttpClientResponse = await this._spHttpClient.post(url, SPHttpClient.configurations.v1, {
body: JSON.stringify({
name: themeName
})
});
if (!deleteResult || !deleteResult.ok) {
throw new Error(`Something went wrong when delete theme. ThemeName=${themeName}.`);
}
return true;
} catch (err) {
console.log(`[Error][SPThemeService.deleteTheme] : ${err.message}`);
return false;
}
}
/**
* Apply theme to the site.
* @param theme Partial theme containing palette property.
* @param siteUrl
*/
public async applyTheme(theme: IThemePreview, siteUrl: string): Promise<boolean> {
try {
let url = `${siteUrl}/_api/ThemeManager/ApplyTheme`;
let applyThemeResult: SPHttpClientResponse = await this._spHttpClient.post(url, SPHttpClient.configurations.v1, {
body: JSON.stringify({
name: theme.name,
themeJson: theme.themeJson
})
});
if (!applyThemeResult || !applyThemeResult.ok) {
throw new Error("Something went wrong when applying theme.");
}
let applyThemeResultJson = await applyThemeResult.json();
if (!applyThemeResultJson) {
throw new Error("Cannot read answer when applying theme..");
}
return true;
} catch (err) {
console.log(`[Error][SPThemeService.applyTheme] : ${err.message}`);
return false;
}
}
/**
* Update theme.
* @param themeName Theme name to be updated.
* @param theme Partial theme containing palette property.
*/
public async updateTheme(themeName: string, theme: IPartialTheme): Promise<boolean> {
try {
let url = `${this._siteUrl}/_api/thememanager/UpdateTenantTheme`;
const body = JSON.stringify({
name: themeName,
themeJson: JSON.stringify(theme)
});
let updateThemeResponse: SPHttpClientResponse = await this._spHttpClient.post(url, SPHttpClient.configurations.v1, {
body: body
});
if (!updateThemeResponse || !updateThemeResponse.ok) {
throw new Error(`Something went wrong when update theme. ThemeName=${themeName}.`);
}
const updateThemeJSONResult = await updateThemeResponse.json();
if (!updateThemeJSONResult) {
throw new Error(`Cannot read JSON result when update theme. ThemeName=${themeName}.`)
}
return updateThemeJSONResult.value;
} catch (err) {
console.log(`[Error][SPThemeService.updateTheme] : ${err.message}`);
return false;
}
}
}
view raw SPThemeService.ts hosted with ❤ by GitHub

Coming soon

In the next post, I will share some thoughts about forcing immediate inheritance of the themes in the connected sites. Stay tuned!

Work with JSON data in PowerShell

Introduction

JSON is a well known, flexible format that allows to easily store the information. PowerShell provides a great cmdlets that helps working with JSON formatted data.

Let’s consider the sample JSON structure, containing information about the colors:

{
"colors": [
{
"color": "black",
"category": "hue",
"type": "primary",
"code": {
"rgba": [255,255,255,1],
"hex": "#000"
}
},
{
"color": "white",
"category": "value",
"code": {
"rgba": [0,0,0,1],
"hex": "#FFF"
}
},
{
"color": "red",
"category": "hue",
"type": "primary",
"code": {
"rgba": [255,0,0,1],
"hex": "#FF0"
}
},
{
"color": "blue",
"category": "hue",
"type": "primary",
"code": {
"rgba": [0,0,255,1],
"hex": "#00F"
}
},
{
"color": "yellow",
"category": "hue",
"type": "primary",
"code": {
"rgba": [255,255,0,1],
"hex": "#FF0"
}
},
{
"color": "green",
"category": "hue",
"type": "secondary",
"code": {
"rgba": [0,255,0,1],
"hex": "#0F0"
}
}
]
}
view raw sample.data.json hosted with ❤ by GitHub

ConvertFrom-Json

The cmdlet allows easily to convert JSON data into the PowerShell custom object

ConvertTo-Json

The same way, the cmdlet allows to convert custom PowerShell object to JSON format.

This approach provides a great flexibility to quickly import and save the data that can be used within PowerShell scripts. It is getting more tricky when the JSON structure or PowerShell scripts logic is getting more and more complex. In addition, it might be also challenging to someone who is not aware of the data structure to start working on the .ps1 files.

The ideal situation would be to bring some kind of strong typing and intellisense to the PowerShell experience. We can use VS Code which is capable of resolving object structure, after loading it into memory.

20190101_VScode0

Now, we know the structure of the initialized object that we are working with and it solves partially the problem, but still when the complexity of the logic grows or we create custom functions that use argument names different than the loaded object name, we loose the syntax highlighting.

20190101_VScode1

PowerShell class for the rescue

To improve the development experience, we can create custom PowerShell classes, which will help us to get strongly typed objects in both the development and runtime scenarios. Let’s create simple classes that are going to represent the JSON data structure:

Now, we can use the Color class, as a parameter type for the Get-ColorDetails function. Thanks to that, we regain the syntax highlighting in VS Code.

20190101_VScode2

function Get-Colors {
param (
[string]$colorsDataString
)
$colorsJSONData = $colorsDataString | ConvertFrom-Json;
[Colors]$colors = [Colors]::new();
$colors.psobject.properties | ForEach-Object {
if ($colorsJSONData.($_.Name)) {
$colors.($_.Name) = $colorsJSONData.($_.Name)
}
}
return $colors;
}
view raw Get-Colors.ps1 hosted with ❤ by GitHub

This approach will ensure that the Colors class will be initialized correctly. Moreover, we do not have to remove some additional information stored in the JSON like e.g. $schema.

VS Code Profile file

To fully leverage the described approach, we need to load the definition of the model into the ‘PowerShell Integrated Console’ every time we open the project. To automate the task and provide the same experience every time, let’s create a VS Code profile file, which is going to load the class definitions.

In the ‘PowerShell Integrated Console’, execute:

notepad $profile

Or create it if doesn’t exist:

New-Item $profile -type File

In the notepad, execute the model script (ColorsModel.ps1), to ensure that our class definition has been loaded:

if (Test-Path './ColorsModel.ps1') {
. ./ColorsModel.ps1
}

Save the file and restart the ‘Powershell Integrated Console’.  Now you can leverage syntax highlighting and strongly typed objects inside your PowerShell functions.

The full sample can be found in my github:
https://github.com/siata13/powershell-json-model

 

Ready, Set, Go!

Hi, I’m Piotr – passionate developer, working on a daily routine with Microsoft technologies: SharePoint/Office365/Azure/PowerShell. In private life, I am a fanatic runner.

In this blog, you will find the content related to SharePoint from all the angles.

I am really excited that you are here. Sharing is caring!