A video version of this talk is now available at https://learnwagtail.com/tutorials/headless-wagtail-workshop-with-vue-js/
python3 -m venv wagtailenv
source wagtailenv/bin/activate
pip install --upgrade pip
pip install wagtail
wagtail start backend
cd backend
./manage.py migrate
./manage.py runserver
./manage.py createsuperuser
./manage.py startapp news
- add
'news'
toINSTALLED_APPS
inbackend/settings/base.py
- Edit
news/models.py
:
from django.db import models
from wagtail.core.models import Page
from wagtail.core.fields import RichTextField
from wagtail.admin.edit_handlers import FieldPanel
class NewsPage(Page):
date = models.DateField("Post date")
intro = models.CharField(max_length=250)
body = RichTextField(blank=True)
content_panels = Page.content_panels + [
FieldPanel('date'),
FieldPanel('intro'),
FieldPanel('body', classname="full"),
]
./manage.py makemigrations
and ./manage.py migrate
Log into the admin, check it's all working and publish a couple of pages.
Follow the first three points from the Wagtail API docs:
- enable the API (including
'rest_framework',
) - configure endpoints
- register URLs
Try fetching all news pages:
http://127.0.0.1:8000/api/v2/pages/?type=news.NewsPage
By default, the API only exposes common fields, like title and slug. To add more fields to our API representation of news pages, edit models.py
:
from wagtail.api import APIField
# under content_panels:
api_fields = [
APIField('date'),
APIField('intro'),
APIField('body')
]
Now we can request our custom fields from the API:
http://127.0.0.1:8000/api/v2/pages/?type=news.NewsPage&fields=intro,body
Make a new folder called frontend
, at the same level as backend
. All your HTML and JavaScript files should go in here. Make a file called vue.html
:
<!DOCTYPE html>
<html>
<head>
<title>My first Vue app</title>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
</head>
<body>
<h2>My first Vue app</h2>
<div id="app">
{{ message }}
</div>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!!'
}
})
</script>
</body>
</html>
Open it in your browser. It should work if you just open the file, but for a more realistic environment you can run it from a tiny Python web server: inside frontend
, run python3 -m http.server 8001
.
In the console, try setting a new value for app.message
. Then try out two-way binding, by adding <input v-model="message">
somewhere inside <div id="app">
. Like Django, Vue has filters. Try adding
filters: {
upper: function (value) {
return value.toUpperCase()
}
},
after el: '#app',
, then add | upper
to your {{ message }}
output.
Make a new file called people.html
:
<html>
<head>
<title>Workshop People</title>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<h1>Workshop People</h1>
<ul>
<li v-for="person in people">
{{ person.name }}
</li>
</ul>
</div>
<script>
const app = new Vue({
el: '#app',
data () {
return {
people: []
}
},
mounted () {
axios
.get('https://api.jsonbin.io/b/5f9fe84ba03d4a3bab0b709b')
.then(response => (this.people = response.data.people))
}
})
</script>
</body>
</html>
Try manipulating the list of people with app.people.pop()
and .push()
.
See the VueJS docs for more information on fetching resources with Axios.
Make a new file called news-listing.html
:
<html>
<head>
<title>Headless news</title>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<h1>Headless news</h1>
<div v-for="item in news">
<h2>{{ item.title }}</h2>
<p>{{ item.intro }}</p>
</div>
</div>
<script>
const app = new Vue({
el: '#app',
data () {
return {
news: []
}
},
mounted () {
axios
.get('http://127.0.0.1:8000/api/v2/pages/?type=news.NewsPage&fields=intro,body')
.then(response => (this.news = response.data.items))
}
})
</script>
</body>
</html>
Why doesn't this work? Check the console errors - we need CORS headers. How about changing the date format? Wagtail takes advantage of Django Rest Framework's custom serialisers.
Wagtail provides an endpoint for individual pages. You can see the listing at http://127.0.0.1:8000/api/v2/pages/. Why don't the detail_url
s work?
Make a new file called news-item.html
:
<html>
<head>
<title>Headless news</title>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<h1>{{ item.title }}</h1>
<h2>{{ item.intro }}</h2>
<p>{{ item.body }}</p>
</div>
<script>
const app = new Vue({
el: '#app',
data () {
return {
item: {}
}
},
mounted () {
axios
.get('http://localhost:8000/api/v2/pages/4/')
.then(response => (this.item = response.data))
}
})
</script>
</body>
</html>
What's wrong with {{ item.body }}
? Double moustaches interpret the data as plain text, not HTML - try <p v-html="item.body"></p>
instead.
Make a new file called routing.html
:
<html>
<head>
<title>Routing demo</title>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<h1>Routing demo</h1>
<p>
<router-link to="/foo">Go to Foo</router-link>
<router-link to="/bar">Go to Bar</router-link>
</p>
<!-- component matched by the route will render here -->
<router-view></router-view>
</div>
<script>
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }
const routes = [
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar }
]
const router = new VueRouter({
routes
})
const app = new Vue({
router
}).$mount('#app')
</script>
</body>
</html>
Now let's combine our API-fetching components into one single page application (SPA), using dynamic routing. Make a new file called index.html
:
<html>
<head>
<title>Headless news</title>
<meta charset="UTF-8">
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<script src="https://unpkg.com/vue-router/dist/vue-router.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<h1>Headless News</h1>
<router-view></router-view>
</div>
<script>
const API_ROOT = 'http://127.0.0.1:8000/api/v2/pages/';
/* News listing component */
const NewsListing = {
template: `
<div>
<div v-for="item in news">
<router-link :to="/news/+ item.id">
<h2>{{ item.title }}</h2>
</router-link>
<p>{{ item.intro }} / {{ item.date }}</p>
</div>
</div>
`,
data: function () {
return { news: [] }
},
mounted () {
axios
.get(API_ROOT + '?type=news.NewsPage&fields=intro,body,date')
.then(response => (this.news = response.data.items))
},
}
/* News item component */
const NewsItem = {
template: `
<div>
<router-link to="/">Home</router-link>
<h1>{{ item.title }}</h1>
<p v-html="item.body"></p>
</div>
`,
data: function () {
return { item: {} }
},
methods: {
getNews() {
axios
.get(API_ROOT + this.$route.params.id + '/')
.then((response) => (this.item = response.data))
}
},
mounted () {
this.getNews();
},
watch: {
'$route' (to, from) {
this.getNews();
}
}
}
const routes = [
{ path: '/', component: NewsListing },
{ path: '/news/:id', component: NewsItem }
]
const router = new VueRouter({
routes
})
const app = new Vue({
router
}).$mount('#app')
</script>
</body>
</html>
Add Tachyons to your <head>
:
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/tachyons/css/tachyons.min.css">
then add some classes to your <body>
:
<body class="w-100 sans-serif cf ph3 ph5-ns pb5 bg-yellow black-70">
your <h1>
:
<h1 class="f-headline-ns f1 lh-solid mb2">Headless News</h1>
and your <router-link>
s:
<router-link class="black dim" :to="/news/+ item.id">
Add an image to your news model:
# in the imports
from wagtail.images.edit_handlers import ImageChooserPanel
# in your NewsPage class
image = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL
)
# in your content_panels for NewsPage
ImageChooserPanel('image'),
./manage.py makemigrations
and ./manage.py migrate
.
See the updated API: http://127.0.0.1:8000/api/v2/pages/4/. We'll need a custom serialiser to create images at sizes that work for our headless front-end. To your models.py
, add
from wagtail.images.api.fields import ImageRenditionField
and to api_fields
add
APIField('image_thumbnail', serializer=ImageRenditionField('fill-100x100', source='image')),
Refresh the API view to see the new image_thumbnail
field.
Now output the thumbnail in Vue's NewsItem
template:
<img v-if="item.image_thumbnail"
:src="'http://127.0.0.1:8000' + item.image_thumbnail.url"
:width="item.image_thumbnail.width"
:height="item.image_thumbnail.height">
Start by converting the body
field of your news model to a Streamfield:
# to your imports, add:
from wagtail.core.fields import StreamField
from wagtail.core import blocks
from wagtail.admin.edit_handlers import StreamFieldPanel
from wagtail.images.blocks import ImageChooserBlock
# convert your blog's body to a StreamField:
body = StreamField([
("heading", blocks.CharBlock(classname="full title", icon="title")),
("paragraph", blocks.RichTextBlock(icon="pilcrow")),
("image", ImageChooserBlock(icon="image")),
])
# and, in content_panels, convert body's FieldPanel into a StreamFieldPanel:
StreamFieldPanel('body')
Migrate your changes, then add some content to your new Streamfield body. Now, in frontend
, copy news-item.html
to news-item-streamfield.html
. For now, change the body
output from
<p v-html="item.body"></p>
to
<p>{{ item.body }}</p>
We can see that it's now outputting JSON, instead of HTML. Vue has some nice template features for looping over different sorts of values - try replacing <p>{{ item.body }}</p>
with
<span v-for="block in item.streamfield">
<div v-if="block.type == 'heading'">
<h2>{{ block.value }}</h2>
</div>
<div v-else-if="block.type == 'image'">
<h2>image: {{ block.value }}</h2>
</div>
<div v-else-if="block.type == 'paragraph'">
<p v-html="block.value"></p>
</div>
</span>
In a real world Vue application, we'd create components for each of these blocks, for better reuse across page types.
- https://cli.vuejs.org/
- Requires Node.js version 8.9 or above (8.11.0+ recommended)
npm install -g @vue/cli
vue create vue-workshop
- https://www.vuemastery.com/
- http://codepop.com/Vue-Essentials-Cheat-Sheet.pdf
- https://learnwagtail.com (search for 'headless')
@tomdyson Awesome I created a proof of concept in vuejs that reads from the api as well, glad to be working directly with you on this!