Get folder contents of large lists using REST

If you have a large lost with more items than the list view threshold you may want to use folders to scope the contents.

It’s possible to get just the contents of a folder using REST before applying a filter and cheat the threshold. The trick is to do a POST and set the CAML query for the view in the body. Additionally set the FolderServerRelativeUrl parameter to the server relative URL of the folder.

Using fetch:

        fetch(
            _spPageContextInfo.webAbsoluteUrl + "/_api/Web/Lists/getByTitle('Big Big List')/getItems",
            {
                method: 'POST',
                headers: {
                    'Accept': 'application/json; odata=verbose',
                    'content-type': 'application/json; odata=verbose',
                    "X-RequestDigest": jQuery("#__REQUESTDIGEST").val()
                },
                credentials: 'same-origin',
                body: JSON.stringify({
                    query: {
                        "__metadata": { type: "SP.CamlQuery" },
                        ViewXml: '<View><Query><Where><Eq><FieldRef Name="BigField" LookupId="TRUE"/><Value Type="Lookup">' + id + '</Value></Eq></Where></Query></View>',
                        FolderServerRelativeUrl: "/MySite/BigBigList/SubFolder/2015"
                    }
                })
            })
            .then(response => {
                return response.json();
            })
            .then(data => {
                if (data.d &amp;&amp; data.d.results) {
                    data.d.results.map(item => {
                        console.log(item.Title);
                    });
                }
            })
            .catch(err => {
                console.log(JSON.stringify(err));
            });

Update JSLink using PowerShell

Customised a form using JSLink and want to provision using PowerShell? Here’s how:

# JSLink for Applications list
$Web = $Context.Web
$ApplicationsList = $Web.Lists.GetByTitle($ApplicationsListName)
$Context.Load($ApplicationsList)
Invoke-PnPQuery;
$Context.Load($ApplicationsList.Forms);
Invoke-PnPQuery;

# iterate through  DispForm.aspx, EditForm.aspx and NewForm.aspx
for ($i = 0; $i -lt 3; $i++) {
    $File = $Context.Web.GetFileByServerRelativeUrl($ApplicationsList.Forms[$i].ServerRelativeUrl)
    $Context.Load($File)
    $Context.ExecuteQuery()
    $WPManager = $File.GetLimitedWebPartManager([Microsoft.SharePoint.Client.WebParts.PersonalizationScope]::Shared)
    $WebParts = $WPManager.WebParts
    $context.Load($WebParts)
    $Context.ExecuteQuery()
    Set-PnPWebPartProperty -ServerRelativePageUrl $File.ServerRelativeUrl -Identity $WebParts[0].Id -Key "JSLink" -Value "~SiteCollection/Style Library/JS/ApplicationsForm.js"
    $Message = $File.ServerRelativeUrl + " UPDATED"
    Write-Output $Message
}

Start Nintex Workflow via Web Service

Need to start a Nintex workflow from within your TypeScript application? Here is a service class which wraps all the gubbins of the asmx service and can be called via a single function that returns a promise.

Parameters:

Workflow Name
List Name
Item ID
Workflow Parameters (as an object {parameter:value,...} ).
import { Web } from '@pnp/sp';
import { xml2json } from 'xml-js';

export class NintexWfService {

    private envStartNintexWorkflow: string;
    private web: Web;
    private digest: string;

    constructor() {
        this.web = new Web(_spPageContextInfo.webAbsoluteUrl);

        this.envStartNintexWorkflow = 
            "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
            "<soap:Envelope xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" xmlns:soap=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
            "<soap:Body>" +
            "<StartWorkflowOnListItem xmlns=\"http://nintex.com\">" +
            "<itemId>ITEM_ID</itemId>" +
            "<listName>LIST_NAME</listName>" +
            "<workflowName>WORKFLOW_NAME</workflowName>" +
            "<associationData>WORKFLOW_DATA</associationData>" +
            "</StartWorkflowOnListItem>" +
            "</soap:Body>" +
            "</soap:Envelope>";

        this.digest = '';
    }

    public startNintexWorkflow(wfName: string, listName: string, itemId: number, wfParameters?: {}): Promise<any> {

        return new Promise((resolve, reject) => {
            this.getDigest()
                .then(digest => {
                    let xmlParameters: string = '';
                    if (wfParameters) {
                        let totalParams: number = 0;
                        xmlParameters = '<Data>';
                        Object.keys(wfParameters).map((k, i) => {
                            if (wfParameters[k]) {
                                xmlParameters += '<' + k + '>' + wfParameters[k] + '</' + k + '>';
                                totalParams++;
                            }
                        });
                        xmlParameters += '</Data>';
                        if (totalParams > 0) {
                            xmlParameters = this.xmlEscape(xmlParameters);
                        }
                        else {
                            xmlParameters = '';
                        }
                    }
                    const url = _spPageContextInfo.webAbsoluteUrl + '/_vti_bin/NintexWorkflow/Workflow.asmx';
                    const headerData = {
                        'Accept': 'text/xml',
                        'Content-Type': 'text/xml; charset=utf-8',
                        'SOAPAction': 'http://nintex.com/StartWorkflowOnListItem',
                        'X-RequestDigest': digest
                    };

                    let soap = this.envStartNintexWorkflow;
                    soap = soap.replace('ITEM_ID', <string>itemId.toString());
                    soap = soap.replace('LIST_NAME', <string>listName);
                    soap = soap.replace('WORKFLOW_NAME', <string>wfName);
                    soap = soap.replace('WORKFLOW_DATA', xmlParameters);

                    return fetch(
                        url,
                        {
                            method: 'POST',
                            headers: headerData,
                            body: soap,
                            credentials: 'same-origin'
                        }
                    );
                })
                .then((data) => {
                    if (!data.ok) {
                        reject(data.statusText)
                    }
                    else {
                        data.clone().text().then(xml => {
                            let wfGuid = '';
                            try {
                                var jsonRes: any = JSON.parse(xml2json(xml));
                                wfGuid = jsonRes.elements[0].elements[0].elements[0].elements[0].elements[0].text;
                            }
                            catch (err) {
                                wfGuid = '';
                            }
                            resolve(wfGuid);
                        });
                    }
                })
                .catch(err => {
                    reject(err);
                });
        });
    }

    private xmlEscape(str: string): string {
        return String(str).replace(/&amp;/g, '&amp;').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
    }

    private getDigest(): Promise<any> {

        return new Promise((resolve, reject) => {
            if (this.digest !== '') {
                resolve(this.digest);
            }
            else {
                fetch(
                    _spPageContextInfo.webAbsoluteUrl + '/_api/contextinfo',
                    {
                        method: 'POST',
                        headers: { 'Accept': 'application/json; odata=verbose', 'content-type': 'application/json; odata=verbose' },
                        credentials: 'same-origin'
                    })
                    .then((response) => {
                        return response.text();
                    })
                    .then((data) => {
                        const jsonVal = JSON.parse(data);
                        if (jsonVal.d &amp;&amp; jsonVal.d.GetContextWebInformation &amp;&amp; jsonVal.d.GetContextWebInformation.FormDigestValue) {
                            this.digest = jsonVal.d.GetContextWebInformation.FormDigestValue;
                            resolve(this.digest);
                        }
                        else {
                            reject(jsonVal);
                        }
                    })
                    .catch((response) => {
                        reject(response);
                    });
            }
        });
    }
}

Notice we’re using fetch() to make the calls.

To call it:

const nwfs = new NintexWfService();
nwfs.startNintexWorkflow(
          'Industrious Workflow',
          'Documents',
          321,
          { MyParameter: 'MyP@r@m3t3r' })
     .then((wfId) => {
          console.log('Workflow GUID: ' + wfId + ' started!');
     });

Recursive JavaScript Promises!

ES6 promises give JavaScript the flexibility and code readability needed to become a more powerful development tool.  However, Promises are not necessarily easy to follow if you’re used to procedural code.  Add to this the concept of recursion and your head may start to hurt!

The following example shows how an asynchronous function can call itself using promises which eventually all resolve up the chain when the final condition is met.

// pause function to return after specified milliseconds
pause = (ms) => new Promise(resolve => {
  setTimeout(resolve, ms);
});

// recursive function which resolves after all iterations
recursePromises = (total, index) => new Promise(resolve => {

  // initialise index if undefined
  index = index || 0;

  if (index < total) {
    console.log(index);
    pause(500)
      .then(() => {
        return recursePromises(total, ++index);
      })
      .then(resolve);
  } else {
    resolve('Done ' + index + ' iterations!');
  }
});

Calling the function thusly:

recursePromises(15)
  .then(res => {
    console.log(res);
  });

