Go back to the main page

Modify the QuickBooks Online Interface with a Chrome Extension

 

Hacking the QuickBooks Online UI

On a recent client project I had been regularly monitoring the QBO sandbox audit log to make sure we were getting the proper results before going live. To validate these results I thought of providing a CSV file of the audit log table to my client's accounting staff.

At first I was just cutting and pasting some JavaScript functions into the Chrome developer tools but that got old and time consuming so instead I just built a full-fledged extension, remarkably called "QBO Audit Log to CSV".

This is not a public released Chrome extension and it is only available at my Github repo.

The extension adds these 2 links to the Audit Log view only.

I did not officially release the extension on the Chrome Web Store so if you want to use it you'll have to follow the instructions in the next section.

Getting started

  1. You are going to need the latest Node version.
  2. $ mkdir ~/chrome-ext
    $ git clone git@github.com:minimul/qbo-audit-log-to-csv.git
    $ cd qbo-audit-log-to-csv
    $ npm install
    
  3. In a separate terminal run gulp watch.
  4. Open up Chrome and go to chrome://extensions.
  5. Check "Developer mode:" ✔︎
  6. Click "Load Unpacked Extensions"
  7. And navigate to the /app directory e.g. ~/chrome-ext/qbo-audit-log-to-csv/app
  8. Here are the main points of note when setting up the extension.
  9. Now you can open qbo.intuit.com/app/auditlog or sandbox.qbo.intuit.com/app/auditlog and you'll see the 2 links from Fig. 2.
This section is at the 2:20 mark.

Modifying the UI

The 3 main scripts that do all the work are: app/scripts.babel/background.js; app/scripts.babel/contentscript.js; and /manifest.json. I'll briefly discuss these files and some of things to take note of.

Minimul says —

The gulp watch command will automatically compile the files in the /app/scripts.babel directory to the /app directory whenever there is a file modification.

The manifest.json file

This is your config and setup file. Here is the manifest.json file for the QBO Audit Log To CSV extension.

{
  "name": "QboAuditLog to CSV",
  "version": "1.0.0",
  "manifest_version": 2,
  "description": "QboAuditLog to CSV",
  "icons": {
    "128": "images/128-icon.png"
  },
  "default_locale": "en",
  "background": {
    "scripts": [
      "scripts/chromereload.js",
      "scripts/background.js"
    ]
  },
  "permissions": [
    "tabs",
    "webNavigation",
    "https://*.intuit.com/*/*"
  ],
  "content_scripts": [
    {
      "matches": [
        "https://*.intuit.com/*/*"
      ],
      "js": [
        "scripts/contentscript.js"
      ],
      "run_at": "document_end",
      "all_frames": false
    }
  ]
}

In this file you determine which scripts are "background" and "content_scripts". You set permissions and determine what URL pattern the extension will run against. As you can see only permissions for "tabs" and "webNavigation" were needed.

The background.js file

The background.js file is a bit hard to explain so here is its definition according to the docs

A common need for extensions is to have a single long-running script to manage some task or state.

Background pages to the rescue.

As the architecture overview explains, the background page is an HTML page that runs in the extension process.

It exists for the lifetime of your extension, and only one instance of it at a time is active. (Exception: if your extension uses incognito "split" mode, a second instance is created for incognito windows.)

Let's take a look at the extension's background.js.

'use strict';

console.log('QboAudit Background Script');

chrome.runtime.onInstalled.addListener(details => {
    console.log('QboAudit previousVersion', details.previousVersion);
})

chrome.webNavigation.onHistoryStateUpdated.addListener( (details) => {
    console.log('QboAudit Page uses History API and we heard a pushSate/replaceState.')
    if(typeof chrome._LAST_RUN === 'undefined' || notRunWithinTheLastSecond(details.timeStamp)){
        chrome._LAST_RUN = details.timeStamp
        chrome.tabs.getSelected(null, function (tab) { 
            if(tab.url.match(/.*\/app\/auditlog1/)){
                chrome.tabs.sendRequest(tab.id, 'runQboAuditLog')
            }
        })
    }
})

const notRunWithinTheLastSecond = (dt) => {
    const diff = dt - chrome._LAST_RUN
    if (diff < 1000){
        return false
    } else {
        return true
    }
}

Of note is the highlighted line above. Naturally, you only want to run the extension within the QBO audit log, which has an ending URL like this: *.intuit.com/app/auditlog. To accomplish this I have a basic wild card set in the manifest.json but I couldn't dial in the permissions any further then what is presently set (see manifest.json above) without getting errors, therefore, I had to put the rest of the solution within the background.js file.

