Fixing SharePoint Promoted Link's New Tab Launch Behaviour

There's a bug with Promoted Links web part in SharePoint 2013 and Office 365, and I have no idea how long it's been there.  This post is about a simple Javascript hack that will fix it.

Story

Robert Crane [O365 MVP] found this one.  Out of the blue, he asks me, hey why does the new tabs always open in the same tab.  I thought, no way, it's always been working.  So naturally, I tried it out and check the anchor tag.  Expecting target="_blank"

TLDR

  • Promoted Links spits out target="="_blank"" for who knows how long.
  • Here's a fix.

SharePoint's Promoted Links Web Part

A quick introduction - this is the Promoted Links web part.  If you have seen SharePoint 2013 or Office 365's SharePoint Online, then you have seen this web part.  It is used everywhere for navigation.

Promoted Link is backed by a regular SharePoint list.  Of interest, is the Launch Behavior column, which lets you decide what to do when a tile is clicked.  You can navigate there Replacing Current Page, open in SharePoint's ModalDialog, or open in a New tab.

Open in New Tab

To prevent spammers running Javascript and creating lots of tabs when you visit a page, a browser gives no way for Javascript to create new tabs.  The only way to do this, is natively in HTML, the tag looks like this:  <a href="http://bing.com" target="_blank">B</a>

SP2013 (and Office 365) spits out this:

What would this do?  Because target="="_blank"" is not a name that the browser knows about, it thinks, ah you want a new tab target, with that name.  So the first click is OK, you navigate to that URL in a new tab.

The problem is that if you click multiple Promoted Tiles, they will all be loaded in the same tab, instead of different new Tabs.

IE11 seems OK

Curiously, IE11 creates new tabs.  This however doesn't work on Edge.

Culprit

There seems to be a bug in sp.ui.tileview.js, adding target= into the attribute.

 

Fix

This can be fixed with a small bit of Javascript.

Edit the Page where your promoted list is giving you trouble.  Say the Home Page.  Insert "Embed Code" in a Web Part zone at the bottom of the page.  (This short cut creates a Script Editor Web Part there)

Paste this bit of script.

<script language='javascript'>
function fixATargetBlank(){
    // using sharepoint's mQuery because I don't want to have
    // dependency on jQuery in this quick script
    m$("a[target][clickaction]").forEach(function(a){a.target="_blank";});
}
// add this function to tell SharePoint to run it on load
_spBodyOnLoadFunctionNames.push("fixATargetBlank");
</script>

The Script Editor will appear blank.  That's OK.  Save the page.

Result

The target is fixed to _blank.  And clicking on the promoted links now opens them in separate new tabs, in all browsers.



AngularJS - Logging Client Side Stack Trace

Nothing beats mythical errors that happens on the client side, but not on your development machine.

This post follows yesterday's post on configuring AngularJS to catch and log exceptions back to the server via an AJAX request.

Logging the Exception

Quick recap of the logging error handler.

function error(message, data, title) {
  var $http = angular.injector(['ng']).get('$http');

  data['url'] = document.location.href;

  var request = {
    method : 'POST',
    url: _spPageContextInfo.webServerRelativeUrl + '/_layouts/MyService.svc/LogMessage',
    data: {
      'log': message,
      'meta': angular.toJson(data),
      'evt': 'error'
    },
    dataType: "json",
    headers: {
      "Content-Type": "application/json; charset=utf-8"
    }
  };
  var promise = $http(request);            

  promise['catch'](function(){
    // send to console log if can't log to webservice
    $log.error('Error: ' + message, data);
  });
}
{
  "exception":
  {
    "message":"[NG-Modular Error] Unable to get property 'Client' of undefined or null reference",
    "description":"Unable to get property 'Client' of undefined or null reference",
    "number":-2146823281
  },
  "url":"http://server/_layouts/MY/form.aspx?IsDlg=1&id=3#/client"
}

Yay it's logging.  But actually, it's pretty hard to figure out where the error is coming from.  "What we need", says the developer to the tester, "is the stack trace.  If we have the stack trace we'd be able to repo this and fix it."

