Get list of recent documents in SharePoint

recent documents

Similar to my last post Get list of frequent sites in SharePoint , this time I’m using the same approach to query a different API and get the recent documents for the current user.

SharePoint offers an OOB web part that you can use to list the recent documents for the current user. But what if you need the exact same information for a custom SharePoint Framework solution?

WARNING

Unfortunately, it seems that this is not currently possible using the SharePoint REST API or MS Graph. The API used on the sample code below is currently not documented and you should understand the risks when using it!

Hopefully Microsoft will make this information available soon via MS Graph or document this API.

Recent documents

[4th Nov 2019 – Update – Added new required HTTP headers when requesting data from the service: X-Office-Platform, X-Office-Application, X-Office-Version]

You can also get the code from this gist

// within your web part onInit method, get the request digest token
const digestCache: IDigestCache = this.context.serviceScope.consume(DigestCache.serviceKey);
const requestDigest = await digestCache.fetchDigest(this.context.pageContext.web.serverRelativeUrl);


//now on your data access layer
// get token
const authRequestHeaders: Headers = new Headers();
authRequestHeaders.append("Accept", "application/json;odata.metadata=minimal");
authRequestHeaders.append("Content-Type", "application/json; charset=utf-8");
authRequestHeaders.append("odata-version", "4.0");
authRequestHeaders.append("x-requestdigest", this._requestDigest);

var resourceData = {
  "resource": "https://officeapps.live.com",
};

const authRequestOptions: IHttpClientOptions = {
  headers: authRequestHeaders,
  body: JSON.stringify(resourceData),
};

const authEndpointUrl = this._webAbsoluteUrl + '/_api/SP.OAuth.Token/Acquire';
const authRawResponse = await this._httpClient.post(authEndpointUrl, HttpClient.configurations.v1, authRequestOptions);
const auth = await authRawResponse.json();

// get data
const dataRequestHeaders: Headers = new Headers();
dataRequestHeaders.append("Authorization", "Bearer " + auth.access_token);
dataRequestHeaders.append("Content-Type", "application/json");
dataRequestHeaders.append("X-Office-Platform", "Web");
dataRequestHeaders.append("X-Office-Application", "120");
dataRequestHeaders.append("X-Office-Version", "*");

const dataRequestOptions: IHttpClientOptions = {
  headers: dataRequestHeaders,
};

const dataEndpointUrl = `https://ocws.officeapps.live.com/ocs/v2/recent/docs?apps=Word,Excel,PowerPoint,Visio,OneNote,Sway,PdfViewer&show=${count}&sort=Date`;
const dataRawResponse = await this._httpClient.get(dataEndpointUrl, HttpClient.configurations.v1, dataRequestOptions);
const data = await dataRawResponse.json();

Get list of frequent sites in SharePoint

frequent sites

SharePoint offers an OOB web part that you can use to list the frequent sites for the current user. But what if you need the exact same information for a custom SharePoint Framework solution?

I have also wrote a similar blog post on how to retrieve recent documents for current user. You can read more here.

WARNING

Unfortunately, it seems that this is not currently possible using the SharePoint REST API or MS Graph. The API used on the sample code below is currently not documented and you should understand the risks when using it!

Some related threads:

https://github.com/SharePoint/sp-dev-docs/issues/1689

https://sharepoint.uservoice.com/forums/329220-sharepoint-dev-platform/suggestions/34075903-api-support-for-followed-sties

Please vote on uservoice 🙂 hopefully Microsoft will make this information available soon via MS Graph or document this API.

Frequent sites

You can get the code from this gist

// get data endpoints, token and payload
const contextRequestHeaders: Headers = new Headers();
contextRequestHeaders.append("Accept", "application/json;odata.metadata=minimal");
contextRequestHeaders.append("odata-version", "4.0");

const contextRequestOptions: IHttpClientOptions = {
  headers: contextRequestHeaders,
};

const contextEndpointUrl = this._webAbsoluteUrl + '/_api/sphomeservice/context?$expand=Token,Payload';
const contextRawResponse = await this._httpClient.get(contextEndpointUrl, HttpClient.configurations.v1, contextRequestOptions);
const context = await contextRawResponse.json();

// get data
const dataRequestHeaders: Headers = new Headers();
dataRequestHeaders.append("Authorization", "Bearer " + context.Token.access_token);
dataRequestHeaders.append("sphome-apicontext", context.Payload);

const dataRequestOptions: IHttpClientOptions = {
  headers: dataRequestHeaders,
};

const dataEndpointUrl = context.Urls[0] + `/api/v1/sites/feed?start=0&count=${count}&acronyms=true`;
const dataRawResponse = await this._httpClient.get(dataEndpointUrl, HttpClient.configurations.v1, dataRequestOptions);
const data = await dataRawResponse.json();

Office UI Fabric images for SPfx projects

office ui fabric icons

When creating SPFx solutions, you will sometimes require base64-encoded images. A common scenario is when you create a ListView Command Set extension . And then you look at Office UI Fabric Icons and think how nice it would be if you could easily get the images as base64-encoded strings to use on SPFx solutions.

And today I found a great tool to do this!

While searching for a tool/website to convert the icons, I stumbled across this amazing blog post: “I Made a Tool to Generate Images Using Office UI Fabric Icons

And on the post, there’s the link to the great tool on codepen.

In this example, I created an icon for a SPFx ListView Command Set that uses 16×16 px icons from Fabric React.

As you can see from the image, all you have to do is copy the Data URL field and paste that value on the manifest.json file of your solution.

The icon will look great on the page (search folders):

Unfortunately the tool seems to have a limitation and it doesn’t give you the option to have transparent background, so be aware when selecting a background colour.
Update: I am glad to confirm that I was wrong and the tool does in fact support transparent backgrounds! Just use ‘transparent’ as the value for the background colour as per the image above.

Hope you find this helpful.

SPFx web parts from different solutions on workbench

workbench customizer

Ever wondered how to add SharePoint Framework web parts from different solutions to your local workbench while developing on localhost? Then look no further 🙂

Even though this may not be a common requirement for everyone, there are cases where it could be handy to have different web parts running on the local workbench that belong to different solutions.
I was sitting on a plane without WiFi and was testing some things with a web part running locally. Was having some issues with the workbench page styles, so I tried to find a way to load the Workbench Customizer web part on the Workbench page, without adding it to my solution. It turned out to be fairly simple.

If you never heard about the Workbench Customizer web part, you can read more on this post.

Bundle the “external” solution

The first thing that you will have to do is go to the solution folder containing the web part that you want to load on the main solution and bundle it. Your solution needs to build for this to work. This will create the dist and lib folders, that will contain the files you need to copy to the other solution.
To bundle, we simply run the SharePoint Framework bundle task “gulp bundle

Copy the “external” solution to your main solution

Next, we need to copy some files from the “external” solution to the folders of your main solution. If your main solution does not have bin and dist folders, you can simply run “gulp bundle” to generate them.
All the files that we are going to copy are located in the dist and lib folders. The folders are locally generated and never included as part of your solution in your Git repository. Additionally, you can simply run the clean task (gulp clean) to remove everything.
In order for the web part to work, you will have to copy two folders:

  • dist – open the dist folder on the “external” solution and copy all the files to the dist folder of your main solution
  • lib>webparts>{your web part name} – open the lib>webparts folder on the “external” solution and copy the folder with the same name as your web part to the lib>webparts folder of your main solution. You should see different folders, displaying the different elements of your solution.

Serve your main solution

Everything is done and you can now run the main solution and add both web parts to the Workbench page. To run the main solution locally, simply run the task “gulp serve” as you would normally do.

You can add both web parts to the page and confirm that both work

Package your main solution

You can safely package your main solution while still having the files for the “external” solution on the dist and lib folders. Those will not be included on the package file 🙂

Cleaning up the main solution

If you want to remove all the files from the “external” solution on the main one, simply run “gulp clean”. It’s that easy.

SPFx Workbench Customizer

workbench customizer web part preview

I recently published a blog post about a web part that I use on the workbench page during development. I have this solution deployed on my dev tenant and simply add it to the bottom of the Workbench page. It allows me to work around some workbench limitations when building the UI of SPFx web parts.

I have recently updated this web part and added it to the PnP web part samples project on GitHub. You can get the code here:

/2019/01/18/spfx-workbench-customizer/

The previous version of the web part allowed you to enable/disable specific CSS overrides. Those overrides were dynamically imported on the page based on your settings. When all the overrides were enabled, the Workbench page would have an interface similar to a modern page. For this to happen, the web part would remove extra margins and add extra styles.

Additionally, the web part now also has a switch button to enable page preview mode by default. When this is enabled, right after the page loads, the web part simulates a click on the Preview button at the top of the page and the editing experience is replaced with the preview.

This is really great when testing smaller screen resolutions as the workbench no longer displays a message telling you to “Widen your browser window”. You can see the difference on the images below

This image has an empty alt attribute; its file name is image-1.png

Give it a try and let me know if you have any feedback. Happy coding 🙂

Thread view for SharePoint classic Discussion Board

thread view

One of the classic SharePoint list types is the Discussion Board. It allows the creation of discussion topics that users can reply to. And users can also reply to previous replies, creating a thread-like scenario.
Unfortunately, Discussion Boards currently only have a Flat view that lists all the replies ordered by creation date. This creates a very poor user experience as it’s nearly impossible to follow the replies to a given topic. Thread view used to be an available feature for discussion lists, but this is no longer the case. There is also a user voice request to bring the feature back.

But wait no more. You can find below a relatively small piece of JavaScript code that will transform the Flat view into a Thread view for discussions by overriding some out of the box functions. The end result will look similar to the image below.

thread view discussions
Thread view for discussions

Thread view

The following code overrides some functions that are used by the OOB JS Link file used to generate the user interface. You can find the OOB file under /_layouts/15/sp.ui.discussions.js or /_layouts/15/sp.ui.discussions.debug.js for the debug (“readable”) version.
The code below overrides the OOB functions with a copy of that code that then contains small changes in order to apply the required modifications. I explain all the relevant code changes below, so keep reading.

var indentWidth = 30;

SP.UI.Discussions.ForumRenderer.prototype.renderForumBody = function SP_UI_Discussions_ForumRenderer$renderForumBody(context) {
    var $v_0 = new SP.HtmlBuilder();
    var $v_1 = context.forumViewState;

    if (!SP.UI.Discussions.Helpers.isNullOrUndefined($v_1)) {
        // CPS - this is where the collection of data is instantiated
        var $v_2 = context.ListData['Row'];
        var $v_3 = 0;

        if (!SP.UI.Discussions.Helpers.$Q(context) && $v_2.length > 0) {
            $v_0.addCommunitiesCssClass('postList');
            $v_0.renderBeginTag('ul');
            context.CurrentItem = $v_2[0];
            $v_0.write(this.renderForumPost(context));
            $v_0.renderEndTag();
            $v_3++;
        }
        if (!$v_2.length) {
            var $v_4 = new Array(0);

            RenderEmptyText($v_4, context);
            $v_0.write($v_4.join(''));
        }
        else {
            $v_0.addCommunitiesCssClass('postList');
            $v_0.addAttribute('id', SP.UI.Discussions.Helpers.$N($v_1.$E_0, this.get_postListId()));
            $v_0.renderBeginTag('ul');
            if ($v_3 < $v_2.length) {
                
                // CPS - this is where replies are being rendered
                console.log('Rendering custom thread view');

                // CPS - sort array by parent item - this is very important as we are assuming that the items are ordered when rendering
                $v_2.sort(function(a, b) {
                    return a['ParentItemID'] - b['ParentItemID'];
                });

                // CPS - render child items
                var childItems = this.getChildItemReplies(context, $v_2, 0, 0);
                $v_0.$0_0.push(childItems);
            }
            $v_0.renderEndTag();
        }
    }
    return $v_0.toString();
}

SP.UI.Discussions.ForumRenderer.prototype.getChildItemReplies = function getChildItemReplies(context, items, parentIndex, indentLevel) {
    var htmlBuilder = new SP.HtmlBuilder();
    var firstChildIndex = -1;

    // CPS - find index of first child
    for(var i = parentIndex + 1; i < items.length; i++) {
        if(items[i]['ParentItemID'] == items[parentIndex]['ID']) {
            firstChildIndex = i;
            break;
        }            
    }

    if(firstChildIndex > 0) {
        for(var i = firstChildIndex; i < items.length; i++) {
            // CPS - if item is a child
            if(items[i]['ParentItemID'] == items[parentIndex]['ID']) {
                context.CurrentItem = items[i];
                context.CurrentItem.IndentLevel = indentLevel;
                
                htmlBuilder.write(this.renderForumPost(context));

                // CPS - recursively call the function again to search for child items of current item
                var childItems = this.getChildItemReplies(context, items, i, indentLevel + 1);
                htmlBuilder.$0_0.push(childItems);
                
                context.CurrentItem = null;
            }
            else {
                // CPS - stop loop when item being parsed is not direct child
                break;
            }
        }
    }

    return htmlBuilder.toString();
}