will produce the result:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Done 15 iterations!

Hereby we can iterate an asynchronous set of operations and be sure all are completed when the calling promise function has resolved.

Nice.

SharePoint Client Side Rendering – Default Field Rendering

Using CSR / JSLink to modify forms and views is a very powerful tool that can be used to great effect.  You can use SharePoint essentially out of the box and get more powerful features without having to rely on 3rd party embellishments.

Using methods outlined on other blogs such as https://www.codeproject.com/Articles/610259/SharePoint-Client-Side-Rendering-List-Forms gives you the outline of how to do this.  You can render the value with your own JavaScript  to get the result you like.

What if you want to render a field in the default SharePoint manner but with an extra class added, or you wish  to render the display version of the field instead of the edit version?

Well as outlined here: https://stackoverflow.com/questions/31516512/js-field-rendering-return-standard-field-rendering you can incorporate the correct default renderer you wish.

However – what if you don’t know what the field type is or what the form type is?  A nice generic function would be nice.  Well after digging down into the MS code gives you can try this:

function renderDefaultField(ctx, baseViewID) {
    if (baseViewID)
        return SPClientTemplates._defaultTemplates.Fields.default.all.all[ctx.CurrentFieldSchema.Type][baseViewID](ctx);
    else
        return SPClientTemplates._defaultTemplates.Fields.default.all.all[ctx.CurrentFieldSchema.Type][ctx.BaseViewID](ctx);
}

This can be used in the following way:

(function () {
    var context = {};
    context.Templates = {};
    context.Templates.Fields = {
        'Field1': {
            'NewForm': showMyField
        },
        'Field2': {
            'NewForm': displayMyField
        }
    };

    SPClientTemplates.TemplateManager.RegisterTemplateOverrides(context);
})();

and implemented thusly:

function showMyField(ctx) {
    return '<span style="color:red">' + renderDefaultField(ctx) + '</span>';
}

or overriding the display type:

function displayMyField(ctx) {
    return '<span style="color:red">' + renderDefaultField(ctx, 'DisplayForm') + '</span>';
}

Easy!

SharePoint REST and Large Lists

As you may have discovered there is a limit on the size of lists in SharePoint.

Well that is not strictly true, the amount of items can be pretty large (30 million or so).  However, you may have received a message:

This view cannot be displayed because it exceeds the list view threshold (5000 items) enforced by the administrator.

The number of items a single view can return is set to 5000 items by default.

You may be returning what you think is way less than that but still get this pesky error.  So what do you do?

Well you could increase the threshold in Central Admin.  But you may not have access to this, or you may realise that this is limited for a reason, and changing such things ‘willy nilly’ is setting the table and pulling up a chair for Mr Cockup!

Better to tackle the issue at source.  So what can you do?

  1. Index your columns
    Yes – this is the first thing you should do if you foresee large numbers of items.  Identify which columns will slice and dice your data most efficiently and get them indexed before your list starts filling up.
  2. Design your Queries / Views
    Make sure that your views (these are ultimately CAML queries) use these indexed columns efficiently.  If you are not using an indexed column in your query then it’s pulling everything back from the container before querying the data.
  3. Utilise Containers
    The view limit is per container – a container can be a view or a folder.  This can be a case for using folders if appropriate.  Folders may be the anathema of what SharePoint is about(!) but here they may avoid some headaches.  Just remember when using Recursive and RecursiveAll in your queries that you’ll be losing their protection.

My query uses an indexed field but I still get the error!

The order matters.  If you are querying on more than one field and not using a compound index then the first value in your query will be used to query the database, following retrieval the remaining filters will be applied.  If this first query brings back more than 5000 items back from the database it will hit the threshold.  So if you’re using an AND put the filter with the most significance as the first AND parameter.

I want to get (much) more than the threshold back for my custom report web part…

You may require to pull back thousands of items for a one off report or such like and knowingly want to exceed for this purpose – e.g. a report.

Not to worry – REST to the REST-cue.

If you’re using REST calls to populate your data client-side you can encapsulate a calls within a single function for this type of requirement.  Here’s an example using jQuery deferred objects and promises.