stacktraceJS

Grab: https://github.com/stacktracejs/stacktrace.js/blob/stable/stacktrace.js

function error(message, data, title) {
  var $http = angular.injector(['ng']).get('$http');
  
  // work out the stacktrace
  var stack = printStackTrace({e: data, guess: true});
  data['stack'] = stack;
  // attach to the logged data

  data['url'] = document.location.href;

  var request = {
    //...
  };
  var promise = $http(request);            
}

The following JSON gets logged.

{
  "exception":
  {
    "message":"[NG-Modular Error] Unable to get property 'exist' of undefined or null reference",
    "description":"Unable to get property 'exist' of undefined or null reference",
    "number":-2146823281
  },
  "url":"http://server/_layouts/MY/form.aspx?IsDlg=1&id=138#/",
  "stack":
  [
    "{anonymous}(#object,\"other\")",
    "printStackTrace(#object)",
    "error(\"[NG-Modular Error] Unable to get property 'exist' of undefined or null reference\",#object)",
    "{anonymous}(?)",
    "{anonymous}(#function)",
    "{anonymous}(#object)",
    "{anonymous}(#object)",
    "{anonymous}(?)"
  ]
} 

Tweaks

"Those #function and #object, they look pretty stupid." commented the developer, who now has the stacktrace, but still thinks it doesn't help all that much.

A few hacks to stacktrace.js

if (arg.constructor === Object) {
  //result[i] = '#object';
  result[i] = arg.constructor.toString();
}
...
else {
   //result[i] = '?';
   result[i] = arg.constructor.toString();
}

This gives more readable stacktrace:

{
  "exception":
  {
    "message":"[NG-Modular Error] Unable to get property 'exist' of undefined or null reference",
    "description":"Unable to get property 'exist' of undefined or null reference",
    "number":-2146823281
  },
  "stack":
  [
    "{anonymous}(\nfunction Object() {\n    [native code]\n}\n,\"other\")",
    "printStackTrace(\nfunction Object() {\n    [native code]\n}\n)",
    "error(\"[NG-Modular Error] Unable to get property 'exist' of undefined or null reference\",\nfunction Object() {\n    [native code]\n}\n)",
    "{anonymous}(TypeError)",
    "{anonymous}(#function)",
    "{anonymous}(\nfunction Object() {\n    [native code]\n}\n)",
    "{anonymous}(\nfunction Object() {\n    [native code]\n}\n)",
    "{anonymous}([object Event])"
  ],
  "url":"http://server/_layouts/MY/form.aspx?IsDlg=1&id=138#/"
}

 

Future of stacktraceJS

The latest version of StacktraceJS (as of August 2015) is marching towards a full promise/A pattern where you request the stacktrace, and it returns you a promise.  Then promise resolves to an array of stack frames, which you can then pretty-print to your own liking.

var callback = function(stackframes) {
    var stringifiedStack = stackframes.map(function(sf) { 
        return sf.toString(); 
    }).join('\n'); 
    console.log(stringifiedStack); 
};
var errback = function(err) { console.log(err.message); };
StackTrace.get().then(callback, errback)

Expect the syntax to change, soon.

I'm also assuming for modern browsers, it'll even look into requesting SourceMap files for minified versions of JavaScript/TypeScript and resolve those lines in the stacktrace produced.   Currently though, it doesn't do all those things.  What it does manage so far, cross browser, is already fairly impressive and a great starting point.

 

 

AngularJS $http and logger Circular Dependency

Error

[$injector:cdep] Circular dependency found: $rootScope <- $http <- logger <- $exceptionHandler <- $rootScope <- $location <- routehelper

TLDR:

If you are looking for RequireJS's easy way to late-bind a dependency, here's how you do it in AngularJS.

var $http = angular.injector(['ng']).get('$http');

Story

I imagine this could be a fairly common scenario.

You implemented AngularJS, with a logger service.

 angular
     .module('blocks.logger')
     .factory('logger', logger);

 logger.$inject = ['$log'];
 function logger($log) {
        var service = {
            error   : error,
            log     : $log.log
        };
        return service;

        /////////////////////

        function error(message, data, title) {
            $log.error('Error: ' + message, data);
        }
}

