A common question in SharePoint forums is how do I load SuchAndSuch.js on every page in the site collection (ok, let’s be honest, it’s usually how to I load jQuery on every page). This is pretty easy to do using the SharePoint client object model and setting something called UserCustomActions. I showed using this utility page in my Accordion View, Custom List View CSR Template. In this post, I’m going to show what’s going on behind the curtains in that utility page. It will be an ASPX page (really just a text file) that you can drop in any SharePoint document library and click on to start immediately configuring UserCustomActions at either the site or the web level. It has no dependencies. It is a self contained page with only HTML and pure JavaScript.
When you put it in a document library and click on it, it looks like so:
All you have to do is type one path to a JavaScript or CSS file per line (in either the site or web), into the text area. By default it operates on UserCustomActions at the site level (i.e. site collection). If you want it to get/set user custom actions at the web level, change the selected scope value. Note that in order to ensure that only files in the site or web are loaded, it skips any path that doesn’t begin with ~site or ~sitecollection. And while there may be some clever exceptions, in general any site user custom action should start with ~sitecollection, and any web user custom action should start with ~site. The utility page doesn’t enforce that, but it will skip any path that doesn’t begin with one or the other. Anyway, once you’ve typed in all of the paths you want, click the save button and it will save the user custom actions and popup a message box on success or failure. If it fails, you probably don’t have sufficient privileges to set user custom actions at the current scope.
I try to avoid doing “here’s a big block of code” kind of posts, but sometimes it’s hard to break up a big block of code and explain it in isolated chunks. In those cases, a big block of code with a lot of comments can be somewhat self-documenting, so that’s what I’ve done below. This is the complete source code for my ASPX page. Just copy and paste the whole thing into notepad and save it with a .aspx extension. Upload that to a SharePoint document library and click on it to open it and start configuring UserCustomActions. Hopefully, I’ve included enough comments that if you know some JavaScript you can work your way through what it’s doing if you care.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 | <%@ Assembly Name="Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> <%@ Page Language="C#" Inherits="Microsoft.SharePoint.WebPartPages.WikiEditPage" MasterPageFile="~masterurl/default.master" %> <%@ Register TagPrefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %> <%@ Import Namespace="Microsoft.SharePoint" %> <asp:Content ContentPlaceHolderID='PlaceHolderPageTitle' runat='server'> Scriptlink Settings </asp:Content> <asp:Content ContentPlaceHolderID='PlaceHolderPageTitleInTitleArea' runat='server'> </asp:Content> <asp:Content ContentPlaceHolderID='PlaceHolderAdditionalPageHead' runat='server'> <meta name='CollaborationServer' content='SharePoint Team Web Site' /> <style type="text/css"> .settingsheader { font-family: "SegoeUI-SemiLight-final", "Segoe UI SemiLight", "Segoe UI WPC Semilight", "Segoe UI", Segoe, Tahoma, Helvetica, Arial, sans-serif; font-size: 1.8em; color: darkslategray; margin-bottom: 20px; } .ms-status-yellow { display: none !important; } .scriptLinksdiv { margin-top: 20px; margin-bottom: 30px; } label { display: inline-block; width: 5em; } .buttun-div { text-align: right; width: 700px; } button.settings-button { font-size: 1em; } </style> </asp:Content> <asp:Content ContentPlaceHolderID='PlaceHolderMiniConsole' runat='server'> <SharePoint:FormComponent TemplateName='WikiMiniConsole' ControlMode='Display' runat='server' id='WikiMiniConsole'> </SharePoint:FormComponent> </asp:Content> <asp:Content ContentPlaceHolderID='PlaceHolderLeftActions' runat='server'> <SharePoint:RecentChangesMenu runat='server' id='RecentChanges'></SharePoint:RecentChangesMenu> </asp:Content> <asp:Content ContentPlaceHolderID='PlaceHolderMain' runat='server'> <div class='settingsheader'>Scriptlinks</divp> <div> Scope: <select id="scope"> <option value="site">Site Collection</option> <option value="web">Site</option> </select> </div> <div class="scriptLinksdiv"> Script files to load: <div> <textarea title="Enter paths to additional JavaScript and/or CSS files to load." id='scriptLinks' rows='10' cols='100'></textarea> </div> </divp> <div class="buttun-div"> <button id="saveButton" type="button" class="settings-button">Save</button> </divp> <script type="text/javascript"> (function () { if (!window.intellipoint) window.intellipoint = {}; //////////////////////////////////////////////////////////////////////////////// // Form code behind class //////////////////////////////////////////////////////////////////////////////// intellipoint.scriptlinkSetter = { scriptlinks: [], //////////////////////////////////////////////////////////////////////////////// // Initialize the SharePoint object model context, and populate the script link // text area with the current script links. //////////////////////////////////////////////////////////////////////////////// init: function () { scriptlinkSetter.initClientContext(function () { scriptlinkSetter.getScriptlinks(scriptlinkSetter.arrayToTextArea); // on scope change, modify and scriptlinkSetter.userCustomActions to point to // the web or site as selected, and reinitialize the script link text area document.getElementById("scope").onchange = function (e) { var scope = document.getElementById("scope").value; if (scope === "web") { scriptlinkSetter.userCustomActions = scriptlinkSetter.webUserCustomActions; scriptlinkSetter.getScriptlinks(scriptlinkSetter.arrayToTextArea); } else { scriptlinkSetter.userCustomActions = scriptlinkSetter.siteUserCustomActions; scriptlinkSetter.getScriptlinks(scriptlinkSetter.arrayToTextArea); } }; var button = document.getElementById("saveButton"); // on click, set the script links; note: all existing script links are deleted // and new ones are added from scratch, in the order they're listed button.onclick = function (e) { e = e || window.event; // when save is clicked, first delete all script links (that look like ours, based on title) scriptlinkSetter.deleteScriptlinks(function () { // reinitialize the scriptlinks property to an empty array scriptlinkSetter.scriptlinks = []; // once delete has succeeded, get the value of the text area var value = document.getElementById("scriptLinks").value.trim(); if (value.length > 0) { // split it into an array of file names var files = value.split("\n"); // loop through the file names for (var i = 0; i < files.length; i++) { var file = files[i]; if (file.trim().length > 0) { file = file.trim(); // if the file name looks like a js or css file, add it to the scriptlinks // property, which will be used by addScriptLinks if (/\.js$/.test(file) || /\.css$/.test(file)) { scriptlinkSetter.scriptlinks.push(file); } } } } // call addScriptLinks, which will add all the script links in the scriptlinks property scriptlinkSetter.addScriptlinks(function () { alert("Scriptlinks successfully saved."); }); }); }; }, scriptlinkSetter.error); }, //////////////////////////////////////////////////////////////////////////////// // Add a script link for each line on the script link text area. Note: lines // that do not begin with ~sitecollection or ~site and do not end with .js or .css will be skipped // intentionally. //////////////////////////////////////////////////////////////////////////////// addScriptlinks: function (callback) { var count = 0; var suuid = SP.Guid.newGuid(); // loop through the array of files for which we want to add script links for (var i = 0; i < scriptlinkSetter.scriptlinks.length; i++) { var file = scriptlinkSetter.scriptlinks[i]; // skip any files that don't start with ~site or ~sitecollection (because adding files outside // of the site collection can break the site), and also skip files that don't end in .js or .css // (because those are the only files we know how to turn into valid script link custom actions) if ((/\.js$/.test(file) || /\.css$/.test(file)) && (/^~sitecollection/.test(file) || /^~site/.test(file))) { count++; // create a new custom action on the active collection (either site or web) var newAction = scriptlinkSetter.userCustomActions.add(); // set the location to ScriptLink newAction.set_location("ScriptLink"); if (/\.js$/.test(file)) { // if JavaScript, set the script source property to point to the file newAction.set_scriptSrc(file + "?rev=" + suuid); } else { // if css, first convert ~site/~sitecollection to an absolute url var css = file.replace(/~sitecollection/g, _spPageContextInfo.siteAbsoluteUrl). replace(/~site/g, _spPageContextInfo.webAbsoluteUrl); // then set the script block element to a line of JavaScript that calls document.write // to create a new link element in the DOM to load the CSS newAction.set_scriptBlock( "document.write(\"<link rel='stylesheet' type='text/css' href='" + css + "'>\");"); } // set the sequence number to something high to load them in the correct newAction.set_sequence(59000 + i); // set the title to something we can recognize later as ours in getScriptLinks newAction.set_title("Scriptlink Setter File #" + i); newAction.set_description("Set programmaically by SetScriptlink.aspx."); // update the script link (the action doesn't actually occur until executeQueryAsync is called, // at which time all of the updates occur in a batch operation) newAction.update(); } } if (count) { // if we had anything to save, execute all of the updates now scriptlinkSetter.clientContext.executeQueryAsync(callback, scriptlinkSetter.error); } else { // otherwise, just call the success callback callback(); } }, //////////////////////////////////////////////////////////////////////////////// // Delete script links who's titles look like they were set by me. //////////////////////////////////////////////////////////////////////////////// deleteScriptlinks: function (callback) { // userCustomActions points to either site or web user custom actions, depending // on which is choosen in the scope select, either way, it was already initialized // in initClientContext (i.e. executeQueryAsync was called on it), so we're ready // to call getEnumerator() on it, which can be called repeatedly var enumerator = scriptlinkSetter.userCustomActions.getEnumerator(); var toDelete = []; // enumerator the user custom actions while (enumerator.moveNext()) { var action = enumerator.get_current(); // if the title matches our pattern, add it to the toDelete array (we can't just call // deleteObject now, because modifying the enumerable while we're enumerating is a no no) if (/^Scriptlink Setter File #/.test(action.get_title())) { toDelete.push(action); } } // if the toDelete array isn't empty if (toDelete.length > 0) { // loop through the toDelete array for (var i = 0; i < toDelete.length; i++) { // call deleteObject on each user custom action in the toDelete array (again the action // doesn't happen until executeQueryAsync is called, and then all deletes happen in batch) toDelete[i].deleteObject(); } // execute all of the delete operations now scriptlinkSetter.clientContext.executeQueryAsync(callback, scriptlinkSetter.error); } else { // if nothing to delete, just call the success callback callback(); } }, //////////////////////////////////////////////////////////////////////////////// // Get script links who's titles look like they were set by me. //////////////////////////////////////////////////////////////////////////////// getScriptlinks: function (callback) { // userCustomActions points to either site or web user custom actions, depending // on which is choosen in the scope select, either way, it was already initialized // in initClientContext (i.e. executeQueryAsync was called on it), so we're ready // to call getEnumerator() on it, which can be called repeatedly var enumerator = scriptlinkSetter.userCustomActions.getEnumerator(); // this is the array of objects, with p equal to a file path and s equal to the sequence number var tmp = []; // an array of file paths, already sorted by sequence number, and passed to the success callback var result = []; // enumerator the user custom actions while (enumerator.moveNext()) { // get the next user custom actions var action = enumerator.get_current(); // test the title string to see if we put it there, if not, skip it if (/^Scriptlink Setter File #/.test(action.get_title())) { // get the script source property from the user custom action var path = action.get_scriptSrc(); if (path) { // if there is a path, it is a script link custom action (i.e. JavaScript file) if (path.indexOf("?") > 0) path = path.substr(0, path.indexOf("?")); // now push the path onto the tmp object array tmp.push({ p: path, s: action.get_sequence() }); } else { // if not, it is a script block custom action, which is a document.write call to create // a link element in the DOM to load a CSS file var scriptBlock = action.get_scriptBlock(); // parse the href attribute value from the link element using a regular expression var regexp = new RegExp("href=\'([^\']*)\'", "i"); var matches = scriptBlock.match(regexp); if (matches && matches.length >= 2) { path = matches[1]; // in the case of CSS, we need to replace ~site/~sitecollection with the appropriate // absolute URL, because the browser isn't going to recognize those expressions var sitecollectionregexp = new RegExp(_spPageContextInfo.siteAbsoluteUrl, "g"); var siteregexp = new RegExp(_spPageContextInfo.webAbsoluteUrl, "g"); path = path.replace(sitecollectionregexp, "~sitecollection"). replace(siteregexp, "~site"); // now push the path onto the tmp object array tmp.push({ p: path, s: action.get_sequence() }); } } } } // sort the tmp object array by sequence number tmp = tmp.sort(function (a, b) { if (a.s < b.s) return -1; if (a.s > b.s) return 1; return 0 }); // now convert it to an array of paths, the sequence number is tossed, but since they're in the // correct order now that's irrelevant for (var i = 0; i < tmp.length; i++) { result.push(tmp[i].p); } // call back the success method, passing an array of paths sorted by sequence number callback(result); }, //////////////////////////////////////////////////////////////////////////////// // Initialize the sharepoint object model, including site, web, and userCustomActions. //////////////////////////////////////////////////////////////////////////////// initClientContext: function (success, failure) { if (!scriptlinkSetter.clientContext) { // create the client context scriptlinkSetter.clientContext = new SP.ClientContext(); // get the site collection context scriptlinkSetter.site = scriptlinkSetter.clientContext.get_site(); // get the site collection user custom actions scriptlinkSetter.siteUserCustomActions = scriptlinkSetter.site.get_userCustomActions(); scriptlinkSetter.clientContext.load(scriptlinkSetter.siteUserCustomActions); // default to operating on the site user custom actions scriptlinkSetter.userCustomActions = scriptlinkSetter.siteUserCustomActions; // get the web context scriptlinkSetter.web = scriptlinkSetter.clientContext.get_web(); // get the web user custom actions scriptlinkSetter.webUserCustomActions = scriptlinkSetter.web.get_userCustomActions(); scriptlinkSetter.clientContext.load(scriptlinkSetter.webUserCustomActions); // execute both of the previous load operations as one batch operation scriptlinkSetter.clientContext.executeQueryAsync(success, failure); } else { // if we've already been called, just call the success callback success(); } }, //////////////////////////////////////////////////////////////////////////////// // Failure callback for all async calls. //////////////////////////////////////////////////////////////////////////////// error: function (sender, args) { alert("Oops, something bad happened...\n\n" + args.get_errorTypeName() + ": " + args.get_message() + " (CorrelationId: " + args.get_errorTraceCorrelationId() + ")"); }, //////////////////////////////////////////////////////////////////////////////// // Utility method to convert an array of links into text area input. //////////////////////////////////////////////////////////////////////////////// arrayToTextArea: function (lines) { if (lines) { // if the lines array isn't empty, join it on newline and shove the result into the text area document.getElementById("scriptLinks").value = lines.join("\n"); } else { // if it is empty, justs empty out the text area document.getElementById("scriptLinks").value = ""; } } }; var scriptlinkSetter = intellipoint.scriptlinkSetter; })(); SP.SOD.executeFunc("sp.js", "SP.ClientContext", function () { // once the SharePoint client context has been initialized, initialize our UI intellipoint.scriptlinkSetter.init(); }); </script> </asp:Content> |
I like to make utility pages like this for performing common administrative tasks that are not available through the OOB browser based interface for SharePoint. It’s really just a SharePoint wiki page. I often see people doing these tasks in deployment scripts using C# or Powershell and the server object model or client object model. The server object model is great for these kind of tasks if you are a farm administrator or can get a farm administrator to run it. If not, the client object model is an option, but many of these tasks are routinely needed by plain-old site collection administrators, who may not even have the authority to install the client object model on their corporate desktop. And generally do not have the skills and tools to do C# and PowerShell. But drop this text file in a document library, open it, fill in this text box, and click this button are instructions that should be easy enough for any SCA to follow. Hope someone finds this useful!