Minimul says —

What's with all of the chrome._LAST_RUN stuff?

Since QuickBooks Online is a single page app you'll need to monitor the push or history state to determine if the user is navigating around. There is a "bug" that duplicate push state events will get kicked off almost simultaneously. Therefore, the rest of the code in background.js deals with not running that duplicate event.

The contentscript.js file

The contentscript.js is the one script that will actually get run inside the page in the traditional sense, like as if you are loading it in via the page's <script></script> tag.

This file contains all of the extension's logic and is called by the background script when the conditions are right.

// background.js excerpt that calls the contentscript.js when the page's URL
// suffix of /app/auditlog is navigated to.
if(tab.url.match(/.*\/app\/auditlog/)){
  chrome.tabs.sendRequest(tab.id, 'runQboAuditLog')
}

// contentscript.js
chrome.extension.onRequest.addListener((request, sender, sendResponse) => {
    if (request == 'runQboAuditLog')
        new QboAuditLog()
});
  
This section is at the 9:28 mark.

Here is the full contentscript.js file:

'use strict';

class QboAuditLog {
    constructor(){
        [this.date, this.time] = new Date().toLocaleString('en-US').split(', ');
        this.init()
    }

    init() {
        const domCheck = setInterval( () => {
            console.log('QboAudit loop running');
            if(document.querySelector('.filterBarContainer')){
                clearInterval(domCheck)
                this.createLinks()
            } else {
                console.log('QboAudit miss');
            }
        }, 2000)
    }

    createLinks(){
        this.csvLink()
        this.removeDupsLink()
    }

    csvLink(){
        const link = document.createElement('a')
        link.innerHTML = 'CSV'
        link.addEventListener('click', () => {
            this.triggerDownload()
            return false
        })
        return document.querySelector('.filterBarContainer').appendChild(link)
    }

    removeDupsLink(){
        const link = document.createElement('a')
        link.innerHTML = 'Remove Dups'
        link.style.paddingLeft = '10px'
        link.addEventListener('click', () => {
            this.removeDups()
            return false
        })
        return document.querySelector('.filterBarContainer').appendChild(link)
    }

    download(csv, filename) {
        const csvFile = new Blob([csv], {type: 'text/csv'})
        const downloadLink = document.createElement('a')
        downloadLink.download = filename
        downloadLink.href = window.URL.createObjectURL(csvFile)
        downloadLink.style.display = 'none'
        document.body.appendChild(downloadLink)
        console.dir(downloadLink);
        downloadLink.dispatchEvent(new MouseEvent('click'))
        document.body.removeChild(downloadLink)
    }

    triggerDownload() {
        let csv = []
        const rows = this.getRows()
        for (let i = 0; i < rows.length; i++) {
            let row = [], cols = rows[i].querySelectorAll('td')
            for (let j = 0; j < cols.length; j++){
                row.push(`"${cols[j].innerText}"`)
            }
            csv.push(row.join(','))
        }
        return this.download(csv.join('\n'), `daily-report-${this.date}.csv`)
    }

    getRows(){
        return document.querySelectorAll('.dgrid-content table tr')
    }

    removeDups() {
        const sel = '.dgrid-column-4';
        const els = document.querySelectorAll(sel)
        els.forEach((item) => {
            const name = item.innerText
            let seen = {}
            this.getRows().forEach((tr) => {
                const txt = tr.querySelector(sel).innerText
                //console.log(`QboAudit ${txt}`);
                if(txt === ''){
                    tr.closest('div').remove()
                    return
                }
                if (name === txt){
                    if(seen[txt]){
                        tr.closest('div').remove()
                    } else {
                        seen[txt] = true
                    }
                }
            })
        })
    }

}

chrome.extension.onRequest.addListener((request, sender, sendResponse) => {
    if (request == 'runQboAuditLog') 
        new QboAuditLog()
}); 

Pay special attention to the init() method. I had to create a loop to check when the DOM was ready to properly insert the links. I tried various things but only constructing this manual loop worked.

Also, take a look at the download() method. It uses the Blob object but I needed to dispatch a mouseclick event as seen in this line downloadLink.dispatchEvent(new MouseEvent('click')) to actually get the download to work.

Conclusion

Be sure to check out the screencast as I have much more commentary including some useful information on debugging and on the automatic chromereload.js code that comes with the Yeoman Chrome Generator, which is what I used to generate the Chrome extension scaffolding. Lastly, don't forget all the code is in its own Github repo.