You want to try to catch errors and log it back to your webservice
So add a REST call.

logger.$inject = ['$log', '$http'];
function logger($log, $http) {
    // snip.

    function error(message, data) {
        $log.error('Error: ' + message, data);

        var request = {
            method : 'POST',
            url: _spPageContextInfo.webServerRelativeUrl + '/_layouts/MyLogService.svc/LogMessage',
            data: {
                'log': message,
                'event': 'error',
                'meta': angular.toJson(data)
            },
            dataType: "json",
            headers: {
                "Content-Type": "application/json; charset=utf-8"
            }
        };
        $http(request);           
    }
}


Error!

[$injector:cdep] Circular dependency found: $rootScope <- $http <- logger <- $exceptionHandler <- $rootScope <- $location <- routehelper

I actually quite like this error.  It's telling me the dependency tree starting from routehelper and looping around to $rootScope

Bingle will tell you to re-architect your solution so you have proper separation of concerns.  But to be honest, I just want to log an error…

So here's how you do it.

var ng = angular.injector(['ng']);
var $http = ng.get('$http');

Final Code Summary

logger.$inject = ['$log'];
function logger($log) {
    // snip.

    function error(message, data) {
        $log.error('Error: ' + message, data);
        
        // inject $http here.
        var $http = angular.injector(['ng']).get('$http');
        if (!$http) return;
            var request = {
                method : 'POST',
                url: _spPageContextInfo.webServerRelativeUrl + '/_layouts/MyLogService.svc/LogMessage',
                data: {
                    'log': message,
                    'event': 'error',
                    'meta': angular.toJson(data)
                },
                dataType: "json",
                headers: {
                    "Content-Type": "application/json; charset=utf-8"
                }
            };
            $http(request);           
        }
}

PhantomJS and Office 365 Brisbane

I thoroughly enjoyed my trip up to Brisbane.  I got to see some friends, saw my sister and presented PhantomJS with SharePoint and SharePoint Online.

 

The response was tremendous, those that came to my session seems really interested and gave me more ideas for future discussions. 

The presentation and demo are here

The new business case scenarios:

  • Automated Smoke Testing of sites - randomly check pages and DOM exist - "nothing has so far blown up"
  • Smoke Testing of sites with different credentials!
  • Scanning and scrapping text off dynamic (Single Page Application) pages and feed them to SharePoint's Search Indexing Service.  Thus, you can have textual search linking back to dynamic URLs.

I also really enjoyed Elaine's session on SharePoint 2016.


The next Office 365 Saturday in Australia will be in Melbourne.  The registration is up here:

http://www.o365saturdayaustralia.com/melbourne

AngularJS Extend and Computed Property

This is an Intermediate level discussion on AngularJS Extend, Computed Property in general, and a fairly complicated binding bug that needs some new learning.

 

I came from a KnockoutJS world.  In the KnockoutJS world which I'm familiar with, there is the concept of a Computed Property.

KnockoutJS Computed Property

Because native Javascript objects does not raise OnPropertyChanged events, KnockoutJS wraps properties with an observable wrapper.
A class would look like this:

function PersonViewModel(){
  var self = this;
  
  self.firstName = ko.observable("");
  self.lastName = ko.observable("");
  
  self.fullName = ko.computed(function(){
    return [
      ko.unwrap(self.firstName), 
      ko.unwrap(self.lastName)
    ].join(" ");
  });
}

The developer takes care of the properties firstName and lastName, and KnockoutJS understands when firstName or lastName changes, fullName needs to be re-evaluated.

AngularJS binds Native Javascript Object

In AngularJS, there is no direct concept of a Computed Property, instead, AngularJS watches various binding expressions and automatically re-evaluate expressions.  Since AngularJS binds to native Javascript directly, it employs a dirty checking mechanism.

A native person might look like this:

var person = {
  firstName: "",
  lastName: ""
};

And a View would look like this:

