The easiest way to add Script and Brand your SharePoint and SharePoint Online

This is the first of a few blog posts I wanted to cover in my 2015 Christmas break on the future of branding. 

I'll probably devote the next article to compare the various different ways to brand your SharePoint site - masterpage?  CustomCss?  On-premises or Online?  Would the technique work in SharePoint 2010?  What about display templates or JSLink?

But I want to march forward and cover what I think is the safest way to brand your SharePoint right now:

Inject JavaScript to any Site or Site Collection via a User Custom Action's ScriptLink property.

This is a great technique because it lets you:

  • Add JavaScript anywhere - scoped to Site Collection or Site.
  • Add CSS via JavaScript
  • You can add more than 1 CSS file
  • Order them the way you want via Sequence
  • You can combine this to load your initial JavaScript file which can be a RequireJS setup and then hand off the controls to RequireJS config
  • Does not modify MasterPage
  • Works in SharePoint 2010, 2013, 2016 and SharePoint Online
  • Only need Site Collection permissions to set up - you don't need to have a Farm Solution or Add-In Model.  The permission is only required to set up the ScriptLink.
  • The object model provides a way for an administrator to check all the User Custom Actions attached to any site/site collection, so there's a level of oversight available if you want to check if your customizations are ready for migration.

There are various ways to attach a script via User Custom Actions.

  • Remote Provisioning (Pattern and Practice) uses it via C# CSOM
  • PowerShell remote provisioning
  • Farm Solution can invoke the API
  • Sandbox Solution can invoke the Client Side Object Model API (*with permission)
  • Add-In can invoke the CSOM API as well (with Site Collection - Full Control permission)

The unfortunate part is, there's no UI for a power user to add or view ScriptLinks directly.  You need to spin up SharePoint Manager or read it via PowerShell.

And that brings me to today's post. 

I build a simple config page in JavaScript. 

Then I did a load of work to make sure everything runs from One Page.

How does it work?

  1. Drop it into a SiteAssets or SitePages library.
  2. he JavaScript on the page detects and loads some dependencies (jQuery, SP.js etc). 
  3. Provided you have site collection permissions, it'll list all existing User Custom Actions
  4. You can specify a filename (including any subfolders like spg/hello.js) and give it a sequence number (default to 1000).  Then you can install a Custom Action to Site Collection or Current Web.  All via the magic of JavaScript.

I also brand it to look a bit like SharePoint.  Just a bit.

This is still a developer blog.  So we're going to talk about code:

function listUserCustomAction(siteOrWeb) {
    siteOrWeb = (siteOrWeb=="site"? "site":"web");
    // ajax call to userCustomActions and order by Sequence
    // this function can do either _api/site or _api/web
    var p1 = $.ajax({
        url: hostweburl + "/_api/"+siteOrWeb+"/userCustomActions?$orderby=Sequence",
        dataType: "json",
        contentType: 'application/json',
        headers: { "Accept": "application/json; odata=verbose" },
        method: "GET",
        cache: false
    });
    
    p1.then(function (response) {
        // use jQuery to do a bit of simple UI update
        $("ul#"+siteOrWeb+"-user-custom-actions").empty();
        $.each(response.d.results, function (i, result) {
            $("ul#"+siteOrWeb+"-user-custom-actions").append(
                "<li>" +
                    " [" + result.Location + "] " +
                    (result.Title || result.Name || "") +
                    " ScriptSrc=" + result.ScriptSrc +
                    " Sequence=" + result.Sequence +
                "</li>");
        });
    });
    return p1;
}

First function.  listUserCustomAction will show existing Custom Actions attached to either the site collection or the current web.  Interestingly - this will also list custom actions attached by other solutions you may have installed in your farm.

 

spg.installUserCustomAction = function(siteOrWeb) {
    // switch to JSOM to install userCustomActions
    var webContext = SP.ClientContext.get_current();
    var userCustomActions;
    if (siteOrWeb == "site") {
        userCustomActions = webContext.get_site().get_userCustomActions();
    }
    else {
        userCustomActions = webContext.get_web().get_userCustomActions();
    }
    webContext.load(userCustomActions);

    // read srcurl and sequence from textboxes
    srcurl = $("#scriptlink-name").val();
    srcsequence = parseInt($("#scriptlink-sequence").val()) || 1000;

    var action = userCustomActions.add();
    action.set_location("ScriptLink");
    action.set_title(srcurl);
    // my tool always attach script from ~sitecollection/SiteAssets/ 
    // you can use subfolders
    // but if you want to use Style Library or some other
    // folder you'll have to change this.
    action.set_scriptSrc("~sitecollection/SiteAssets/" + srcurl);
    action.set_sequence(srcsequence);
    action.update();

    webContext.load(action);
    // my SP.ClientContext has a special promise enabled callback
    // lets me pipe one promise to the next
    return webContext.executeQueryPromise().pipe(function () {
        return spg.listUserCustomActions();
    });
};

Reminder: http://johnliu.net/blog/2015/12/convert-sharepoint-jsoms-executequeryasync-to-promise-in-the-prototype I wrote previously how to change SharePoint JSOM's ExecuteQueryAsync to just return a Promise.  This is important, because you can do stupidly fun things like chaining one asynchronous callback to another one and don't even need to pull out your hair.

 