function getBigList(listId, data, url, dfd) {

    // deal with empty parameters    
    dfd = dfd || $.Deferred();
    data = data || [];
    url = url || _spPageContextInfo.webAbsoluteUrl 
            + '/_api/lists/getbyid(\'' + listId + '\')/items?$OrderBy=ID&$top=5000';

    // make sure digest up to date (for long operations)
    UpdateFormDigest(_spPageContextInfo.webServerRelativeUrl, _spFormDigestRefreshInterval);

    jQuery.ajax({
        url: url,
        type: 'GET',
        aync: true,
        headers: {
            'Accept': 'application/json;odata=verbose',
            'X-RequestDigest': $('#__REQUESTDIGEST').val()
        },
        success: onSuccess,
        error: onError
    });

    function onSuccess(response) {
        if (response.d && response.d.results) {
            $.each(response.d.results, function (index, resItem) {
                data.push(resItem)
            });

            // check for more pages
            if (response.d.__next) {
                // there are more so recursively call with next page url (response.d.__next)
                // and pass the data and deferred object
                getBigList(listId, data, response.d.__next, dfd);
            }
            else {
                // now we have all
                dfd.resolve(data);
            }
        }
        else resolve([]);
    }

    function onError(error) {
        dfd.reject('getBigList: ' + getErrorMessage(error));
    }

    return dfd.promise();
}

The key here is that the parameter $top is used to specify the upper limit to what the call can return, additionally the results must be sorted by ID – the default, unqueried data.  If the $top number is exceeded then the first ‘page’ is returned along with a URL to the subsequent page.  This can be called recursively until all the data is received.

Call this in the following way:

getBigList(LIST_ID)
    .done(function (data) {
        $.each(data, function (index, dataItem) {
            //dataItem.stuff......
        });
    })
    .fail(function (msg) {
        console.log(msg);
    });

So by being mindful of what SharePoint is doing server-side and structuring our lists and queries thoughtfully we can get around some of the limitations in an efficient way.

A jQuery Progress Bar Plugin

Here is a simple progress indicator written as a jQuery plugin.  Useful for using with iterative asynchronous processes.

Simply create an element on the page (div) and call CSProg(total, msg) on it with the total no of actions  to complete and an optional message.

Each time an action completes call increment() to update the progress bar / message.

Complete by calling complete(msg) with an optional message.

The colour of the bar and message come from the container color and background-color CSS properties respectively.  These will contrast as the bar passes the message area.

JavaScript:

(function ($) {
    $.fn.CSProg = function (total, msg) {
        if (!$(this).height()) $(this).height(20);
        this.$progFrame = $(this).find('.cs-progFrame');
        var foreColour = $(this).css('color');
        var bkColour = $(this).css('background-color');
        if (bkColour == 'rgba(0, 0, 0, 0)' || bkColour == 'transparent') bkColour = '#fff';
        if (!this.$progFrame.length) this.$progFrame = $(document.createElement('div')).attr({
            'class': 'cs-progFrame'
        });
        this.$progFrame.attr({
            'data-total': total,
            'data-current': 0,
            'data-message': msg
        }).css({
            'text-align': 'center',
            'position': 'relative',
            'border': '1px solid ' + foreColour
        });
        $(this).append(this.$progFrame);

        this.$progFrameText = $(this).find('.cs-progFrameText');
        if (!this.$progFrameText.length) this.$progFrameText = $(document.createElement('div')).attr({
            'class': 'cs-progFrameText'
        }).css({
            'color': foreColour,
            'background-color': bkColour
        });
        this.$progFrame.append(this.$progFrameText);

        this.$progBar = $(this).find('.cs-progBar');
        if (!this.$progBar.length) this.$progBar = $(document.createElement('div')).attr({
            'class': 'cs-progBar'
        }).css({
            'position': 'absolute',
            'top': 0,
            'margin': 0,
            'color': bkColour,
            'background-color': foreColour,
            'overflow': 'hidden'
        });
        this.$progFrame.append(this.$progBar);

        this.$progBarContent = $(this).find('.cs-progBarContent');
        if (!this.$progBarContent.length) this.$progBarContent = $(document.createElement('div')).attr({
            'class': 'cs-progBarContent'
        });
        this.$progBar.append(this.$progBarContent);

        this.$progFrame.height($(this).height()).width($(this).width()).show();
        this.$progFrameText.height($(this).height()).width($(this).width()).css({
            'line-height': $(this).height() + 'px'
        }).show();
        this.$progBar.height($(this).height()).width(0);
        this.$progBarContent.height($(this).height()).width($(this).width()).css({
            'line-height': $(this).height() + 'px'
        });
    };

    $.fn.increment = function () {
        var total = parseInt(this.$progFrame.attr('data-total'));
        var current = parseInt(this.$progFrame.attr('data-current'));
        var message = this.$progFrame.attr('data-message');
        if (message)
            message = message + ': ';
        else
            message = '';
        message = message + (current + 1) + '/' + total;
        current++;
        if (this.$progBar && this.$progFrame && this.$progFrameText && this.$progBarContent) {
            this.$progBar.width((current / total) * this.$progFrame.width());
            this.$progFrame.attr({
                'data-current': current
            });
            this.$progFrameText.text(message);
            this.$progBarContent.text(message);
        }
    };

    $.fn.complete = function (msg) {
        if (this.$progBar && this.$progFrame && this.$progFrameText && this.$progBarContent) {
            msg = (typeof msg === 'undefined') ? '' : msg;
            this.$progBar.width(this.$progFrame.width());
            this.$progBarContent.text(msg);
            this.$progFrameText.text(msg);
        }
    };

}(jQuery));