<div>
  First Name: {{ person.firstName }}
  Last Name: {{ person.lastName }}
  Full Name: {{ person.firstName + ' ' + person.lastName }}
</div>

Defining Class for AngularJS

But very quickly, one would find that this gets very unwieldy, and you end up duplicating expressions everywhere in your View.  Duplicated code is never good.  So we need to tidy up the definition of the model a bit more.

function Person(){
  var self = this;
  
  self.firstName = "";
  self.lastName = "";
  
  self.fullName = function(){
    return [self.firstName,self.lastName].join(" ");
  };
}

AngularJS Extend

Such definitions are nice and good, but you will need a way to populate these when you pull your data back from a REST service as JSON.  Luckily, AngularJS provides both angular.extend as well as angular.merge helper methods to get you going quickly. 

If you are familar with jQuery, this is nearly identical to jQuery.extend, it copies the properties from the later objects onto the first one.

var jsonJohn = { firstName: "John", lastName: "Liu" };
var personJohn = angular.extend( new Person(), jsonJohn );

// personJohn should have firstName, lastName 
// and fullName() == 'John Liu'

The updated View is then:

<div>
  First Name: {{ person.firstName }}
  Last Name: {{ person.lastName }}
  Full Name: {{ person.fullName() }}
</div>

Extending Arrays

When you have a collection of people, the code needs to look like this:

var people = [];
var jsonPeople = [
  { firstName: "John", lastName: "Liu" },
  { firstName: "Mark", lastName: "Liu" }
];
people = angular.extend( people, jsonPeople );
<div data-ng-repeat="person in people">
  First Name: {{ person.firstName }}
  Last Name: {{ person.lastName }}
  Full Name: {{ person.fullName() }}
</div>

This looks good initially, except there are a few problems:

  • angular.extend does not know anything about Person class, and fullName is not available
var people = [
  new Person(),
  new Person()
];
var jsonPeople = [
  { firstName: "John", lastName: "Liu" },
  { firstName: "Mark", lastName: "Liu" }
];
people = angular.extend( people, jsonPeople );
  • angular.extend will override people[0] with jsonPeople[0] (not null).  So the result still does not define fullName()

So you need something like this:

var people = [
  new Person() // always show one row by default even if blank
];
var jsonPeople = [
  { firstName: "John", lastName: "Liu" },
  { firstName: "Mark", lastName: "Liu" }
];
var l = jsonPeople.length > people.length ? jsonPeople.length : people.length;
for (var i=0; i < l; i++){
  people[i] = angular.extend( new Person(), people[i], jsonPeople[i] );
  // copy jsonPeople[i] (if not null) onto people[i]
  // copy people[i] onto new Person()
  // store it back on people[i]
}

A subtle bug in copying with extend

The Computed Property doesn't work because the way angular.extend copies references, people[i].fullName is copied to (new Person()).fullName, which means that the (new Person()).fullName is calculating from the previous people[i].fullName, because it uses the 'self' reference.

(let me know if I need to draw a picture)

There are two ways to fix this:

1. We can fix how we extend existing records.

//...
for (var i=0; i < l; i++){
  people[i] = angular.extend( people[i] || new Person(), jsonPeople[i] );
  // OR  people[i] and new Person()
  // so that if there is an existing Person object, it is kept
}

2. We can fix how we define the Person class.  I think this is the better method.

function Person(){
  var self = this;
  
  self.firstName = "";
  self.lastName = "";
  
  Person.prototype.fullName = function(){
    // re-connect 'self' to the current object
    var self = this;
    return [self.firstName,self.lastName].join(" ");
  };
}

This bumps the fullName method to the prototype/supertype, so it is no longer an immediate property defined on the Person object.

Summary

  • Notes on doing computed property in AngularJS
  • How to define a Class for AngularJS
  • How to handle extend and extend an array
  • How to move computed methods to the prototype
  • Plunker http://plnkr.co/edit/HcuxnT3EAVrtz1PyWwXb check out the two Count1 and Count2 computed properties and how they work differently after you clicked 'update'