Skip to main content

  • May 3, 2021

Monkey Patching in Craft

Craft CMS’ admin dashboard is very intuitive, while providing a lot of complex features and functionality. A great deal of this functionality depends on Craft's very own global variable (“Craft”) which extends Pixel & Tonic's GarnishJS UI Toolkit library. Decorative Illustration

But, what happens when you need to do something not supported out-of-the-box? That's where Monkey Patching, which I explain in this post, can help.

To start, Monkey Patching shouldn't be part of your daily routine nor should you be using this practice often. This technique should be used as a last resort, or specifically when another approach is not possible. This technique should also only be used when that "patch" will not affect existing functionality. When applying this to client projects, it should be considered only on a case-by-case basis. We wanted to share it because it’s interesting and is sometimes the only way to solve a problem, but there are definitely downsides to be aware of.

Adding our own JS to the admin dashboard is pretty straightforward through Asset Bundles, but sometimes we need to piggyback on existing functionality and in rare instances completely replace it -- for example, in a recent project we needed to add a new “tab button” to the default Asset Image Editor. Here's where a concept called Monkey Patching is very useful.

Wait, what’s Monkey Patching?

Monkey Patching is a technique in which you extend or modify a program's functionality by supplying a "patched" version of your own (see Wikipedia's definition here). The following examples will be in JavaScript, but Monkey Patching is a technique that can be used in any language

A quick example

A lot of you may already be using Monkey Patching without even knowing. Here's a quick example of overriding the console.log function that will show the date every time you call it. Try it in your browser.

const log = console.log;
console.log = function() {
 const date = new Date
 log.apply(
   console, 
   [date.toUTCString()].concat(...arguments)
 );
}

console.log('Foo Bar');
console.log({Foo: 'Bar'});

Now, for the nitty-gritty

As I noted earlier, in our scenario, we are trying to add a new "tab button" to the default Asset Image Editor. When you click on this button, you should see a new panel with different predefined "crop sizes" for the user to choose from and previewing how the image would look.

Our problem is that we need to find a way to "listen" for the user opening the Asset Image Editor so we can add our button and our views when the editor loads. Unfortunately for us, Craft's JS is very tightly coupled (this is not usually a bad thing!), which means that its classes and functions have a specific purpose that are meant to work exclusively with each other. Since it is using Garnish, some events don't bubble up the DOM tree as they have stopPropagation(). Our views also need to be added when the editor loads. Additionally, we'll need to "listen" when a user clicks "save" so we can record their selection in our new pane.

We have three main goals:

  • Listen for the Asset Image Editor opening.
  • Add our views when the editor loads.
  • Listen for the user clicking the save button.

How can Monkey Patching help us?

Once our Craft plugin has added our JavaScript using Asset Bundles, we can start “monkeying” around with our code. We'll start by creating a utility function to "patch" Craft's Asset Image Editor methods:

// utils.js
export function monkeypatch(
    Class,
    method,
    callback,
    override = false
) {
    const methodFn = Class.prototype[method];

    Class.prototype[method] = function(...args)
    {
        if (!override) {
            methodFn.apply(this, arguments);
        }

        callback.call(this, args[0]);
    }
}

Let's break it down! This function accepts 4 arguments: the JS class that contains the method we're trying to patch; the actual method name; our callback function; and an optional override method.

The first thing we do is save an instance of the existing method in case we need to use it later. We then override the method we are trying to patch in the class's prototype with a function. This function checks if the existing method shouldn't be overridden -- if so, call it and finally call our callback function with the same context and arguments as the original method.

In our main JS file we can do something like this:

// main.js
import { monkeypatch } from './utils';
import { loadEditorPatch, showViewPatch, saveImagePatch } from './patches';

const AssetImageEditorClass = Craft.AssetImageEditor;

monkeypatch(
    AssetImageEditorClass, 
    'EditorLoad',
    loadEditorPatch,
    true
);

monkeypatch(
    AssetImageEditorClass, 
    'showView', 
    showViewPatch,
    true
);

monkeypatch(
    AssetImageEditorClass, 
    'saveImage', 
    saveImagePatch,
    true
);

Now that we have a utility function and we’ve imported it, we can start patching some methods for the Asset Image Editor Class. After looking at the source code, I found that the methods we need to patch are 'EditorLoad', 'showView', and 'saveImage'.

Our patch for the “EditorLoad” method allows us to listen for the Asset Image Editor opening and adds our views; the “showView” method allows us to toggle between the different panes in the editor; and finally, the “saveImage” method allows us to save the user selection to the backend. What’s inside our patch functions is not necessarily important for this example, but this shows how Monkey Patching lets you add functionality that would otherwise not be possible.

All told, Monkey Patching is a great technique for edge cases where no other approach seems feasible. It allows you to extend or completely replace functionality from existing frameworks that you have no access to the source code. As with anything, you have to be wary of the use cases and maintainability. Rapid releases can cause your patch to stop working -- and even worse, to break -- but if used correctly, you can use this technique to extend not just Craft’s Dashboard but pretty much anything.

*Disclaimer: Please use at your own risk. Changes could break at any time as you’re going outside of documented APIs. Happy Coding!

Back to Top

comments powered by Disqus