Skip to content

Frontend Customization

Craig Jennings edited this page Jul 10, 2018 · 2 revisions

Customization

Agent has been designed to make customization as easy as possible, while still maintaining a simple upgrade process that minimizes conflicts. To do this, each module has been split into its own file, and webpack has been programmed to look for custom code before baseline code when compiling. This means that customizations can be applied on a granular level while not editing baseline code. Let's go through some ways to customize certain modules in Agent.

Models

Models are simply Backbone.Model extended, and as such can be extended just as simply. For example,

const BaseModel = Backbone.Model.extend({
  defaults: {
    key1: 'value1',
  },

  getData() {
    return {
      data: 'data',
    };
  },

  parse(res) {
    return res;
  },
});

// Custom file
const CustomModel = BaseModel.extend({
  parse(res) {
    return res.data;
  },
});

CustomModel will retain the defaults of BaseModel, but its parse method has been updated.

Things become slightly more complicated when properties need to be customized, but they can be handled in a straight-forward manner. If extra defaults needed to be added to the above BaseModel, we can just extend the prototype's defaults in the CustomModel:

const CustomModel = BaseModel.extend({
  // This overrides the defaults of BaseModel
  defaults: {
    key2: 'value2',
  },

  // Instead, do this
  defaults: _.extend({}, BaseModel.prototype.defaults, {
    key2, 'value2',
  }),
});

The second declaration will properyl assign the defaults of the CustomModel to contain both key1 and key2.

It is also possible to call the BaseModel's method, and then continue with the custom logic:

const CustomModel = BaseModel.extend({
  getData() {
    const data = BaseModel.prototype.getData.apply(this, arguments);

    data.moreData = 'moreData';

    return data;
  },
});

It is a good common practice to call base class methods via apply(this, arguments) as that will ensure that if more arguments are added to the baseline method, the customization will pass them along correctly and no update to the code will be required.

Views

Views will be extended in much the same way Models are. Properties can be overriden or extended, and methods can be replaced or enhanced. A common pattern that we strive to implement, particularly with form views, is creating a getData method that returns the data in the DOM necessary to submit the form. The reason for this is to make it easier to extend the data needed in the form. For example,

// AccountFormView.js
const AccountFormView = Marionette.LayoutView.extend({
  getData() {
    return {
      name: this.$('#name').val(),
      orgId: this.$('#orgId').val(),
      status: this.$('#status').val(),
      type: this.$('#type').val(),
    };
  },
});

// CustomFormView.js
const CustomAccountFormView = AccountFormView.extend({
  getData() {
    const baseData = AccountFormView.prototype.getData.apply(this, arguments);

    const data = _.extend(baseData, {
      aNewProperty: this.$('.new-field').val(),
    });

    return data;
  },
});

Some Views have a generateTabs method that is responsible for creating the tabSettings object and returning it. This again makes an easy injection point for customizations. Another aid in customization is the OrderedMap that it creates. This class allows for easy removal and addition of entries in the map. That means it is very useful when creating a set of tabs so that this set can be manipulated later.

// AccountFormView.js
const AccountFormView = Marionette.ItemView.extend({
  //...
  generateTabs() {
    const tabs = new OrderedMap();

    tabs.add('tabOne', { /* settings */ });
    tabs.add('tabTwo', { /* settings */ });

    return tabs;
  }
});

// CustomAccountFormView.js
const CustomAccountFormView = AccountFormView.extend({
  generateTabs() {
    const tabs = AccountFormView.prototype.generateTabs.apply(this, arguments);

    tabs.remove('tabTwo');
    tabs.add('customTab', { /* settings */ });
    tabs.insert('firstTab', { /* settings */ }, 0);

    return tabs;
  }
});

If tabs are added in baseline that aren't desired in the customization, it's a simple one-line change to remove that tab.

Controllers

Most controllers are simple ES6 Classes. In a handful of situations, a Marionette Object is used mainly for its built-in ability to listenTo other objects when needed. Depending on the style of controller, customizing it will look different, yet still fairly simple.

const Controller = Marionette.Object.extend({
  show() {
    // ...
  },
});

// CustomController.js
const CustomController = Controller.extend({
  show() {
    Controller.prototype.show.apply(this, arguments);
  },
});
class Controller {
  show() {
    // ...
  }
}

// CustomController.js
class CustomController extends Controller {
  show() {
    super.show(arguments);
  }
}

Overriding Modules

Agent has been built with customization in mind and strives to make it easy for developers to add, change, and remove functionality without disrupting other parts of the application. One rule of thumb to follow when customizing Agent: Never change a baseline file. Always instead create a custom file that pulls in the baseline file and manipulates the object as needed. Let's see how this works in action.

Webpack has been programmed to search in the custom bottle for javascript code before it searches in baseline bottles. This means that if webpack is trying to resolve the dependency 'app/core/MyModule', it will first look for source/custom/content/scripts/app/core/MyModule before searching elsewhere. This means that if a file in that path exists, Webpack will stop searching and resolve the dependency with that file. This is very powerful. Basically, any file can be overridden by having a file with the matching directory structure in the custom bottle.

// source/Agent.Shared.Core/content/scripts/app/core/MyModule.js
class MyModule {
  // ...
}

// source/custom/content/scripts/app/core/MyModule.js
// This overrides the 'MyModule' class above
class MyCustomModule {
  // ...
}

export default MyCustomModule;

This, however, creates a problem. If the desire is to extend a module instead of replace it, there's seems to be no way for the custom script to point back to the baseline.

// sources/custom/content/scripts/app/core/MyModule.js
import MyModule from 'app/core/MyModule'; // This will resolve to the custom file again

Enter the power of Webpack! We have configured Webpack to have special aliases back to baseline code for situations like this.

// sources/custom/content/scripts/app/core/MyModule.js
import MyModule from 'override/core/MyModule'; // This will always resolve to the baseline file

class MyCustomModule extends MyModule {
  // ...
}

export default MyCustomModule;

Following this pattern, any object can be overridden or extended. This is why the rule of thumb is Never change a baseline file. There should never be a need to. It should raise flags if you find yourself editing baseline files, and there is most likely a better way to achieve the customization desired without changing the baseline code.

However, Agent isn't perfect and there are bound to be areas where customizing code is still more difficult that it should be. If there is a place where this is the case, please create a case explaining how the code could be better structured to provide for easier customization and we will investigate making that change.

Conclusion

Customization is integral to Agent's design and with the power of Webpack it is simple and straight-forward to achieve this.

Next: Styling

Clone this wiki locally