Experiment – Find out where SPFx Web Parts are being used in Modern SharePoint sites

Recently, I saw a post on Tech Community that asked if there were APIs available to find out where a specific web part may be used in an environment. The reason was to provide a list of sites so an email could be sent to those specific site owners to let them know that a deployment was going to happen for some SPFx web parts.

My first thought was to loop through all of the sites and find out if the the SharePoint Framework app had been installed. This would work except in the case of a tenant wide deployment of the SharePoint Framework web parts. So instead of finding out where a web part has been installed, we need to find out where a web part was actually being used.

The Experiment

To me, this sounded like a great idea, unfortunately, I wasn’t aware of any APIs that were able to do this. Then I got to thinking, maybe we could use the search API to do this. In those post, I am going to try and see if we can use the Search API to find web part usage in SharePoint. Be aware this solution would only work for modern pages using your SPFx web part.

CanvasContent1

When adding web parts to a modern site page in Office 365, the HTML content is saved into a column called “CanvasContent1”. I quickly looked at the search schema to see if I would be able to search on this column.

canvascontent

Unfortunately, by default, the CanvasContent1 managed property called CanvasContent1OWSHTML didn’t have a crawled property mapped to it.  So I decided to map ows_CanvasContent1 to a new RefinableString.

untitled

Now, in order for the column to be searchable we have to wait for the column to re-crawl in our environment… this could take some time in SharePoint Online.

What to search for

The CanvasContent1 field contains a bunch of html data about the contents of the page. This includes web parts and their configuration including properties. Stored inside the CanvasContent1 field will also include the ID of the web parts configured on the page.With this in mind, I figured it would be fairly easy to find where a web part is being used in an environment by searching against this field.

Let’s say that I have a web part with the component id of 62799350-83b2-40a1-b35d-5417cc54daea as shown in this SPFx manifest file.

untitled

If I execute a search query against the RefinableString field where it contains this GUID, I should be fairly certain the page contains my web part.

Using the SharePoint Search  REST API, I can execute the following call to return the Title, Path and Site of the page that is rendering my web part.

Request

https://testsite.sharepoint.com/sites/test/_api/search/query?QueryText='RefinableString134:62799350-83b2-40a1-b35d-5417cc54daea*'&selectProperties='Title,Path,SPWebUrl'

Response

In the response, I have found 2 pages where my web part is being loaded.

