Skip to content

Instantly share code, notes, and snippets.

@tomdyson
Last active April 3, 2024 16:55
Show Gist options
  • Star 29 You must be signed in to star a gist
  • Fork 9 You must be signed in to fork a gist
  • Save tomdyson/abf1e973db4dcd50b388816f8c20adb0 to your computer and use it in GitHub Desktop.
Save tomdyson/abf1e973db4dcd50b388816f8c20adb0 to your computer and use it in GitHub Desktop.
Headless Wagtail with Vue.js

Headless Wagtail with Vue.js

A video version of this talk is now available at https://learnwagtail.com/tutorials/headless-wagtail-workshop-with-vue-js/

Install Wagtail

  1. python3 -m venv wagtailenv
  2. source wagtailenv/bin/activate
  3. pip install --upgrade pip
  4. pip install wagtail
  5. wagtail start backend
  6. cd backend
  7. ./manage.py migrate
  8. ./manage.py runserver
  9. ./manage.py createsuperuser

Add a news app to our Wagtail site

  1. ./manage.py startapp news
  2. add 'news' to INSTALLED_APPS in backend/settings/base.py
  3. 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.

Configure the API

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

Get started with Vue.js

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.

Fetching resources with Axios

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.

Fetch data from our Wagtail site

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.

Create our news detail page

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.

Routing

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>

Styling

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">

Images

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">

Bonus 1: Streamfield

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.

Bonus 2: Headless preview

Bonus 3: deploy the front end to Netlify

Bonus 4: Vue CLI

Resources

@jamesray
Copy link

jamesray commented Apr 1, 2020

@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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment