After some experimenting, I found that the only two columns you need to set are the ID and Type
ID needs to have 61cbb965-1e04-4273-b658-eedaa662f48d set as it’s value
Type needs to have TargetTo set as it’s value
You still need to provide values for Name, DisplayName, StaticName, but they can be whatever you want.
Don’t like the name Target Audiences then you can change it to something else. The great thing is, it can still be turned off through the List or Library settings page.
To enable Audinence targeting using PnP PowerShell it’s as simple as running this script.
This is done using the Yeoman SharePoint Generator in a terminal of your choice.
1 2 3
md my-command-set cd app-extension yo @microsoft/sharepoint
Enter the following options
1 2 3 4 5 6 7 8 9 10
? What is your solution name? my-command-set ? Which baseline packages... ? SharePoint Online only (latest) ? Where do you ... files? Use the current folder ? Do you want ... tenant admin ... in sites? No ? Will the components ... APIs ... tenant? No ? Which type of client-side component to create? Extension ? Which type of client-side extension to create? ListView Command Set Add new Command Set to solution my-command-set. ? What is your Command Set name? SecurableCommandSet ? What is your Command Set description? SecurableCommandSet description
Full base64-encoded image is included with source code
Next open up SecurableCommandSetCommandSet.ts
Add a private field to the classe to store the visibility of the command.
1
private isInOwnersGroup: boolean = false;
We want to make sure the command is only visible to people who are in the Owners group of the site we are in.
This is done in the onListViewUpdated method of the SecurableCommandSetCommandSet class.
Below is the code added when the project is created.
1 2 3 4 5 6 7 8
@override public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void { const compareOneCommand: Command = this.tryGetCommand('COMMAND_1'); if (compareOneCommand) { // This command should be hidden unless exactly one row is selected. compareOneCommand.visible = event.selectedRows.length === 1; } }
Replace it with following
1 2 3 4 5 6 7 8 9
@override public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void { const compareSecureCommand: Command = this.tryGetCommand('CMD_SECURE'); if (compareSecureCommand) {
Install the PnP client side libraries as we’re going to need some of their magic in this solution
1
npm i @pnp/sp @pnp/common @pnp/logging @pnp/odata --save
SharePoint Groups and their members aren’t available in the BaseListViewCommandSet.context property, so we’re going to need to load them.
The problem is that this will have to be done using Promises and onListViewUpdated doesn’t return a promise.
Luckily we have the onInit method for this (returns Promise<void>). This method gets called when you component is initialised (Basically when the list view is loaded up in the page). Anything in the onInit method will run before the commands are rendered, similar to the actions you’d run in the componentWillMount method of a react component.
To use the pnpjs library it needs to be initialised first and this needs to be done in the onInit method.
Add the import statement
1
import { sp } from"@pnp/sp";
Replace the onInit method with the following code, this sets up the sp helper with context of the Extension and then to call into the SharePoint Groups in the site, we’re going to have to await away in the onInit method again to call into the site and set the isInOwnersGroup field.
For the observant people out there, you may notice that I’ve declared user as any. For some reason, the users collection returned has UpperCamelCase properties and the TypeScript reference is using lowerCamelCase, which was causing a TypeScript compile error. Hence the user.Email === email rather than user.email === email in the some function call.
In this snippet of code, we’re getting the login of the current user, the associated owner group of the site and then getting the users in the group.
The some function determines if the user is in the group and it’s result sets the isInOwnersGroup.
Finally an update is needed on the onListViewUpdated method to show / hide the command.
Currently there is a bug in tenants that are on First Release that stops gulp serve working correctly. If you can’t see your command then switch to Standard (not always instant!), if it still doesn’t work then try deploying without the --ship paramter. See all the details on the sp-dev-docs GitHub repository
Make sure you account is in the Owners group (It won’t be if you created a “groupified” team site).
If all is good your new CommandSet should appear on the top menu and will show the alert message when clicked.
Summary
Getting the SharePoint group users is just an example of how you can use the onInit method to call into other services, like custom web apis, MS Graph, etc.
Remember that this could effect the load time of your command, which may effect the user experience. The context menu may not be on screen for long, so your menu may have not loaded before it’s gone.
Metadata navigation and per-location views are an little known, but powerful way of making lists more useful.
It allows you to assign a default view and others views to a folder, content type or field. My need was to allow users to navigate using a Taxonomy field. Dependent on the selected field, I would like to show different fields using a view.
The first part is to add the Metadata navigation.
This is done by creating a hierarchy using MetadataNavigationHierarchy
1 2 3 4 5 6 7 8 9
var list = SPContext.Current.Web.Lists.TryGetList("MyList"); var field = list .Fields.TryGetFieldByStaticName("MyField"); var settings = MetadataNavigationSettings.GetMetadataNavigationSettings(list); var hierarchy = settings.FindConfiguredHierarchy(field.Id);
if (hierarchy == null) { hierarchy = new MetadataNavigationHierarchy(field); settings.AddConfiguredHierarchy(hierarchy); }
The hard part is adding the settings for the per location views.
This can only be done by injecting XML into the settings XML. MetadataNavigationSettings is a wrapper class around an XML snippet that is stored in a hidden property of the root folder of the list.
Have a look at SPList.RootFolder.Properties["client_MOSS_MetadataNavigationSettings"]
The XML Schema is as follows. Haven’t found anything documenting this schema on MSDN yet, so this is just taken from my configuration of my list, so may differ on yours
The part I’m interested in here is the ViewSettings and View tag. The UniqueId attribute relates to the GUID of the selected Term GUID. So this will show the views defined using the View tag when the Term is selected in the Metadata navigation.
If a View tag is added with 0 index this will be used as the default view when the term is selected, all other positive numbers will be shown in the order defined as other available views for that Term. Any negatives will not be available (You don’t need to add them)
I used the following code to add these nodes programmaticallty using XLinq
var list = SPContext.Current.Web.Lists.TryGetList("MyList"); var view = list.Views.Cast().SingleOrDefault(v => v.Title == "MyView"); var session = new TaxonomySession(SPContext.Current.Site); var field = list.Fields.TryGetFieldByStaticName("MyField"); var term = session.GetTerm("a96cea49-ef78-4bfa-8a69-2c49071155fb"); var settings = MetadataNavigationSettings.GetMetadataNavigationSettings(list); var doc = XDocument.Parse(settings.SettingsXml);
var metaDataField = ( from f in doc.Descendants("MetadataField") let fieldId = f.Attribute("FieldID") where fieldId != null && fieldId.Value == field.Id.ToString() select f ).SingleOrDefault();
if (metaDataField != null) { var viewSettings = ( from v in metaDataField.Elements("ViewSettings") let uniqueNodeId = v.Attribute("UniqueNodeId") where uniqueNodeId != null && uniqueNodeId.Value == term.Id.ToString() select v ).SingleOrDefault(); if (viewSettings == null) { metaDataField.Add( new XElement("ViewSettings", new XAttribute("UniqueNodeId", term.Id.ToString()), new XElement("View", new XAttribute("ViewId", view.ID.ToString()), new XAttribute("CachedName", view.Title), new XAttribute("Index", "0"), new XAttribute("CachedUrl", view.Url) ) ) ); } }
settings = new MetadataNavigationSettings(doc.ToString()); MetadataNavigationSettings.SetMetadataNavigationSettings(list, settings);
If you have a field called “Folder” it will not be available in the returned ListItem object
e.g. listItem["Folder"].ToString()
The inner workings of the ListItem object uses an ExpandoObject to store the properties. It seems it mixes this up in the FieldValues Collection with all your custom fields. The Folder property then takes the value from the FieldValues collection to make it available to the Folder property. Thus making your own Folder field “disappear”
Here is how you can replicate it.
Create a list based on the Custom List template
Add a column called “Folder” and make it a text field
Add a column called “DisplayName” and make it a text field
Add a column called “MyField” and make it a text field
Add a couple of dummy rows of data
Create a console application in VS that references Microsoft.SharePoint.Client and Microsoft.SharePoint.Client.Runtime
Add the following code to the Main method
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
using (var context = new ClientContext("http://siteurl")) { var query = CamlQuery.CreateAllItemsQuery(); var listItems = context.Web.Lists.GetByTitle("Testing").GetItems(query);
Here is the code from .Net Reflector that shows the ListItem object populating the properties. It’s also worth noting that the other properties detailed in this method will have the same problem, but it’s unlikely that you’ll call a field FileSystemObject!
This just adds an extra column to the end with an icon that opens the ‘View Properties’ dialog.
You can either use a Content Editor web part to make it view specific or add it to a global script for all views. It’s a little hacky, but does the job.
It relies on the title column being available to extract the id of the item.
RunAs("http://asharepointsite", "THEDOMAIN\THEUSER", c => { // The code to execute. e.g. get a the UserProfileManager as the passed in user var upm = new UserProfileManager(c, true);
// Do some stuff to the UPM as the passed in user });
if (SPContext.Current != null) { if (SPContext.Current.Web != null) { currentUser = SPContext.Current.Web.CurrentUser; } }
SPSecurity.RunWithElevatedPrivileges(delegate { using (var site = new SPSite(siteUrl)) { using (var web = site.OpenWeb()) { try { var user = web.EnsureUser(userName);
string format = GetResourceString(template.TitleFormatLocStringResourceFile, template.TitleFormatLocStringName, (uint)CultureInfo.CurrentUICulture.LCID);
The format variable will now contain something along the lines of “{Pubisher} has posted a note {Link} on {PublishDate}”
Now you could at this point take the corresponding Properties in the ActivityEvent, but there is a whole load of formatting that needs to be done. The Publisher Tag needs to be turned into a link to the users profile page and display their name, etc, etc.
There is an easier way, you can use the static methods of the ActivityTemplateVariable.
The problem here is that you have to pass in an ActivityTemplateVariable as one of the parameter and there is no obvious way of doing this.
There are no properties or methods in the ActivityEvent that give you an ActivityTemplateVariable or is there?
After further investigation of the TemplateVariable string property, you can see that this actually returns an XML string. This XML string is a de-serialised ActivityTemplateVariable object.
1
var variable = (ActivityTemplateVariable)FromXml(item.TemplateVariable, typeof(ActivityTemplateVariable));
You can now use this object to pass to the static method of ActivityTemplateVariable to return the full HTML representation of the tag.
If you then combine that with SimpleTemplateFormat you can then loop round all the tags and replace them.
1
var items = SimpleTemplateFormat.SimpleParse(formatToUse);