A video version of this talk is now available at https://learnwagtail.com/tutorials/headless-wagtail-workshop-with-vue-js/
python3 -m venv wagtailenvsource wagtailenv/bin/activatepip install --upgrade pippip install wagtailwagtail start backendcd backend./manage.py migrate./manage.py runserver./manage.py createsuperuser
./manage.py startapp news- add
'news'toINSTALLED_APPSinbackend/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_urls 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 ImageRenditionFieldand 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/clivue 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!