SP.UI.Discussions.PostBehavior.prototype.$2C_0 = function SP_UI_Discussions_PostBehavior$$2C_0($p0, $p1) {
    this.$0_0.CurrentItem = this.jsonItem;
    if ($p1.get_$1B_0()) {
        $p0.addCommunitiesCssClass('postListItem');
        if (this.$3_0) {
            $p0.addCommunitiesCssClass('postRootContainer');
        }
        else {
            $p0.addCommunitiesCssClass('postReplyListItem');
            // indent items based on IndentLevel property
            if(this.jsonItem.hasOwnProperty('IndentLevel')) {
                $p0.addAttribute('style', 'margin-left: ' + this.jsonItem['IndentLevel'] * indentWidth + 'px; ');
            }
        }
        $p0.addAttribute('id', this.getPostElementID('Root'));
        $p0.renderBeginTag('li');
    }
    if ($p1.get_$21_0()) {
        $p0.addCommunitiesCssClass('postMainContainer');
        $p0.renderBeginTag('div');
    }
    if (this.$3_0) {
        this.renderRootPostMetaData($p0);
        $p0.addCommunitiesCssClass(this.get_$G_0() ? 'threadSubjectContainerCollapsed' : 'threadSubjectContainer');
        $p0.addAttribute('role', 'heading');
        $p0.addAttribute('aria-level', '2');
        $p0.renderBeginTag('div');
        this.$2D_0($p0, false, $p1);
        if ($p1.get_$3M_0()) {
            $p0.addCssClass('ms-floatRight');
            $p0.addCommunitiesCssClass('postExpander');
            $p0.addAttribute('id', this.getPostElementID('Expander'));
            $p0.addAttribute('title', this.get_$G_0() ? Strings.STS.L_SPDiscExpandPostAltText : Strings.STS.L_SPDiscCollapsePostAltText);
            $p0.addAttribute('href', 'javascript:;');
            $p0.renderBeginTag('a');
            $p0.addCssClass(this.get_$G_0() ? 'ms-comm-postExpanderExpandContainer' : 'ms-comm-postExpanderCollapseContainer');
            $p0.renderBeginTag('span');
            $p0.addAttribute('src', GetThemedImageUrl('spcommon.png'));
            $p0.addCssClass(this.get_$G_0() ? 'ms-comm-postExpanderExpand' : 'ms-comm-postExpanderCollapse');
            $p0.renderBeginTag('img');
            $p0.renderEndTag();
            $p0.renderEndTag();
            $p0.renderEndTag();
        }
        this.$2B_0($p0, 'ms-metadata ms-comm-statsInlineContainer', 'ms-comm-statsInline', 'ms-comm-reputationNumbers');
        $p0.renderEndTag();
    }
    if (!this.get_$G_0()) {
        if (this.$3_0) {
            $p0.addCssClass('ms-comm-rootBestBackground');
            $p0.addCssClass('ms-comm-rootPostContainer');
            $p0.renderBeginTag('div');
        }
        $p0.addCssClass('ms-table');
        $p0.renderBeginTag('div');
        $p0.addCssClass('ms-tableCell');
        $p0.renderBeginTag('div');
        this.$3A_0($p0);
        $p0.renderEndTag();
        $p0.addCssClass('ms-verticalAlignTop');
        $p0.addCssClass('ms-tableCell');
        $p0.addCssClass('ms-fullWidth');
        $p0.renderBeginTag('div');
        this.$3B_0($p0);
        this.renderAuthorCardMetaData($p0);
        if ($p1.get_$3J_0()) {
            this.$3I_0($p0);
        }
        $p0.addAttribute('class', 'ms-core-defaultFont');
        $p0.renderBeginTag('div');
        this.$27_0($p0, false, $p1);
        this.$3H_0($p0);
        if ($p1.get_$29_0()) {
            this.$29_0($p0);
        }
        $p0.renderEndTag();
        $p0.renderBeginTag('div');
        $p0.addCommunitiesCssClass('postReplyContainer');
        $p0.addAttribute('id', this.getPostElementID('ReplyContainer'));
        $p0.renderBeginTag('div');
        $p0.renderEndTag();
        $p0.renderEndTag();
        $p0.renderEndTag();
        $p0.renderEndTag();
        if ($p1.get_$1j_0() && this.get_bestResponseIsVisible()) {
            $p0.addCssClass('ms-comm-bestResponseDividerHr');
            $p0.renderBeginTag('hr');
            $p0.renderEndTag();
            $p0.addCommunitiesCssClass('bestResponseContainer');
            $p0.addCommunitiesCssClass('postMainContainer');
            $p0.addAttribute('id', this.getBestResponsePostElementID());
            $p0.renderBeginTag('div');
            $p0.addCssClass('ms-textLarge');
            $p0.addCommunitiesCssClass('bestPostHeader');
            $p0.renderBeginTag('div');
            $p0.addCommunitiesCssClass('bestResponseIcon-span');
            $p0.renderBeginTag('span');
            $p0.addAttribute('src', GetThemedImageUrl('spcommon.png'));
            $p0.addCommunitiesCssClass('bestResponseIcon');
            $p0.renderBeginTag('img');
            $p0.renderEndTag();
            $p0.renderEndTag();
            $p0.writeEncoded(Strings.STS.L_SPDiscBestHeader);
            $p0.renderEndTag();
            $p0.renderEndTag();
        }
        if (this.$3_0) {
            $p0.renderEndTag();
        }
    }
    if ($p1.get_$21_0()) {
        $p0.renderEndTag();
    }
    if ($p1.get_$1B_0()) {
        $p0.renderEndTag();
        if (this.$3_0) {
            $p0.addCssClass('ms-comm-allRepliesHeader');
            $p0.addAttribute('role', 'heading');
            $p0.addAttribute('aria-level', '3');
            $p0.renderBeginTag('li');
            if (this.repliesSortPicker) {
                var $v_0 = new SP.HtmlBuilder();

                $v_0.addCssClass('ms-textLarge');
                $v_0.renderBeginTag('span');
                $v_0.writeEncoded(Strings.STS.L_SPDiscAllRepliesLabel);
                $v_0.renderEndTag();
                $p0.addCommunitiesCssClass('replyHeader');
                $p0.renderBeginTag('div');
                $p0.write($v_0.toString());
                $p0.renderEndTag();
                this.repliesSortPicker.render($p0);
            }
            $p0.renderEndTag();
        }
    }
}

