Using Svelte for a CMS

Published 2017-07-05


In my current work with From Now On I inherited a CMS website that was built with old technology, created in a rush, and had a lot of fundamental issues. In the process of upgrading the site, we decided to use a totally amazing no-framework JS tool called Svelte.

After having spent a few months slowly rebuilding the existing CMS, I have a design approach that seems to be working pretty well and is easy to modify and maintain, so I'm going to describe it here!

Intro

I'll introduce some of the tools and concepts used. We use Bootstrap for fast styling, Svelte for the templates/HTML, and some data structures to make message passing and object saving consistent and easier to pull into the brain.

Intro: Bootstrap

We are using Bootstrap, so an example text input field might be these HTML elements:

<div class="form-group">
	<label for="firstname">First Name</label>
	<input type="text" class="form-control" id="firstname" placeholder="Name">
</div>

With bootstrap you can add a class to the div.form-group element to indicate the "validation state" of that field. So if you wanted a nice red outline around the input element to indicate an error, you would add the has-error class to the div.form-group:

<div class="form-group has-error">
	<label for="firstname">First Name</label>
	<input type="text" class="form-control" id="firstname" placeholder="Name">
</div>

Bootstrap is opinionated, and I don't use all of it, so later I'll show you how to customize it for your own site.

Intro: Svelte

The bits of Svelte that I consider the fundamentals are that you write in mostly normal HTML, you pass data in as attributes, and you pass data out as events:

<!-- SomeForm.html -->
<MyComponent
	myStuff="{{things}}"
	on:namedEvent="myHandler(event)"
/>

Svelte does support two-way binding, and I will show you how I use it safely. For now though, two-way binding is not something that you will generally use. Instead, a component will take in a named property and emit named events.

In the above example:

Intro: Composing Components

Since we are building a CMS, mostly we are dealing with a lot of views that have forms, with lots of inputs. And in anything like that, you'll definitely end up with repeatable chunks that you don't want to rewrite for every view.

What I've ended up doing is distinguishing between components that are the lowest level and emit single events (like a text input element with minimal classes/styling), versus components that contain one or many of those low level components.

Currently I've named them "form field" (for a single field) and "form element" for a composed set of fields. That naming isn't very clear, so I'll probably change it when I think of something better.

The biggest issue here is that you want all your different components to be taking in consistently named values, and emitting consistently named events. In HTML <input> elements, it seems that the norm is naming the value input value, and the native emitted change event is usually named onchange or on:change in Svelte. Let's keep that, and stick with these guidelines:

Specifically, the low-level components should really attempt to adhere to those guidelines.

Intro: Data/Message Structure

What I really wanted to end up with was some component that could look like this:

<!-- MyForm.html -->
<ComponentOne
	value="form.propertyOne"
	on:change="...do something one..."
/>
<ComponentTwo
	value="form.propertyTwo"
	on:change="...do something two..."
/>

And so I wanted all the components to emit change events that were consistently shaped.

What I ended up doing is following these rules:

Low-level form components emit raw values.

For example, a form component that is a wrapper for an <input type="text"> would emit a change event where typeof event === 'string' and so on.

Higher-level form components emit "change" objects.

These change objects look like this:

{
	"namedProperty": "emitted value"
}

So for example, a higher-level component that handles a first and last name might emit a change event that looks like this:

{
	"firstName": "Billy"
}

Because the thing emitted is an object, it can emit a change object containing multiple properties, at any depth. For example, an address book component might emit a change event that looks like this:

{
	"person": {
		"firstName": "Billy",
		"lastName": "Badger"
	},
	"age": 72
}

Because of this consistent behaviour, a view that is a complex form really is just a component that emits bigger change objects, and you can make some utilities to accumulate and save overall emitted changes.