<d:query xmlns:d="http://schemas.microsoft.com/ado/2007/08/dataservices" xmlns:m="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata" xmlns:georss="http://www.georss.org/georss" xmlns:gml="http://www.opengis.net/gml"m:type="Microsoft.Office.Server.Search.REST.SearchResult">
<d:ElapsedTime m:type="Edm.Int32">104</d:ElapsedTime>
<d:PrimaryQueryResult m:type="Microsoft.Office.Server.Search.REST.QueryResult">
<d:CustomResults m:type="Collection(Microsoft.Office.Server.Search.REST.CustomResult)"/>
<d:QueryId>37de1142-2baf-4ea6-ab0b-b646a0a57d31</d:QueryId>
<d:QueryRuleId m:type="Edm.Guid">00000000-0000-0000-0000-000000000000</d:QueryRuleId>
<d:RefinementResults m:null="true"/>
<d:RelevantResults m:type="Microsoft.Office.Server.Search.REST.RelevantResults">
<d:GroupTemplateId m:null="true"/>
<d:ItemTemplateId m:null="true"/>
<d:Properties m:type="Collection(SP.KeyValue)">
<d:element>
<d:Key>GenerationId</d:Key>
<d:Value>9223372036854775806</d:Value>
<d:ValueType>Edm.Int64</d:ValueType>
</d:element>
<d:element>
<d:Key>indexSystem</d:Key>
<d:Value/>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>ExecutionTimeMs</d:Key>
<d:Value>47</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element>
<d:Key>QueryModification</d:Key>
<d:Value>
RefinableString134:62799350-83b2-40a1-b35d-5417cc54daea* -ContentClass=urn:content-class:SPSPeople
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>RenderTemplateId</d:Key>
<d:Value>
~sitecollection/_catalogs/masterpage/Display Templates/Search/Group_Default.js
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>StartRecord</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element>
<d:Key>IsLastBlockInSubstrate</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsFirstBlockInSubstrate</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsFirstPinnedResultBlock</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsLastPinnedResultBlock</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsFirstRankedResultBlock</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsLastRankedResultBlock</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>MixedTableOrder</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
</d:Properties>
<d:ResultTitle m:null="true"/>
<d:ResultTitleUrl m:null="true"/>
<d:RowCount m:type="Edm.Int32">2</d:RowCount>
<d:Table m:type="SP.SimpleDataTable">
<d:Rows>
<d:element m:type="SP.SimpleDataRow">
<d:Cells>
<d:element m:type="SP.KeyValue">
<d:Key>Rank</d:Key>
<d:Value>16.8176937103271</d:Value>
<d:ValueType>Edm.Double</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>DocId</d:Key>
<d:Value>17601926184608</d:Value>
<d:ValueType>Edm.Int64</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Title</d:Key>
<d:Value>Home</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Path</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlertnativeHome.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>SPWebUrl</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>OriginalPath</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlertnativeHome.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>PartitionId</d:Key>
<d:Value>ca45b536-df01-44b6-afa0-d3f8e7ebb312</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>UrlZone</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Culture</d:Key>
<d:Value>en-US</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>ResultTypeId</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>RenderTemplateId</d:Key>
<d:Value>
~sitecollection/_catalogs/masterpage/Display Templates/Search/Item_Default.js
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
</d:Cells>
</d:element>
<d:element m:type="SP.SimpleDataRow">
<d:Cells>
<d:element m:type="SP.KeyValue">
<d:Key>Rank</d:Key>
<d:Value>16.8176937103271</d:Value>
<d:ValueType>Edm.Double</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>DocId</d:Key>
<d:Value>17601957874490</d:Value>
<d:ValueType>Edm.Int64</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Title</d:Key>
<d:Value>Home</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Path</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlternativeHome2.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>SPWebUrl</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>OriginalPath</d:Key>
<d:Value>
https://testsite.sharepoint.com/sites/test/SitePages/AlternativeHome2.aspx
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>PartitionId</d:Key>
<d:Value>ca45b536-df01-44b6-afa0-d3f8e7ebb312</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>UrlZone</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>Culture</d:Key>
<d:Value>en-US</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>ResultTypeId</d:Key>
<d:Value>0</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element m:type="SP.KeyValue">
<d:Key>RenderTemplateId</d:Key>
<d:Value>
~sitecollection/_catalogs/masterpage/Display Templates/Search/Item_Default.js
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
</d:Cells>
</d:element>
</d:Rows>
</d:Table>
<d:TotalRows m:type="Edm.Int32">2</d:TotalRows>
<d:TotalRowsIncludingDuplicates m:type="Edm.Int32">2</d:TotalRowsIncludingDuplicates>
</d:RelevantResults>
<d:SpecialTermResults m:null="true"/>
</d:PrimaryQueryResult>
<d:Properties m:type="Collection(SP.KeyValue)">
<d:element>
<d:Key>RowLimit</d:Key>
<d:Value>500</d:Value>
<d:ValueType>Edm.Int32</d:ValueType>
</d:element>
<d:element>
<d:Key>SourceId</d:Key>
<d:Value>8413cd39-2156-4e00-b54d-11efd9abdb89</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element>
<d:Key>CorrelationId</d:Key>
<d:Value>02b6b69e-10c1-7000-727b-bc8a6f5546b9</d:Value>
<d:ValueType>Edm.Guid</d:ValueType>
</d:element>
<d:element>
<d:Key>WasGroupRestricted</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsPartial</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>HasParseException</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>WordBreakerLanguage</d:Key>
<d:Value>en</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>IsPartialUpnDocIdMapping</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>EnableInterleaving</d:Key>
<d:Value>true</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>IsMissingUnifiedGroups</d:Key>
<d:Value>false</d:Value>
<d:ValueType>Edm.Boolean</d:ValueType>
</d:element>
<d:element>
<d:Key>Constellation</d:Key>
<d:Value>iC6B8D</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
<d:element>
<d:Key>SerializedQuery</d:Key>
<d:Value>
<Query Culture="en-US" EnableStemming="True" EnablePhonetic="False" EnableNicknames="False" IgnoreAllNoiseQuery="True" SummaryLength="180" MaxSnippetLength="180" DesiredSnippetLength="90" KeywordInclusion="0" QueryText="RefinableString134:62799350-83b2-40a1-b35d-5417cc54daea*" QueryTemplate="" TrimDuplicates="True" Site="532bd9a4-869f-45e2-b5f0-323f6604a429" Web="c67564a0-242f-40bf-b4ac-a0ea8dbc935e" KeywordType="True" HiddenConstraints="" />
</d:Value>
<d:ValueType>Edm.String</d:ValueType>
</d:element>
</d:Properties>
<d:SecondaryQueryResults m:type="Collection(Microsoft.Office.Server.Search.REST.QueryResult)"/>
<d:SpellingSuggestion/>
<d:TriggeredRules m:type="Collection(Edm.Guid)"/>
</d:query>