// CPS - required for JS overrides to render correctly 
sp_ui_discussions_initialize();

Sorting

We start by copying the content of the renderForumBody function from the
sp.ui.discussions.debug.js file and modify it as required.

The magic starts at line 36. A simple sorting function sorts the array of items by ParentItemId. This is the property that contains the parent item for each reply. It allows us to order the data in “segments” and ensuring that the child items of a given parent are positioned together within the array.

Getting child items

Next, at line 41, we call the getChildItemReplies function that returns the html code for the child elements of the discussion topic (item with index zero on the array) and add that result to the HTML Builder object.

The getChildItemReplies is a function I created (is not an override, like the other 2 functions) that recursively renders child items of a parent item and invokes itself again to render the child items of each item being rendered. To improve performance, it contains some conditions that will limit the items being parsed and stop the code loops.

There is also something very important happening at line 67. context.CurrentItem.IndentLevel = indentLevel;
Here, we add a new property to each item that contains the indent level of that item. This is very important as it will allow us to easily change the styles in the next step.

Styles

In this example, all the OOB styles are preserved with the exception of the indentation for the Thread view.
We start by copying the $2C_0 function from the
sp.ui.discussions.debug.js file and modifying it as required.

The change here is extremely simple. at line 98, we simply apply a margin-left style based on the IndentLevel property that we added to the item previously. Alternatively you could add a custom class and have additional custom CSS applied to it.

Initialize

The last line of code (266) is a function call from the original
sp.ui.discussions.debug.js file. I honestly don’t know for sure why this is required here, but without it the custom code doesn’t override the OOB file so just keep it there 🙂