Example:

<div id="cont"></div>

 

$('#cont').CSProg(10, 'Things happening');

function onUpdate(){
    $('#cont').increment();
}

function onComplete(){
    $('#cont').complete('Done');
}

 

How to log into a SharePoint 2013 site as another user

Up to SharePoint 2010 you could log into the site as a different user by going to the Welcome menu and selecting ‘Sign in as different user’.

Come SharePoint 2013 you go to do the same and yikes!  there’s just an option to sign out!  What do you do when you want to sign in with a different account?

Actually it’s quite easy.  Just go to:

<SITE URL>/_layouts/15/closeConnection.aspx?loginasanotheruser=’true’

You’ll be prompted to sign in and can enter the user credentials.

https://support.microsoft.com/en-us/help/2752600/-sign-in-as-different-user-menu-option-is-missing-in-sharepoint-server-2013

 

Making a SharePoint 2013 Publishing Site responsive

SharePoint 2013 introduced the concept of Device Channels with the noble intention of allowing developers to create distinct versions of a SharePoint site targeted at specific devices. This would be done by essentially implementing a different master page for each device catered for. Therefore different controls could be added and even different markup for each situation.

This is a very powerful option to have, but also – it’s a lot of work.

And I’m a lazy man.

In the fickle world of Information Technology changes need to be enacted quickly. And who knows when the next hefty-bearded graphic designer type will impose a new, more modern-er look and feel on your site?

Well for an easily implemented solution – especially in a publishing site where we are mainly talking just copy and images – we can do it the accepted and proper way.  Like in the real world. Using CSS and media queries.

Gubbins

First of all there’s a lot of SharePoint gubbins on the page which, frankly, is making this job more difficult.  I’m talking about things that for a publishing site, in mobile view, just make our lives more difficult.  It’s a judgement call as to what is needed but where possible – get rid (or should I say obscure)?  I’m talking about:

  • the ribbon
  • search box
  • Main navigation
  • Quick Launch

The majority of this can be done with the following CSS.  Were taking 1024px as the minimum width for the desktop environment here:

@media only screen and (max-width: 1024px) {
    #s4-workspace {
        width: auto !important;
        overflow: hidden !important;
        overflow-y: auto !important;
    }

    #ms-designer-ribbon,
    #sideNavBox,
    .ms-breadcrumb-top,
    #DeltaPlaceHolderSearchArea {
        display: none !important;
    }

    #titleAreaBox {
        margin: 0 !important;
    }

    #s4-titlerow {
        padding-bottom: 0px !important;
        padding-top: 20px;
        height: auto !important;
    }

    .ms-breadcrumb-box {
        float: left;
        height: auto !important;
    }

    #pageTitle {
        display: flex;
    }

    .js-callout-launchPoint {
        display: none !important;
    }

    #s4-workspace {
        width: auto;
        margin-left: auto;
        margin-right: auto;
    }

    #contentBox {
        margin: 0px !important;
        margin-left: auto;
        margin-right: auto;
        min-width: unset;
    }

    .ms-webpartzone-cell {
        margin: 0 0 10px 0 !important;
    }

    .ms-webpart-chrome-title {
        display: none;
    }

    .ms-rtestate-field > img {
        width: 150px !important;
        height: inherit !important;
    }
}

Breaks