In my result set, I have found two pages in the same site that was using this specific web part. With a larger result set that comes back, I could loop through the records and figure out which sites are running my web part, and send an email accordingly to each site owner about the upcoming web part deployment!

Final Thoughts

This was a fun experiment to test out and I am going to continue to do some more exploration to see if there are other methods possible for finding out this information. I haven’t been able to do thorough testing, but I would love to hear if this works for you and/or if you have another approach!

Caveats/Feedback

  1. This would only work for web parts placed on modern pages
  2. If site owners have disabled content from being searchable, it will not show up. (Thanks Paul Bullock!)
  3. There are much better approaches to monitoring this type of information. As Sam Crewdson noted on twitter  the use of Application Insights is a robust way to track and monitor solutions in your environment. Here is a blog post by Chris O’Brien showing how to use application insights with SPFx.

PnP-Starter-Kit for Standard Release Tenants

I am going to preface this blog post with — if you have an issue with the solution setup I have below, please do not add issues to the PnP-Starter-Kit issues list. This solution below, is a replication of the pnp-starter-kit and not managed by the PnP Team.

I’d also like to say, that you don’t need this post. The PnP-Starter-Kit should be deployed into First-Release tenants. I’ve had reservations on whether or not to post this.

What is it?

The PnP-Starter-Kit is an open sourced initiative that was released by the PnP-Team to show all of the capabilities that currently exist around provisioning in Office 365 using the SharePoint Framework and PnP Provisioning. For more information on the project, the GitHub repository can be found here.

One of the requirements of the PnP-Starter-Kit, is that it requires your tenant to be configured for “First Release for everyone”. This is because some of the web parts utilize the new AadHttpClient and MsGraphClient. These are currently in preview, but require permissions to be requested and approved under “API Management” in the admin center.

What’s in this my solution?

This solution is essentially an exact replica of the PnP repository, except that I have removed all of the web parts that require First Release and I have modified the deploy scripts and PnP Provisioning XML files to also support standard release tenants. What this means is that you will not have the LOB application or any of the web parts that generally show up on the personal page (except the people directory).

Why do this?

You may be wondering why I would do this. Since all of this is brand new and uses experimental features, it should only be deployed into a test/demo tenant, where turning on First Release shouldn’t be a problem. However, through some of the issues lists and comments I’ve seen on social media, it appears many people want to deploy the web parts into standard release tenants to test out the web parts, so I figured “why not?”.

You can get a demo tenant for free and set that tenant to first-release. However, if you really feel you want to deploy this into Standard Release… here you go.

How to deploy

The deployment of this is exactly the same as the full PnP Starter Kit, except you don’t have to configure any of the API management stuff.

GitHub Repository Here

  1. Make sure you’ve updated your PowerShell module to the latest version.
  2. Make sure your development environment is configured correctly here.
  3. Make sure your tenant is configured following this page for the User Profile property for the Portal Footer.
  4. Make sure your tenant has an app catalog
    1. Wait 24 hours after creating one
  5. Make sure your account is a Term Store Administrator

To deploy the starter kit, you’ll need to upload the sharepoint-portal-showcase.sppkg from the /package folder to your app catalog.  This will provision the web parts to all of your site collections for use. If you’d like to also include the 3 demo sites, and pre-built pages included in the full starter kit, then open up Powershell and navigate to the provisioning folder. Once inside the folder, execute the following command to start the provisioning process.