Deployment

The code below needs to be deployed as a JS Link file of a discussion board web part. Because we are overriding the OOB file, we also need to ensure that we keep loading that file.
Upload the custom JavaScript file into your site (for example, into the Site Assets library).
Edit the Discussion Board web part properties and add the following to the JS Link property:

For Discussion Board lists on normal Team sites

sp.ui.discussions.js|~sitecollection/SiteAssets/thread.js

For Discussion Board lists on Communication sites

sp.ui.discussions.js|~sitecollection/SiteAssets/thread.js|sp.ui.communities.js

SPFx Workbench Customizer

custom-workbench

With SharePoint Framework, Microsoft also introduced a really good development story for creating custom web parts: the Workbench page.
This page is not only available when you are developing solutions locally, but also on a SharePoint site. This gives you the option to access data on a SharePoint site from code running on your machine. Let’s be honest, it’s great!

Unfortunately, the Workbench page also has some limitations for some development scenarios. One of them being, in my opinion, how the overall page styles differ from a normal modern SharePoint page.

SharePoint Workbench page

As you can see on the image above, the styles are really not great. I personally don’t find a reason why they differ so much from a modern page, and why, for example, the page layout is limited to a maximum width of 924px .

When building custom web parts, I had previously (in some occasions) included some code into the project to deal with this in different ways. But this was never a great approach, as I used to comment/delete that code after the work was done…

And this led me to think on a better solution that would allow me to not having to worry about doing it again. So I thought on just creating a web part for it and always add it to the Workbench whenever I’m working on it.

Workbench Customizer web part

The image above is an example of the workbench page when the web part is added to it (the text on the page is just multiple text web parts to test different zones).

When added to the workbench page, the web part will apply the following changes by default:

  • Change the max page width, allowing the editable area to be the same width as a modern page (1236px)
  • Center the canvas zones on the page, in line with modern pages
  • Update overflow, allowing the scrollbar to appear on the right side of the page
  • Remove additional padding that is introduced by having the page in edit mode by default

Every of the items above is controlled by a web part property that can simply be disabled on the web part properties panel. This is currently achieved using dynamic imports. Every customisation is kept on a separate SASS file that is dynamically imported when the property is enabled. When a property is disabled, a message will be displayed asking you to refresh the page.
I will be looking on improving the experience over time, but it does it’s job at the moment…

Global CSS overrides? Is this not bad?

The CSS changes to the overall page are done using the :global approach. I absolutely know this is not a recommended approach for customising SharePoint, but remember that we are only customising the Workbench page! Who cares! If it breaks, you can simply take it out of the page…

Code

The web part is obviously open-source and is currently on my personal GitHub account
https://github.com/joelfmrodrigues/spfx-workbench-customizer

I’m planning on submitting a PR to the PnP web part samples repository over the next days and will update the post if the PR is accepted.

Update: The web part is obviously open source and is available under the PnP web part samples repository.

If you have any ideas on things that may be missing or any feedback about the current implementation, please reach out. Any feedback is welcome.

Hope you find it useful and use it while building your own web parts.

I have published another blog post with an update. You can read more here: SPFx Workbench Customizer update

Add Google Analytics to SharePoint modern pages

ga-sp

I had a client requirement to help them add Google Analytics to a modern SharePoint site. The objective was to track all SharePoint page views within the site.

The first thing that came to my mind was to look for a solution available online. As this is a fairly common scenario, I assumed it would be easy to find one for my requirements. But this was also what the client had tried to do before calling us, and they got stuck with some limitations on the solutions that they have found.
There are plenty of solutions available online for using Google Analytics with SharePoint modern pages. From complete implementations, to blog posts with the relevant code snippets. But I was also unable to find one that was able to track full and partial page loads. And so I decided to tweak one to work on the scenarios.

Base solution

The code below is based on the original solution provided by João Ferreira on this blog post. He uses an SPFx application customizer to load the Google Analytics script on every SharePoint page. When testing it, we found that it was not tracking all the page loads, so we updated it slightly.

Additionally, the this.context.application.navigatedEvent event currently has a known bug and fires twice. The workaround provided by jonthenerd worked fine in this scenario.
2019-03-01 update: The bug on the navigatedEvent seems to be fixed now, so I have updated the code below. I have also sent a PR with the changes to the original repository (by João Ferreira). You can find a full implementation project on his post.