spg.uninstallUserCustomAction = function(siteOrWeb) {
    var webContext = SP.ClientContext.get_current();
    var userCustomActions = webContext.get_site().get_userCustomActions();

    if (siteOrWeb == "site") {
        userCustomActions = webContext.get_site().get_userCustomActions();
    }
    else {
        userCustomActions = webContext.get_web().get_userCustomActions();
    }
    webContext.load(userCustomActions);

    srcurl = $("#scriptlink-name").val();
                
    var p1 = webContext.executeQueryPromise();
    var p2 = p1.pipe(function () {
        var i = 0, count = userCustomActions.get_count(), action = null;
        for (i = count - 1; i >= 0; i--) {
            action = userCustomActions.get_item(i);
            // look for any script that has the same url as yours
            // and delete only those
            if (action.get_scriptSrc() == "~sitecollection/SiteAssets/" + srcurl) {
                action.deleteObject();
            }
        }
        // more chaining promise fun
        return webContext.executeQueryPromise().pipe(function () {
            return spg.listUserCustomActions();
        });
    });
     return p2;
};

So here we are.  A page that you can use to install and uninstall ScriptLinks from SharePoint Online and SharePoint 2013/2016.

* SharePoint 2010 needs a few tweaks with the _layout/15 path.  Also, the SP.UI.ChromeControl doesn't work so the page will have to look simpler.

What does the result look like?

Here's what it looks like in a browser Network tab

They are not found because they aren't really there!  But you can see it's looking for the files in ~sitecollection/siteassets/hello.js

And the Sequence is important.  I have hello1.js at 999 and hello.js at 1000, here's what they look like in the <head> tag of the SharePoint page.

SharePoint inserted them into the page at runtime, and I never touched the MasterPage.  And that's a big win.

Download

 

https://github.com/johnnliu/UserCustomActionsConfigPage/tree/master/dist

Related Posts

Yet another fix for "App with the same version and product ID" on-premises

"The provided App differs from another App with the same version and product ID"

Ever since the beginning of 2013, we have this this problem when you deploy and redeploy App (now SharePoint Add-in) to either SharePoint 2013 on-premises or Office 365's SharePoint Online.

If you Bingle about - you will find various fixes, in order of severity:

1 - you can try to retry Installing the App, or removing it from Site Content http://www.mavention.nl/blog/provided-App-differs-another-App-same-version-product-ID

2 - try removing it from Site and Site Collection recycle bins.  Also remember the second level recycle bin.  This one is usually good enough for Office 365.

3 - Jeremy Thake says don't use the same farm account http://www.jeremythake.com/2013/10/sharepoint-2013-apps-the-provided-app-differs-from-another-app-with-the-same-version-and-product-id/

4 - You can also just increment the version number.  This is difficult when you work in a team and you don't want to bump _everyone_'s number

5 - Delete the offending Site Collection and create a new one

I found a new, TOTALLY UNSUPPORTED WAY.

Sometimes, you really just want to delete the old one.  But the Object Model doesn't have objects that go that deep.  Hence there are no way to do this via command-line either.

You need AppInstance to perform an uninstall.  If you can't get an AppInstance, but the AppPackage is still in your Site Collection, you are pretty much stuck.

Let's go check out the content database.  Disclaimer: Do this if you are curious.  Also, don't do this on your production.

SELECT [PackageFingerprint]
      ,[ProductId]
      ,[Package]
      ,[Title]
      ,[IsDownloadInvalidated]
      ,[SiteId]
  FROM [WSS_CONTENT_DB].[dbo].[AppPackages]

You want to know which Site Collection it is.  So figure out which AppPackage is the one SharePoint can't delete - by cross-checking the SiteId

PackageFingerprint is an important field that we'll need in a minute.  It acts like a unique identifier.
IsDownloadInvalidated is probably 0 (meaning it is valid).  You just can't delete it.

SharePoint has a few nice store procedures.

exec [WSS_CONTENT_DB].[dbo].proc_App_InvalidatePackage
    @PackageFingerprint, @SiteId

-- example
exec [WSS_CONTENT_DB].[dbo].proc_App_InvalidatePackage
    0x9DB9C067E1B970AD1258E28AE26EC3AE17CF772BC093D0CCF8E1832FF3B171261A09812778830621AA63FD4C9D1EA922DC5432E1CACDEA00B1CDA82F9A28CAE2, 
    '282292D5-3686-460D-9AB4-9354272FB2A1'

This stored procedure will flag the AppPackage as Invalid.  If you do SELECT * FROM AppPackages again, you'll see it is now IsDownloadInvalidated = 1

So SharePoint now thinks there was something wrong with the download of this package :-)Let's invoke SharePoint's auto-immune system and nuke the AppPackage

exec [WSS_CONTENT_DB].[dbo].proc_App_DeleteInvalidatedDownloadApp
    @PackageFingerprint, @SiteId

-- same arguments.  

This one will nuke the AppPackage clean.