.\deploy.ps1 -TenantUrl https://YourTenant.sharepoint.com -SitePrefix Demo

 

Questions?

If you have any questions, just ask me. Please note, that I will not really be maintaining this repository, so it will not be receiving any of the new bug fixes and updates from the PnP Team.

Please do not add issues about this package to the PnP-Starter-Kit GitHub repository issues list.

Security Trimmed Hub Site Navigation Updates!

I had recently posted a blog  about a SharePoint Framework (SPFx) application customizer I built for use with hub sites in Office 365. The problem I currently have with Hub navigation is that all of the links are static links. This means, that whoever creates the common navigation must be cognizant of what links end users have access to. With that in mind, I built a new navigation the replaces the out-of-the-box hub site navigation elements with a security trimmed listing of sites within a Hub.

For brevity, this isn’t an all inclusive tutorial. It’s just discussing the high level changes to the original implementation of the application customizer.

How it works

The new navigation uses a combination of the SharePoint Search API and the new Hub sites API. When a user lands on a site collection that belongs with in a hub, I grab the hub site information to find all of the sites that exist within the hub using the Search API. The benefit of this, is that I can security trim a listing of sites with in the navigation. That means I can have sites in a hub that a user may not have access to, yet include them as elements in the global hub site navigation.

HubNavSite

What’s wrong with V1?

While this approach is great and I have had quite a bit of success building out security trimmed navigation, the default implementation lacks the ability to use the existing hub site navigation elements. This is why I have released v2 which is available on GitHub for download.

The new application customizer still implements the “Sites” listing node in the navigation.
NewNav

Adding existing Hub navigation

Version 2 is almost identical to version 1 with the exception that I am now pulling in the static elements from the default hub navigation. To do this, I’ve made some modifications to the base ReactHubsiteNavbarApplicationCustomizer.ts file, to set the Navigation property equal to the navigation array that returns from the _api/web/hubsitedata endpoint.

this._currentHubSiteData = await  searchService.getHubSiteData().then((hubData: IAssociatedSite) => {
if(hubData){
return searchService.getHubID(hubData).then((hub: IHubSiteData) => {
//add existing hub navigation
hub.Navigation = hubData.navigation;
return hub;
});
}
else{
return null;
}
});

Now that I have the elements as part of the _currentHubSiteData object, I just have to modify my HubNavBar.tsx component to accommodate loading the existing navigation nodes. One thing that isn’t working currently with the commandbar, is the ability to have a navigation element with children, in which the parent link is clickable.

SiteTrimmedMenuCode

You may notice the methods are almost identical. Rather than refactoring… I’ve separated them out because I have some future changes I’d like to make specifically to the rendering of the trimmed sites listing.

Ability to edit navigation

Now that we are showing the existing navigation elements and hiding the existing Hub navigation html…I needed a way to allow users to add and edit the original Hub navigation. There is an endpoint at _api/Navigation/SaveMenuState for saving changes to the menu, however it is undocumented by Microsoft so I will avoid using it for now. Instead, I am going to re-use the out-of-the-box Hub site navigation control to manage adding/editing elements.

To do this, I am checking if the user has ManageWeb permission, and then I will show an edit button which will show/hide the original Hub navigation.
//if user has manage web permissions, show edit button
const hasPermission:boolean = this.props.context.pageContext.web.permissions.hasPermission(SPPermission.manageWeb);


if(hasPermission){
commandBarItems.push({
key:"editButton",
name:"Edit",
itemType:ContextualMenuItemType.Header,
onClick:this._editOnClick,
className:"editButton",
href:"#"
});
}

Caveat: The out of the box permission for managing Hub navigation is the new “Edit” permission. Which means that users in the Member group have access to change the navigation. I think this is a bit too much permission, so I am restricting the permissions to “ManageWeb”.

The Result

hubnavnew

The end result is a combination of security trimmed sites listing within the hub and the existing hub site navigation. User’s with ManageWeb permissions on the Hub site will be able to see the edit button to modify the existing navigation elements.

Notes

Because of the caching in the customizer, it may take 15 minutes for changes to appear!

GitHub – https://github.com/bcameron1231/SharePoint-Framework/releases/tag/v2.0

Code is in pre-release and subject to change (refactoring).