Code

The following code uses the approach provided by Google Analytics for tracking Single Page Applications, like SharePoint pages that only load partially..

Hope you fins this useful, and feel free to leave comments or provide feedback.

export default class AnalyticsApplicationCustomizer
  extends BaseApplicationCustomizer<IAnalyticsApplicationCustomizerProperties> {

  private isInitialLoad = true;

  private getFreshCurrentPage(): string {
    return window.location.pathname + window.location.search;
  }


  private navigatedEvent(): void {

    let trackingID: string = this.properties.trackingID;
    if (!trackingID) {
      Log.info(LOG_SOURCE, `${strings.MissingID}`);
    } else {

      if (this.isInitialLoad) {
        this.initialNavigatedEvent(trackingID);
        this.isInitialLoad = false;

      }
      else {
        this.partialNavigatedEvent(trackingID);
      }
    }
  }

  private initialNavigatedEvent(trackingID: string): void {

    console.log("Tracking full page load...");

    var gtagScript = document.createElement("script");
    gtagScript.type = "text/javascript";
    gtagScript.src = `https://www.googletagmanager.com/gtag/js?id=${trackingID}`;
    gtagScript.async = true;
    document.head.appendChild(gtagScript);

    eval(`
          window.dataLayer = window.dataLayer || [];
          function gtag(){dataLayer.push(arguments);}
          gtag('js', new Date());
          gtag('config',  '${trackingID}');
        `);
  }

  private partialNavigatedEvent(trackingID: string): void {

    console.log("Tracking partial page load...");

    eval(`
        if(ga) {
          ga('create', '${trackingID}', 'auto');
          ga('set', 'page', '${this.getFreshCurrentPage()}');
          ga('send', 'pageview');
        }
        `);
  }

  @override
  public onInit(): Promise<void> {

    this.context.application.navigatedEvent.add(this, this.navigatedEvent);

    return Promise.resolve();
  }
}

SPFx solution using PnPjs for Project Online REST API

SPFx Project SharePoint

If you know me or follow me on Twitter/LinkedIn, you must have realized by now how much I like the PnPjs library. Enough to venture myself to speak about it on 3 SharePoint Saturday events last year. The library has packages for SharePoint and Graph endpoints and can be easily used on SPFx solutions. But if you need an SPFx solution that consumes Project Online API, what options do you have?
Kudos to Paweł Hawrylak who started creating the Project module for PnPjs and currently already offers support for a wide range of endpoints. The module is currently in a dev branch and requires additional work and testing, but it’s already a phenomenal effort.

This blog post will cover the required steps to generate a local PnPjs Project package to consume Project Online REST APIs and create a SPFx web part that uses it.

Published npm packaged

2019-12-31 update
If you only want to give the current version a try, there is now a packaged published to npm that you can simply install. Don’t forget to also install the required dependencies of @pnp/common, @pnp/odata, @pnp/logging and you are ready to rock!

npm i pnpjs-project-online-package @pnp/common@1.3.7 @pnp/logging@1.3.7 @pnp/odata@1.3.7 --save-exact

Get the code from GitHub

2019-11-14 update: I now have a repository forked from the main PnPjs project where I added the code for the project module. The good news is that this is using the latest PnPjs version (1.3.7 at the time)

https://github.com/joelfmrodrigues/pnpjs/tree/feature/project-online-api

From here, you can download, clone or fork it, what ever makes sense for what you plan to do with it. Just be aware that if you plan to fork it to contribute, and you have also previously forked the original PnPjs repository, you may need to delete the last and fork after that.
You can find the original code under Paweł’s GitHub fork of the main PnPjs project on GitHub.

Build PnPjs packages

Since we have the PnPjs source code, we need to build and generate local packages. You can get additional information of the PnPjs gulp commands on the official documentation.

Run the following commands in the order specified:

  1. npm install – to install the npm packages required by PnPjs
  2. gulp build – to ensure that the solution can build successfully
  3. gulp package – to generate all the different library packages, including the new Project package

Peer dependencies