You can now go back to PowerShell and redeploy your App.

PS> $App = Import-SPAppPackage -Path <Path> -Site <Url> -Source <Source>
PS> Install-SPApp -Web <Url> -Identity $App 

This works really well.  But you REALLY shouldn't touch the Content DB

 

Convert SharePoint JSOM's ExecuteQueryAsync to Promise in the Prototype

Today's blog is about adding an additional method to SharePoint JavaScript Object Model (JSOM)'s ClientContext object, so we can use it directly like a promise.

I call it "executeQuery" (instead of executeQueryAsync)

Wrapper with jQuery's $.Deferred

SP.ClientContext.prototype.executeQuery = function() {
   var deferred = $.Deferred();
   this.executeQueryAsync(
       function(){ deferred.resolve(arguments); },
       function(){ deferred.reject(arguments); }
   );
   return deferred.promise();
};

Wrapper with AngularJS's $q

SP.ClientContext.prototype.executeQuery = function() {
   var deferred = $q.defer();
   this.executeQueryAsync(
       function(){ deferred.resolve(arguments); },
       function(){ deferred.reject(arguments); }
   );
   return deferred.promise;
};

How do you use this?

var ctx = SP.ClientContext.get_current();
var web = ctx.get_web();
ctx.load(web);
var promise = ctx.executeQuery(); // look!  :-)

promise.done(function(){
  console.log(web.get_title());
});
promise.then(function(sArgs){
  //sArgs[0] == success callback sender
  //sArgs[1] == success callback args
}, function(fArgs){
  //fArgs[0] == fail callback sender
  //fArgs[1] == fail callback args.
  //in JSOM the callback args aren't used much - 
  //the only useful one is probably the get_message() 
  //on the fail callback
  var failmessage = fArgs[1].get_message();
});

This may seem to be just small syntactic sugar, but now you have JSOM returning a promise that you can chain, loop, combine and juggle to your heart's content!

Remember, jQuery.ajax, SPServices, AngularJS, and now JSOM all returns promise objects now.

2015 Xbox One Black Friday Edition

USA

Best Console Deals

http://deals.dell.com/compare/X500GOWFALLTD

$299
Gears of War: Ultimate Edition Bundle (that's 4 games) 
Fallout 4 (and Fallout 3 code next week) (that's 2 more games)
+extra controller
think this one is completely sold out.

http://www.bestbuy.com/site/microsoft-xbox-one-rise-of-the-tomb-raider-bundle-black/4512300.p?id=1219756880977&skuId=4512300

$349
Both Tomb Raiders.  (2 games)
Extra controller. 
1 TB HD (standard is 500 GB).

Xbox Live Gold

Buy 1 month for $1 from within the console.  Then buy 12 month for ~$30 via CDKeys.  Don't buy Xbox Live Gold monthly at $10/month. 

Gold is necessary for online play and you get up to 4 free games per month.  You need to go buy Xbox Live Gold.

EA Access

If you like EA's many games: Dragon Age, Battlefield, Titanfall, PvZ, Need for Speed, and their endless line of sports games.  Then pop down $30/year for EA Access all-you-can-eat pass.

Accessories

http://www.microsoftstore.com/store/msusa/en_US/list/categoryID.70508400?icid=XboxOneR_ModD_BFCM_WirelessControllers_112615

Multiple special controllers on sale.

Hard Drive

Standard console comes with 500GB.  This is not enough.  But Xbox One can use up to two more external harddrives at once.  I prefer 2TB Western Digital or Seagate portable harddrives - they can draw power from the XB1 directly and you don't need an extra plug.  Because of XB1's Hyper-V setup, external HD actually runs faster than internal HD.  These are regularly around US$65 and dropping.

Games

I buy Digital. 
http://majornelson.com/2015/11/26/black-friday-xbox-digital-game-deals/

There's extra discounts if you are an Xbox Live Gold member, which is $1 for this month.

Kinect, TV Tuner

If you are on the edge about whether you need these, pass.  They have their place and I love them, but Black Friday is about awesome deals and you can skip these.

 

Enabling LastPass bookmarklet with Microsoft Edge

One of the problems of Microsoft's Edge browser not supporting Extensions right now is that password management with tools like LastPass can be quite difficult.

Here is a workaround with LastPass via Bookmarklets

You need the help of another browser - either IE or Chrome. 
Because Edge doesn't currently have a way to create/modify bookmarklets

1. In IE/Chrome.  Login to your LastPass https://lastpass.com/index.php?ac=1

2. Browser down Tools > Bookmarklet

 

 3. In IE/Chrome.  Drag LastPass Fill! Into the Favourites Bar (IE) or Bookmark Bar (Chrome)
 4. In Edge, go to Settings > Show the favorites bar > On
 5. Import from another browser - choose either IE or Chrome
 6. In Edge.  Once import is complete, drag the bookmarklet into the Favourite Bar.  I also deleted other favourites that I didn't need from the import.

7. You now have a Lastpass bookmarklet!

 

Summary

This works for Twitter and Reddit

I also hope as Favourites gets synchronized across Universal Windows Devices - this should work on Xbox One and Windows Mobile 10 as well.