Want your SharePoint 2013 site to scale well on a mobile device? Well you’ve got all those breaks to consider in your CSS for iPhone, iPad, Galaxy etc (oh, and Windows phones if you are clinging onto that one for grim death).  These can be bewildering and who knows where is the best point to make the break?

Mends

If you’re using the Promoted Links in your site – why not take a cue form these and structure your breaks accordingly.  You can structure your CSS to use media queries based around the widths of the tiles in the Promoted Links.  Get this right and the tiles will always fit within the page, flowing onto the next line when page real-estate constricts.  This approach, in tandem with my post on wrapping the Promoted Links, allows the site to behave as a true mobile site.

The following CSS creates a break for widths of five tiles down to two, and sets the page elements accordingly:

/* 5 tiles across */
@media only screen and (max-width: 1024px) and (min-width:790px) {
    #s4-bodyContainer,
    #contentBox {
        width: 790px !important;
        min-width: 790px !important;
    }

    .ms-promlink-body,
    .ms-promlink-root,
    .ms-fullWidth,
    .ms-webpart-chrome,
    .ms-webpart-chrome-title,
    .ms-WPBorder,
    .ms-wpContentDivSpace {
        width: 800px !important;
    }
}

/* 4 tiles across */
@media only screen and (max-width: 789px) and (min-width:630px) {
    #s4-bodyContainer,
    #contentBox {
        width: 630px !important;
        min-width: 630px !important;
    }

    .ms-promlink-body,
    .ms-promlink-root,
    .ms-fullWidth,
    .ms-webpart-chrome,
    .ms-webpart-chrome-title,
    .ms-WPBorder,
    .ms-wpContentDivSpace {
        width: 640px !important;
    }
}

/* 3 tiles across */
@media only screen and (max-width: 629px) and (min-width:470px) {
    #s4-bodyContainer,
    #contentBox,
    .ms-promlink-body,
    .ms-promlink-root,
    .ms-fullWidth,
    .ms-webpart-chrome,
    .ms-webpart-chrome-title,
    .ms-WPBorder,
    .ms-wpContentDivSpace {
        width: 470px !important;
        min-width: 470px !important;
    }

    .ms-promlink-body {
        width: 480px !important;
    }
}

/* 2 tiles across */
@media only screen and (max-width: 469px) {
    #s4-bodyContainer,
    #contentBox,
    .ms-promlink-body,
    .ms-promlink-root,
    .ms-fullWidth,
    .ms-webpart-chrome,
    .ms-webpart-chrome-title,
    .ms-WPBorder,
    .ms-wpContentDivSpace {
        width: 310px !important;
        min-width: 310px !important;
    }

    .ms-promlink-body {
        width: 320px !important;
    }
}

Bingo!

Setting Promoted Links to wrap on the page

Anyone who’s implemented a SharePoint 2013 site and used the Promoted Links web part to provide a nice, clean navigation has, no doubt, found this a welcome addition when needing a quick, graphical navigation.

However, add more tiles than the width of the page allows and the overflow will become obscured – necessitating the scroll buttons to be used to get to the hidden links.  That is, frankly, terrible.  Wouldn’t you think that this situation would be better if these tiles wrapped onto the next line?  Wouldn’t that be more, erm, intuitive?

Well you’re not the only one.  And many have thought the same.  There are a few solutions to this floating around the webbage.  Here’s mine:

function setPromLinks() {
    try {
        if ($('.ms-promlink-root')) {
            $('.ms-promlink-root').each(function () {
                try {
                    var wpidDec = $(this).attr('id');
                    var wpid = wpidDec.split('{')[1];
                    wpid = wpid.split('}')[0];
                    var width = $('div[webpartid=' + wpid.toLowerCase() + ']').width();

                    $(this).width(width).show().find('.ms-promlink-body').width(width + 10);
                }
                catch (err) {
                    document.getElementsByClassName('ms-promlink-root').style.display = 'block';
                }
            });
        }
    }
    catch (err) {
        document.getElementsByClassName('ms-promlink-root').style.display = 'block';
    }
}

_spBodyOnLoadFunctionNames.push('setPromLinks');

Link the function into your master page and also include the following css to hide those horrid buttons.

.ms-promlink-header {
    display: none;
}
.ms-promlink-root {
    display: none;
}

It should be noted that the CSS will hide the whole Promoted Links web part content from the off, it being displayed when the script executes.  So the additional error catching is to make sure it’s displayed should jQuery not be available.