We are going to install the local Project package that we have just build in our SPFx solution. Unfortunately, the peer dependencies don’t seem to work as expected when you do so and you get errors when trying to use the packages on your web part.
If you inspect the package.json file for the Project package created (dist/packages/project), you can find the following required peerDependencies which are not resolved by default

  "peerDependencies": {
        "@pnp/common": "1.3.7",
        "@pnp/logging": "1.3.7",
        "@pnp/odata": "1.3.7"
    },

Not the ideal solution for sure, but a simple way to get around this problem is to simply install the packages yourself.
Remember that we are only testing the new Project package and all the others are kept untouched, so seems sensible to me to install them directly from npm.
Ensure that your command prompt is on the
dist/packages/project directory and run the following command

npm install @pnp/common@1.3.7 @pnp/logging@1.3.7 @pnp/odata@1.3.7

The Project package is now ready to be consumed by our solution.

Create new SPFx solution

I’m not going to provide any specific instructions here as the official documentation is excellent and give you all the information you need. If you are new to SharePoint Framework development, please check it out and learn how to get started.

Add PnPjs packages to SPFx solution

Again, remember that Project is the only package that is not published to npm, so is the only one that we need to install from a local path.
Start by installing all the common PnPjs packages that you usually install. In this case I’m using the 1.2.5 version as it’s the version that matches the local version.

npm install @pnp/common@1.3.7 @pnp/logging@1.3.7 @pnp/odata@1.3.7 --save-exact

Next, install the local Project package by providing a relative path to the package folder. In my example:

npm install ../PnPjs/dist/packages/project

Establish Context

Following the official guidance, we also need to establish the context when using the library in SPFx. Simply add the following block of code into your web part main file as provided on the documentation:

import { project } from "pnpjs-project-online-package"; // or import from your local package for development

// ...

public onInit(): Promise<void> {

  return super.onInit().then(_ => {

    // other init code may be present

    project.setup({
      spfxContext: this.context,
      project: {
        baseUrl: 'https://XXXXXXXXXX.sharepoint.com/sites/pwa'
      }
    });
  });
}

// ...

Please note that we are setting the baseUrl property to be the PWA site. This is to allow the solution to work from any SharePoint sites, not only Project Online.

Use it!

It’s all done and you can now use the fluent library to interact with project online!
All you need to do is to import the Project package when you need it

import { project } from "@pnp/project";

Some usage examples:

// get all projects
const projects = await project.projects.get();

// get projects, filtering by name, returning only the Id and Name properties, and limiting the results count to 1
const projectInfo = await project.projects.filter(`Name eq '${projectName}'`).select('Id,Name').top(1).get();

// create timesheet
const createResult = await project.timeSheetPeriods.getById('XXXXXXXXXXXXXXXX').createTimeSheet();

Style SPFx workbench

This blog post does not contain anything amazing or new. But it’s something I use for a very long time and decided to write it down to hopefully help someone. Style the SPFx workbench page.

Note: I have since created a workbench customizer web part that you can just add to the workbench page. You can read more here. And you can find the web part here.

The SPFx Workbench page is fantastic. You can use the local version to run your code locally, or you can use the online hosted version to test your code against your site. But it has some limitations.
One of the limitations I often find is related to the maximum width. This is mainly a problem with web parts that need to be able to adapt to wider web part zones. The Workbench page is set to have a max-width of 924px by default on one of the top page elements, preventing the middle area of the page from expanding.
But as we are talking about CSS, it can be easily fixed with more CSS.

The :global prefix

When the SPFx build tools generate CSS classes from your SASS files, it changes the name of the classes to make them unique and ensure that you do not override any CSS class already on the page with that name. But what if you want to override CSS? (Please don’t do it on normal pages as it’s not recommended!)
In that case, you can make use of the :global attribute. By prefixing your styles with :global, you can ensure that they will not be renamed by the build tools.

You can take advantage of this attribute to change the styles of the Workbench page. This is only a page targeted for developers, so it’s not the end of the world if the OOB styles are changed and your overrides break. You can just update them and fix the issue if that ever happens.

The following snippet allows you to control the max width of the page, by overriding the default 929px value applied to that element.


:global #workbenchPageContent {
    max-width: 1284px;
}

But the above is only an example. You can have a look around and override other styles if that makes sense for your case. Just make sure you do so in a way that your CSS overrides don’t get applied to other pages when your web part is deployed.