For the past two months, our team has been working on a GatsbyJS website built with TypeScript and React. While some of the content on the website is retrieved from a database, many web pages contain information that should be set by the owner of the website – we’ll refer to these pages as the “static site”. The owner of the website is not an experienced web developer, so they would like to be able to set the content of the static site without touching the source code. We needed a content management system (CMS) to provide this functionality, so we decided to use Netlify CMS, which manages the site content using a git repository.
Background
To build a CMS using Netlify CMS, the developer adds collections to their config file. The collections represent either individual pages or types of pages (for example, a blog post type). In each collection there should be one or more “widgets”, which provide interfaces for the site admin to add their custom content to. The choice of widgets determines what types of data the admin can add to each page in their site. Netlify CMS has a good selection of default widgets, which have proven to be flexible enough to cover most of our requirements for the static site. Part of this flexibility is thanks to the “object” and “list” widgets, which can have other widgets (including other objects and lists) nested in them.
If the developer wanted to allow the site admin to create a variable-length list of items, then they would add something like this to their config file (a YAML file, for those interested):
collections: - name: pages label: Pages files: - name: home label: Home file: path/to/markdownFile.md fields: - label: List Widget Example name: listWidget widget: list fields: - { label: Item Heading, name: itemHeader, widget: string } - { label: Item Content, name: itemText, widget: markdown }
This would generate a form on the admin page (where the site admin edits their content) containing a list of pairs of input fields – one simple text-field, and one rich-text editor with nice formatting options. As you might imagine, by nesting different widgets, a developer can create quite complex structures for their site admins to edit.
The problem
As flexible as the default widgets are, we did eventually come across a use-case that needed some custom code.
On our static site, we have an element that displays a variable number of icons in a circular layout.
Each icon had the same structure, so the natural choice would be to implement the editor for this graphic using a list widget. However, there is no upper limit to the number of items that you can add to a default list widget. If we allowed this behaviour for our graphic, it would not be long before the icons began to overlap.
The graphic also would not look very good with only one or two icons, as most of the space would be wasted.
What we really needed was a variant on the default list widget, with the ability to specify maximum and minimum lengths for the list. It is clear that we are not alone in wanting this feature, as there is currently a request to add maximum and minimum options to the list widget. However, as our project is time sensitive, we could not wait for this feature to be approved so we decided to create a custom list widget with the functionality that we needed.
Creating a custom widget
Netlify does officially support the creation of custom widgets and has provided a custom widget tutorial on their blog. When making our custom list widget, we encountered a couple of challenges that weren’t addressed in the official documentation – these will be the focus of this blog post.
A Netlify CMS widget is composed of two elements:
- The controls that the admin adds their changes to.
- A preview element, used to display the content in the preview window.
So, when we registered our widget with Netlify CMS, we needed both components:
cms.registerWidget('boundedList', boundedListControl, boundedListPreview);
In our case, it is only the control element that is really interesting, as the content contained in our custom list should be displayed in the same way as it would in the default list.
Writing a custom widget in Typescript
Unlike the official tutorial, we were working in TypeScript, not JavaScript. To benefit from the type-checking of the TypeScript compiler, we had to add types and interfaces to our code. This meant that we would have to do some investigating to work out what types Netlify was implicitly using in its widget code.
We had to define interfaces for the props (properties) and state that would be used by our widget component. Netlify CMS can pass a lot of props to a widget, but luckily we only had to define types for the props that we were actually going to use in our component. Similarly, because we never accessed the state of our component, we were able to avoid defining a state interface (instead, we just used “any”).
import * as React from 'react'; import { Map, List } from 'immutable'; interface BListProps { value: List<any>; field: Map<string, any>; } class BoundedListControl extends React.Component<BListProps, any> { /* some code */ render(): JSX.Element { return(/* some element */); } } const boundedListPreview = (props: any): JSX.Element => { /* some code */ return(/* some element */) }
In our case, we use “value” and “field” in the props. In the official documentation, the “field” object is revealed to be an immutable map. (Not that I originally noticed this – I spent some time working it out on my own. The lesson to learn is “always double-check the documentation”!) The type of “value” depends on the widget being used, but some printing to the console quickly identified our “value” to be an immutable list (which is hardly surprising). We also confirmed this by checking the source-code for the default list widget, where the types of many props are defined by a “propTypes” object.
Wrapping a default Netlify CMS widget
Because our custom list is very similar to the default list, it would be frustrating if we had to reimplement all the functionality that Netlify had already completed. Luckily, Netlify CMS provides a function to get the components of an existing widget for use in a custom one. Inside of our .render()
function, we could do something like this:
render(): JSX.Element { const ListControl = CMS.getWidget('list').control; return <ListControl {...this.props} />; }
Which would cause our widget to render the control component for the default list! Great, so we created our widget file in src/cms/boundedList.tsx
and immediately encountered a problem. We didn’t want to keep accessing Netlify’s global window.CMS
variable. Instead we preferred to check that it is valid once, then pass a variable round to the files that needed it. Netlify CMS instantiates widget objects automatically, so we couldn’t just pass the CMS variable as an argument to the widget’s constructor. Instead, we wrapped the class definition in a higher-order component. It initially looked wrong to return a class definition from a function, but when I learned that JavaScript class-definitions are really just functions themselves, it made a little more sense.
export function boundedListControlMaker(cms: CMSType) { return class BoundedListControl extends React.Component<BListProps, any> { … } } export function boundedListPreviewMaker(cms: CMSType) { return ((props: any): JSX.Element => { … }) }
With this pattern, the line to register our component with Netlify CMS looks a little different:
/* In a setup file where cms is in scope */ cms.registerWidget('boundedList', boundedListControlMaker(cms), boundedListPreviewMaker(cms));
So now, the full code to just wrap the default list widget with no functional changes is:
import * as React from 'react'; import { Map, List } from 'immutable'; /* An interface containing the function signatures that we want to use from Netlify CMS */ import { CMSType } from '../helpers/setupCMS'; interface BListProps { value: List<any>; field: Map<string, any>; } export function boundedListControlMaker(cms: CMSType) { return class BoundedListControl extends React.Component<BListProps, any> { render(): JSX.Element { const ListControl = cms.getWidget('list').control; return( <ListControl {...this.props} /> ); } } } /* This is the full preview component – it just returns the default list preview */ export function boundedListPreviewMaker(cms: CMSType) { return ((props: any): JSX.Element => { const ListPreview = cms.getWidget('list').preview; return ( <ListPreview {...props} /> ); }); }
Intercepting and modifying the “field” prop
The field prop is an immutable map containing all the options that were set for a widget in the config file. So, if we add options to set the maximum and minimum number of items allowed in our bounded list in the config file…
collections: - name: pages label: Pages files: - name: home label: Home file: path/to/markdown.file fields: - label: Bounded List Widget Example name: boundedListWidget widget: boundedList min_items: 3 max_items: 8 fields: - { label: Item Heading, name: itemHeader, widget: string } - { label: Item Content, name: itemText, widget: markdown }
Then they become accessible in the field prop in our widget component:
/* Include defaults because the options may not be explicitly set */ const minItemsDefault: number = 0; const maxItemsDefault: number = Number.MAX_SAFE_INTEGER; /* In a function somewhere... * The variables are set to the values of the options if they exist in config.yml, or the defaults otherwise */ const maxItems: number = this.props.field.get('max_items', maxItemsDefault); const minItems: number = this.props.field.get('min_items', minItemsDefault);
You can also access stuff like the name and label of the widget in the field prop!
You are also not stuck passing the exact same field prop to the wrapped widget. We wanted to set the “allow_add” option in the default list when the number of items reached the maximum, as this would disable the button to add a new item. However, we could not just pass an object containing allow_add: false
to the wrapped list because it would break due to the other missing options. So, we needed a way of modifying the field prop. Although the field prop is immutable, you can still call “set” on it – it will just return a new immutable map instead of modifying the value in the existing one.
/* In .render() */ const updatedFieldObject: Map<string, any> = this.props.field.set('allow_add', this.canAddItems()); /* Have to set the field prop explicitly because we want to use a different field object */ return( <ListControl {...this.props} field={updatedFieldObject} /> );
Using the “value” prop
The value prop represents the current value of the input field. It is updated as the site admin makes changes to the fields, not just after saving. This means that we could access the value prop to see how many items were in the list at that moment and check it against our maximum and minimum bounds.
The only thing to watch out for when getting the value prop is making sure that you have a default value to use if the value prop is undefined (it will be undefined if there is no saved data for that field).
getValue(): List<any> { return this.props.value || List(); }
We did not experiment with setting the value prop programmatically.
Calling functions on the wrapped widget
We also wanted to be able to call the function on the default list to add a new item, so that we could add empty items when too many items are removed from the bounded list. To be able to access the widget component that is being rendered, you need to create a ref for it. In TypeScript, you also need an interface containing the functions that you want to call.
interface ListWidgetInterface { handleAdd: (e: Event) => void; } export function boundedListControlMaker(cms: CMSType) { return class BoundedListControl extends React.Component<BlistProps, any> { private listWidget: React.RefObject<ListWidgetInterface>; constructor(props: BListProps) { super(props); this.listWidget = React.createRef(); } render(): JSX.Element { return( <ListControl {...this.props} ref={this.listWidget} /> ); } private someFunction = (): void => { /* Spoof the event object that handleAdd normally expects. */ const e: Event = new Event('default'); this.listWidget.current.handleAdd(e); } } }
Conclusions
Some of what we have done to get our bounded list to work may not be intended by Netlify, as their documentation does not cover it. However, for now at least, the bounded list is working as intended.
Being able to wrap an existing Netlify widget is certainly powerful but it isn’t perfect. Without going into the source code (which is thankfully possible, as Netlify CMS is open-source) and modifying that, there is only so much we were able to do with the wrapped list widget. For example, we had to enforce the minimum number of items by adding empty ones up to the minimum. This is not a wholly user-friendly solution, because we are silently making modifications to the content without the explicit input from the site admin. Though we added some notifications to let the admin know what is happening, it would still be much nicer to be able to disable the buttons that delete items when the length of the list reaches the lower bound. It was only because Netlify CMS already provided an option to disable adding items that we were able to use that in our custom widget.