Capturing save events client-side
Before we can save any changes we need to be able to know when the page's content has changed. This is covered in the Getting started guide but it's worth covering again now.
Whenever the user saves the document (e.g by clicking the green tick button), a saved event will be triggered against our editor instance. We can listen for the saved event and assign a function to save the updated content to a persistent store such as a file or database on the server. The following code assigns a function to listen for the saved event.
// Initialize our editor
var editor = ContentTools.EditorApp.get();
editor.init('*[data-editable]', 'data-name');
// Listen for saved events
editor.addEventListener('saved', function (ev) {
// Save the changes ...
var regions = ev.detail().regions;
});
To listen for the saved event we call the addEventListener method with the name of the event we want to listen for and a callback function that will be called whenever that event is triggered. Our callback will receive an event (ev) instances which contains a map of editable regions that have been modified.
Synchronous or asynchronous
Once we receive a save event we need to send our data to the server (assuming we're going to be using a server-side store). We can either do that:
- Synchronously - create a form, populate it with hidden fields and submit it. The server-side handler then saves the changes to the store and redirects the user back to the page.
- Asynchronously - we send our save request using an AJAX request or Websocket push. This is the recommended approach as it allows the save operation to be performed in the background whilst the user continues to interact with the page.
Auto-saving
Nothing's fool proof and browsers crash; auto-save has saved me hours of work enough times to warrant it a few paragraphs here. The editor doesn't support auto-saving out of the box but it's easy to implement, all that's needed is to call the save method against the editor at a set interval:
// Add support for auto-save
editor.addEventListener('start', function (ev) {
var _this = this;
// Call save every 30 seconds
function autoSave() {
_this.save(true);
};
this.autoSaveTimer = setInterval(autoSave, 30 * 1000);
});
editor.addEventListener('stop', function (ev) {
// Stop the autosave
clearInterval(this.autoSaveTimer);
});
Now every 30 seconds the editor will check to see if the user has made any changes, and if they have a save event will be triggered. The benefits of the asynchronous approach are clear here, reloading the page every time we auto-save is going to get on the user's nerves pretty quickly.
Note that we're sending true to the editor's save method; this tells the editor we want the save to be passive, without this the regions will be converted to non-editable HTML.
Sending save requests to the server with AJAX
For the purpose of the tutorial we're going to assume we've decided on sending our save requests using AJAX. The following code does just that:
editor.addEventListener('saved', function (ev) {
var name, onStateChange, passive, payload, regions, xhr;
// Check if this was a passive save
passive = ev.detail().passive;
// Check to see if there are any changes to save
regions = ev.detail().regions;
if (Object.keys(regions).length == 0) {
return;
}
// Set the editors state to busy while we save our changes
this.busy(true);
// Collect the contents of each region into a FormData instance
payload = new FormData();
payload.append('__page__', window.location.pathname);
for (name in regions) {
payload.append(name, regions[name]);
}
// Send the update content to the server to be saved
onStateChange = function(ev) {
// Check if the request is finished
if (ev.target.readyState == 4) {
editor.busy(false);
if (ev.target.status == '200') {
// Save was successful, notify the user with a flash
if (!passive) {
new ContentTools.FlashUI('ok');
}
} else {
// Save failed, notify the user with a flash
new ContentTools.FlashUI('no');
}
}
};
xhr = new XMLHttpRequest();
xhr.addEventListener('readystatechange', onStateChange);
xhr.open('POST', '/x/save-page');
xhr.send(payload);
});
Our code calls /x/save-page using the POST HTTP method. The page's pathname is stored against the __page__ parameter (e.g for this page it would be /tutorials/saving-strategies), and the content of each region is stored using the region's name as the parameter name.
The above code will work whether you've implemented the auto-save code or not.
Server-side storage
The number of ways to save content is I suspect infinite but fortunately we're only going to look at a couple of them.
The server-side code examples are all presented in Python and use the web framework Flask. I've used many languages and frameworks and I've tried to ensure the code presents the principles without getting bogged down in the language and framework specifics.
Saving to file
The first approach is to save changes directly into the HTML or template files being edited. I'm using a variant of this approach to save content on this site (including this page you're reading now). Not only is it very simple to implement, but it also works great with version control.
You can choose to save into static HTML files or if you're using a template engine (I'm using Jinja2 for this site) then it's an easy step to save into templates also.
For the tutorial we'll assume we have a site structured as below:
/site /html index.html about.html /static content-tools.css content-tools.js site.js app.py
Our web app (defined in app.py) will be responsible for mapping URLs to the relevant files in our /html folder, (e.g /my-page will return /html/my-page.html) as well as handling /x/save-page requests. The site.js file should contain the code from the client-side section of the tutorial, the content-tools.css/.js should be copied from the build folder of the repo.
We won't be looking at providing secured access to the save_page view and what that entails as it's beyond the scope of the tutorial - most web frameworks provide documentation and tutorials on this task. For this site I actually limit editing to the local development environment and use fabric to deploy to the production environment via the repo. It's a little crude but it works great and saved me time better spent writing documentation.
The following code is our complete web app including the show_page and save_page views:
from flask import Flask, abort, render_template
import os
# Create the app
app = Flask(__name__)
@app.route('/', defaults={'page': 'index'})
@app.route('/<page>')
def show_page(page):
"""Map a URL path to a HTML filename"""
html_root = os.path.abspath('html')
filename = os.join('html', page) + '.html'
# Is the filename safe to access?
if not os.path.abspath(filename).startswith(html_root):
abort(404)
# Do we have an HTML file to match the URL?
if not os.exists(filename):
abort(404)
# Load the contents of our HTML file
with open(filename, 'r') as f:
html = f.read()
return html
@app.route('/x/save-page', methods=['POST'])
def save_page():
"""Save changes to a page"""
html_root = os.path.abspath('html')
# Find the page
filename = request.form['__page__']
if filename == '':
filename = 'index' # The index page will appear as ''
filename += '.html'
# Is the filename safe to access?
if not os.path.abspath(filename).startswith(html_root):
abort(404)
# Do we have an HTML file to match the URL?
if not os.exists(filename):
abort(404)
# Read the contents of the HTML file and update it
with open(filename, 'r') as f:
html = f.read()
# For each parameter in the request attempt to match and replace the
# value in the HTML.
for name, value in request.form.items():
# Escape backslashes in the value for regular expression use
value = value.replace('\\', '\\\\')
# Match and replace editable regions
start = '<!--\s*editable\s+' + re.escape(name) + '\s*-->'
end = '<!--\s*endeditable\s+' + re.escape(name) + '\s*-->'
html = re.sub(
'({0}\s*)(.*?)(\s*{1})'.format(start_tag, end_tag),
r'\1' + value + r'\3',
template_html,
flags=re.DOTALL
)
# Save changes to the HTML file
with open(filename, 'w') as f:
f.write(html)
return 'saved'
if __name__ == "__main__":
app.run()
For the most part the code and accompanying comments should be self explanatory, however the way the HTML is updated on saving the page deserves a little more explanation. Here's the HTML for our index page (index.html):
<html>
<head>
<title>My site</title>
<link rel="stylesheet" type="text/css" href="/static/content-tools.css">
</head>
<body>
<main data-editable data-name="main-content">
<!-- editable main-content -->
<h1>Welcome to my website</h1>
<p>It's nice here</p>
<!-- endeditable main-content -->
</main>
<script src="/static/content-tools.js"></script>
<script src="/static/site.js"></script>
</body>
</html>
Notice that there are 2 comments, one at the start of our editable block <!-- editable main-content --> and one at the end <!-- endeditable main-content -->. These comments are markers for the section of the page that should be replaced.
The region name (given by data-name) must match the name inside the comments or the save will either do nothing, or worse will insert the wrong content (e.g content intended for another region).
You don't have to use comments (or markers) to update the HTML file, another option would be to use an HTML parser library to find and replace the relevant elements, however if you're updating templates this option might not be available and markers work well here.
The really nice thing about this approach is you can still update your files directly in a text editor and assuming you're using a version control tool such as Git or Mercurial all your changes are now tracked.
Saving to a database
Text editors and repos work great for those of us used to this workflow, but for customers more accustomed to content management systems Git and Sublime mean very different things (well sometimes it's the same in the case of Git). Not only that but in-page editing is often applicable to only part of the data schema; a product description may be a candidate for in-page editing but the pricing matrix or available product variations won't be.
The product scenario is a good example. Databases make it easy to search and order records and to access information in a native format (such as a product's price). In this case it makes sense to store the product data, including the description, in a database.
If you're using a database with a schema-less structure such as MongoDB you can most likely store the region data as a dictionary-like structure, if not then you can pack the data as JSON to store it as a string and unpack when you retrieve it.
Before we do that though, let's start by making a small change to our client-side code so that instead of sending the regions as individual parameters they're sent as a single JSON value.
// Collect the contents of each region into a FormData instance
payload = new FormData();
payload.append(
'product',
document.querySelector('meta[name=product]').getAttribute('content')
);
payload.append('regions', JSON.stringify(regions));
...
xhr.open('POST', '/x/save-product'); // We've renamed the save view
Now instead of looping through each item in the regions object and adding a parameter for it we simply pack the regions object as JSON and add it as a parameter. We're also assuming that the product's Id has been set in a meta tag in the HTML head (this will be shown shortly).
Back to the server-side and how to store our content in the database. We need to rewrite our save_page view (renamed save_product) to store our data in the database:
@app.route('/x/save-product', methods=['POST'])
def save_product():
"""Save changes to a page"""
# Find the product we're updating
product_id = ObjectId(request.form['product'])
product = app.db.products.find_one({'_id': product_id})
if not product:
abort(404)
# Convert the regions provided to a dictionary
regions = json.loads(requset.form['regions'])
# Save the new description for the product
db.products.update_one(
{'_id': product_id},
{
"$set": {
"description": regions
}
})
return 'saved'
One thing to notice here is that we're still catering for multiple regions whilst we're saving under one field/attribute description, the reason for this is that the description might actually be made up of more than one region, for example we might have a region for the main description and another for specifications.
If we're saving into a database that only allows flat data, such as MySQL, then we can validate the regions parameter as JSON then store it directly in the description field:
# Make sure we can read the JSON
regions = json.loads(regions)
# Save the new description for the product
app.db.cursor().execute('''
UPDATE `products`
SET `description`=%s
WHERE `id`=%s
''',
(json.dumps(regions), product_id)
)
Some SQL databases have support for working with JSON - it's worth finding out what's supported if you're implementing this approach.
So we can now save our product descriptions in the database but we also need to rewrite the show_page view (renamed save_product) to retrieve and display our product data:
@app.route('/products/<slug>')
def show_product(slug):
"""View a product"""
# Find the product
product = app.db.products.find_one({'slug': slug})
if not product:
abort(404)
# Render the product template
return render_template('product.html', product=product)
Our new view returns a rendered template instead of a HTML file:
<html>
<head>
<title>{{ product.name }}</title>
<meta name="prouduct" content="{{ product._id }}">
<link rel="stylesheet" type="text/css" href="/static/content-tools.css">
</head>
<body>
<main class="product">
<h1 class="product__name">{{ product.name }}/<h1>
<div class="product__price">£{{ product.price }}</div>
<div
class="product__main"
data-editable
data-name="main"
>{{ product.description.main|safe }}</div>
<aside
class="product__spec"
data-editable
data-name="spec"
>{{ product.description.spec|safe }}</aside>
</main>
<script src="/static/content-tools.js"></script>
<script src="/static/site.js"></script>
</body>
</html>
There are no markers for the editable regions now, instead we include the content within the editable regions just as we would any other product attribute.
The use of the safe filter instructs the template engine (Jinja2) not to escape the value; since we're inserting HTML we don't want it escaped.
That's all folks
I'm very fond of the saving to file approach, especially when used to update templates - it's pure simplicity. However from experience I know that 9 times out of 10 I'll be setting up a database and allowing users to edit attributes both using forms as well as the in-page editor. Even something as simple and content rich as a blog benefits from a database; listing entries in date order, publishing at a given date/time and allowing visitors to comment are all features that a database makes easy.