(For example, I wrote a difference accumulator that we use at work, and I'll demo in here.)

If it isn't clear why this design decision is powerful, it should become clearer in some of the later sections.

The Framework

With all the pieces described in the intro section, we are ready to begin putting it all together into something useful and powerful. We will start with building Svelte forms, including with error handling, then we will look at a design for handling changes from those forms. With those ideas, we can build any kind of view.

The Framework: Form Fields

As I've said, I'm not real happy with the name, but a "form field" is an individual field, like a single text input.

For something like a text input, there are a few properties that you will almost always want to have, and where you have them you'll want them to be consistent. You'll want to build up a library of these fields which are built and styled (themed) for your site.

Since we are using Bootstrap, here's what a complete text input field looks like:

<!-- FormFieldText.html -->
<div class="form-group {{state ? `has-${state}` : ''}}">
	{{#if label}}
	<label
		for="{{id}}"
		class="control-label"
	>
		{{label}}
		{{#if helptext && showHelptext}}
		<span class="text-muted">({{helptext}})</span>
		{{/if}}
	</label>
	{{/if}}
	<input
		type="text"
		class="form-control"
		id="{{id}}"
		placeholder="{{placeholder || ''}}"
		bind:value="value"
		on:change="fire('change', value)"
		on:focus="set({ showHelptext: true })"
		on:blur="set({ showHelptext: false })"
	>
</div>

There's a lot going on here, so let me break it down:

In addition, you'll notice that I am using two-way binding here, with bind:value="value", and then when the on:change event is fired, I'm using value instead of event.

This is for simplicity: the change event fires an actual DOM/JS event, so you would need fire('change', event.target.value) and there are some lifecycle issues that I don't understand very well yet, so it ends up being simpler to write it that way. (Svelte also runs code on the onchange event, which handles updating the scope value, and then fires the Svelte-flavored on:change event.)

Note also that we are watching for the change event. This means the value will not update until that text input field loses focus, which has worked well in almost all cases so far. There may be cases where you want to watch for actual keypresses, but it's likely that you can create a good user experience with change only.

You might compose this form-field component in a form like this:

<!-- NameForm.html -->
<div class="row">
	<div class="col-xs-12 col-md-6">
		<FormFieldText
			label="First Name"
			placeholder="Jean"
			helptext="Must be 2-20 characters."
			value="{{firstname}}"
			on:change="fire('change', { firstname: event })"
		/>
	</div>
	<div class="col-xs-12 col-md-6">
		<FormFieldText
			label="Last Name"
			placeholder="Phillipe"
			helptext="Must be 2-20 characters."
			value="{{lastname}}"
			on:change="fire('change', { lastname: event })"
		/>
	</div>
</div>

Notice that this NameForm.html uses multiple components, and on a change event for each component it fires that data structure discussed in the intro: { key: value }.

Using this data structure consistently means that you can use the NameForm component inside a bigger form, listen for change events, and know the emitted data is always consistent.

That brings me to the next section!

The Framework: Forms

Each editable page view within the CMS can be thought of as a "form", which (in this framework) is a component containing any number of form-field components or form elements. After this we'll build a few tools to make it easier to interact with these forms.

In general, you might think of a "form" as something that's a complete component, not meant for re-use. Because of that, I've set the input data on the property form instead of value, but consistency might end up being better instead.

Here's a much-shortened example of a form within our app, used to edit games (like football, soccer, etc.):

<!-- GameEdit.html -->
<h1>{{form.title}}</h1>
<fieldset disabled="{{saving}}">
	<div class="row">
		<div class="col-xs-12 col-lg-6">
			<h2>Teams</h2>
			<FormFieldSelect
				label="Our Team"
				options="{{teams}}"
				selected="{{form.teamId}}"
				state="{{state.teamId}}"
				on:change="fire('change', { teamId: event })"
			/>
			<FormFieldText
				label="Their Team"
				value="{{form.opponentName}}"
				state="{{state.opponentName}}"
				on:change="fire('change', { opponentName: event })"
			/>
		</div>
		<div class="col-xs-12 col-lg-6">
			<h2>Images</h2>
			<ImagesBasic
				value="{{form}}"
				state="{{state}}"
				on:change="fire('change', event)"
			/>
		</div>
	</div>
	<div class="row">
		<div class="col-xs-12">
			<button on:click="fire('save', form)">Save Changes</button>
		</div>
	</div>
</fieldset>

<script>
import FormFieldSelect from './FormFieldSelect.html'
import FormFieldText from './FormFieldText.html'
import ImagesBasic from './ImagesBasic.html'

export default {
	components: { FormFieldSelect, FormFieldText, ImagesBasic },
	data() {
		return {
			// If `form` or `state` are ever undefined, this form
			// component will break and throw a null pointer exception,
			// so we set the defaults.
			form: {},
			state: {}
		}
	}
}
</script>

Note a couple things:

These form components are used, not inside other Svelte components, but inside a state router, so we'll have something like this:

// imagine that `component` is an already loaded and
// active Svelte component
function activateView(service, component) {
	// we keep track of the changes
	const changes = {}
	component.on('change', diff => {
		Object.assign(changes, diff)
	})
	// when the form is saved, disable the component form,
	// then call a service to save changes
	component.on('save', form => {
		component.set({ saving: true })
		service
			.call('save form', { diff, form })
			.then(updatedForm => {
				// when the form save is complete, update it
				// with the final updated form
				component.set({
					form: updatedForm,
					saving: false
				})
			})
	})
}

In practice, it gets more complicated than this, but that's the core principle. For any field (and for the complete form) you should always be thinking about the possible states it can be in.

Writing Tests

Testing Svelte components is a little tricky, because you'll probably need to compile them before testing. I did that by using glob-module-file, browserify, and tape-run. I name all the Svelte tests like *.test-svelte.js and then have this in the package.json:

{
	"scripts": {
		"test": "glob-module-file --pattern='./**/*.test-svelte.js' | browserify - | tape-run"
	}
}

This will glob up all your Svelte tests and execute them inside electron. That's not exactly fast, but I haven't found a better way that I like yet.

Now What?

Of course there's a lot more to building a framework, and we've made many architectural decisions within our app already that are custom to what we want to accomplish.

Since I'm running out of steam writing this, I thought I'd list some modules that we use, and some that I've personally found to be very useful. I'd advise you to either use these tools/modules, or learn enough to see the idea they are trying to solve, and see if those concepts are worth integrating into your own app.

The End

That's all for now!

As people ask questions or I think of more things that don't quite fit inside this article, I'll add them to this open issue, but if you've got questions about any of this, or want to pick my brain about ideas, don't hesitate to ask by opening new a Github issue.

Eventually I'd like to put together a demo of all this, and if I do I'll update this document. You could open a Github issue about it and that might be easier to track than this website.

License

This article and all code herein, I release under the Very Open License.