Build SPA With React and Wagtail
Build SPA With React and Wagtail
1 Introduction 1
1.1 Objectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.2 Who is this course for . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.3 What is included . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1.4 How to use the source code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.5 Demo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.6 What if you have problem or suggestions . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
1.7 Changelog . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
2 Setup project 4
2.1 Objectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.2 Create Django Project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.3 Import Wagtail . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
2.4 Run DevServer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.5 Reference . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
5 StreamField 22
5.1 Objectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5.2 What is StreamField . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5.3 Block . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5.4 Body . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
5.5 Dive Deep . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
i
6 Build REST API with Django REST Framework 27
6.1 Objectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
6.2 Django REST Framework . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
6.3 Install Django REST Framework . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
6.4 Serializer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
6.5 Serializer Field . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
6.6 Viewsets & Router . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 30
6.7 Filter . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
6.8 Pagination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
6.9 Conclusion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
ii
13.1 Objectives . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
13.2 Bootstrap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
13.3 Install Dependency . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 66
13.4 Simple Test . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
13.5 Test with Bootstrap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
iii
20 Unittest React Component (Part 1) 121
20.1 Objectvie . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
20.2 Jest . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
20.3 Testing Library . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
20.4 Test philosophy . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
20.5 Test TagWidget . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 122
20.6 Test Ajax . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
20.7 Snapshot Test . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 124
iv
26.7 CORS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
26.8 Media Domain . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161
26.9 Nginx . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 162
26.10Live View from Wagtail Admin . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 162
v
Chapter 1
Introduction
1.1 Objectives
This course will teach you how to build a SPA (single-page application) using React and Wagtail CMS.
By the end of this course, you will be able to:
1. Understand Docker and use Docker Compose to do development
2. Build a REST API for Wagtail CMS
3. Use the Django shell to test code and check data.
4. Test the REST API and generate test coverage report
5. Use the factory package to help create test data
6. Build a React app from create-react-app
7. Understand React Components and the component lifecycle
8. Understand React router
9. Use Storybook to develop React Components
10. Test React components and the frontend app
11. Make React app work with Wagtail preview
12. Deploy the production app to DigitalOcean
1
React Wagtail Tutorial, Release 1.0.0
You can use code below to run dev application on your local env.
You need Docker and Docker Compose and you can install it here Get Docker2
1.5 Demo
If you meet problem, please check FAQ first (you can find it at the end of the book)
If you want to talk with me, please send email to
[email protected]
1.7 Changelog
1.7.1 1.0.0
2 Chapter 1. Introduction
React Wagtail Tutorial, Release 1.0.0
1.7. Changelog 3
Chapter 2
Setup project
2.1 Objectives
django==3.1
2 directories, 1 file
4
React Wagtail Tutorial, Release 1.0.0
wagtail==2.10.2
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
"wagtail.contrib.forms",
"wagtail.contrib.redirects",
"wagtail.embeds",
"wagtail.sites",
"wagtail.users",
"wagtail.snippets",
"wagtail.documents",
"wagtail.images",
"wagtail.search",
"wagtail.admin",
"wagtail.core",
"modelcluster",
"taggit",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
urlpatterns = [
path('admin/', admin.site.urls),
path('cms-admin/', include(wagtailadmin_urls)),
path('documents/', include(wagtaildocs_urls)),
# For anything not caught by a more specific rule above, hand over to
# Wagtail's serving mechanism
re_path(r'', include(wagtail_urls)),
]
if settings.DEBUG:
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Now, all the config is done, let’s run the Wagtail app
# migrate db.sqlite3
(env)$ ./manage.py migrate
# runserver
(env)$ ./manage.py runserver
Watching for file changes with StatReloader
Performing system checks...
1. Now if you visit http://127.0.0.1:8000/ you will see Welcome to your new Wagtail site!.
2. The welcome page is created by Wagtail migration and you can check the source code here8
3. You will see db.sqlite3 is created at the project directory, here we did not specify Django to use
other db so default sqlite is used by default.
2.5 Reference
8 https://github.com/wagtail/wagtail/blob/v2.10.2/wagtail/core/migrations/0002_initial_data.py#L30
9 https://docs.wagtail.io/en/latest/getting_started/integrating_into_django.html
3.1 Objectives
Docker Compose is a tool for defining and running multi-container Docker applications.[47] It
uses YAML files to configure the application’s services and performs the creation and start-
up process of all the containers with a single command.
The docker-compose CLI utility allows users to run commands on multiple containers
at once, for example, building images, scaling containers, running containers that were
stopped, and more.
First, please download and install Docker Compose10 if you haven’t already done so.
$ docker --version
Docker version 18.09.2, build 6247962
$ docker-compose --version
docker-compose version 1.23.2, build 1110ad01
Let’s start with our config file structure, this can help you better understand the whole workflow:
├── compose
│ └── local
│ └── django
│ ├── Dockerfile
│ ├── entrypoint
│ └── start
├── docker-compose.yml
├── manage.py
10 https://docs.docker.com/compose/install/#install-compose
7
React Wagtail Tutorial, Release 1.0.0
├── react_wagtail_app
└── requirements.txt
You will see we have config files docker-compose.yml and some files in compose directory, you do not
need to create them for now. I will talk about them with more details in the coming sections.
Note: The config file structure come from cookiecutter-django11 , which is a great project for people who
want to learn Django.
version: '3.7'
services:
web:
build:
context: .
dockerfile: ./compose/local/django/Dockerfile
image: react_wagtail_app_web
command: /start
volumes:
- .:/app
ports:
- 8000:8000
env_file:
- ./.env/.dev-sample
depends_on:
- db
db:
image: postgres:12.0-alpine
volumes:
11 https://github.com/pydanny/cookiecutter-django
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_DB=react_wagtail_dev
- POSTGRES_USER=react_wagtail
- POSTGRES_PASSWORD=react_wagtail
volumes:
postgres_data:
Notes:
1. Here we defined two services, one is web (django devserver), the other one is db
2. We create a named docker volume postgres_data, and use it to store the db data, so even db con-
tainer is deleted, the db data can still exist.
DEBUG=1
SECRET_KEY='randome_key'
DJANGO_ALLOWED_HOSTS=*
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=react_wagtail_dev
SQL_USER=react_wagtail
SQL_PASSWORD=react_wagtail
SQL_HOST=db
SQL_PORT=5432
Please make sure .env is not excluded in the .gitignore, so it can be added to Git repo
Please note that the db login credential should match environment variables of db service in
docker-compose.yml
Next, let’s update DATABASES, SECRET_KEY, DEBUG, and ALLOWED_HOSTS react_wagtail_app/settings.py to
read env variables.
import os
DATABASES = {
"default": {
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
"NAME": os.environ.get("SQL_DATABASE", os.path.join(BASE_DIR, "db.sqlite3")),
"USER": os.environ.get("SQL_USER", "user"),
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
"HOST": os.environ.get("SQL_HOST", "localhost"),
"PORT": os.environ.get("SQL_PORT", "5432"),
}
}
3.5.1 Dockerfile
In Docker Compose, we can let it create docker container from existing docker image or custom docker
image.
To build custom docker image, we need to provide Dockerfile
Please create directory and file like this
├── compose
│ └── local
│ └── django
│ ├── Dockerfile
Edit compose/local/django/Dockerfile
FROM python:3.8-slim-buster
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
WORKDIR /app
ENTRYPOINT ["/entrypoint"]
Notes:
1. PYTHONDONTWRITEBYTECODE=1 tell Python to not write bytecode (.pyc) and __pycache__ directory on
local env.
2. RUN sed -i 's/\r$//g' /entrypoint is used to process the line endings of the shell scripts, which
converts Windows line endings to UNIX line endings.
3. In the above docker-compose.yml, we config docker volumn .:/app, so here we set WORKDIR /app.
If we edit code on host machine, then the code change can also been seen in /app of the docker
container.
Next, let’s check the entrypoint and start script.
3.6 Entrypoint
In docker-compose.yml, we can use depends_on to let web service run after db service. However, it can
not guarantee web service start after db service is trully ready. (Github Issue12 )
So we can add script in entrypoint to solve this problem.
compose/local/django/entrypoint
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
postgres_ready() {
python << END
import sys
import psycopg2
try:
psycopg2.connect(
dbname="${SQL_DATABASE}",
user="${SQL_USER}",
password="${SQL_PASSWORD}",
host="${SQL_HOST}",
port="${SQL_PORT}",
)
except psycopg2.OperationalError:
sys.exit(-1)
sys.exit(0)
END
}
until postgres_ready; do
>&2 echo 'Waiting for PostgreSQL to become available...'
sleep 1
done
>&2 echo 'PostgreSQL is available'
exec "$@"
1. We defined a postgres_ready function which is called in loop. The loop would only stop if the db
service is able to connect.
2. The last exec "$@" is used to make the entrypoint a pass through to ensure that Docker container
runs the command the user passes in (command: /start, in our case). For more, check this Stack
Overflow answer13 .
#!/bin/bash
12 https://github.com/docker-library/postgres/issues/146
13 https://stackoverflow.com/a/39082923/2371995
3.6. Entrypoint 11
React Wagtail Tutorial, Release 1.0.0
set -o errexit
set -o pipefail
set -o nounset
django==3.1
wagtail==2.10.2
psycopg2-binary
$ docker-compose build
Once the images are build, start the application in detached mode:
$ docker-compose up -d
This will start containers based on the order defined in the depends_on option. (db first, web second)
1. Once the containers are up, the entrypoint scripts will execute.
2. Once Postgres is up, the respective start scripts will execute. The Django migrations will be applied
and the development server will run. The Django app should then be available.
You can check the docker compose application with this command.
$ docker-compose ps
Name Command State Ports
--------------------------------------------------------------------------
app_db_1 docker-entrypoint.sh postgres Up 5432/tcp
app_web_1 /entrypoint /start Up 0.0.0.0:8000->8000/tcp
4.1 Objectives
Before we start, let’s take a look at the page structures, which can help better understand the next sec-
tions.
There would be two page type in our project, BlogPage and PostPage
BlogPage would be the index page of the PostPage
So the page structures would seem like this.
BlogPage
PostPage1
PostPage2
PostPage3
PostPage4
Now you can see Django app blog created at the root directory.
.
├── blog
├── compose
├── docker-compose.yml
├── manage.py
14
React Wagtail Tutorial, Release 1.0.0
├── react_wagtail_app
└── requirements.txt
INSTALLED_APPS = [
# code omitted for brevity
"blog",
]
Next, let’s start adding blog models, there are mainly two types of models we need to add here.
1. Page models (BlogPage, PostPage)
2. Other models (Category, Tag)
blog/models.py
class BlogPage(Page):
description = models.CharField(max_length=255, blank=True,)
class PostPage(Page):
header_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
ImageChooserPanel("header_image"),
]
1. When you create page models, please make sure all page classes inherit from the Wagtail Page
class.
2. Here we add a description field to the BlogPage and a header_image field to the PostPage.
3. We should also add edit handlers to the content_panels so we can edit the fields in Wagtail admin.
To make the blog supports Category and Tag features, let’s add some models.
blog/models.py
@register_snippet
class BlogCategory(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True, max_length=80)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
]
def __str__(self):
return self.name
class Meta:
verbose_name = "Category"
verbose_name_plural = "Categories"
@register_snippet
class Tag(TaggitTag):
class Meta:
proxy = True
1. Here we created two models, both of them inherit from the models.Model, which are standard
Django models.
2. register_snippet decorator would register them as Wagtail snippets, that can make us
add/edit/delete the model instances in snippets of Wagtail admin.
3. Since Wagtail already has tag support built on django-taggit, so here we create a proxy-model14
to declare it as wagtail snippet
Now page models and snippet models are created. But we still need to create Intermediary models so
the connections between page and snippet can be stored in the db.
Note: I do not recommend use ParentalManyToManyField in Wagtail app even it seems more easy to
understand. You can check this Wagtail tip15 for more details.
class PostPageBlogCategory(models.Model):
page = ParentalKey(
"blog.PostPage", on_delete=models.CASCADE, related_name="categories"
)
blog_category = models.ForeignKey(
"blog.BlogCategory", on_delete=models.CASCADE, related_name="post_pages"
)
panels = [
SnippetChooserPanel("blog_category"),
14 https://docs.djangoproject.com/en/3.1/topics/db/models/#proxy-models
15 https://www.accordbox.com/blog/wagtail-tip-1-how-replace-parentalmanytomanyfield-inlinepanel/
class Meta:
unique_together = ("page", "blog_category")
class PostPageTag(TaggedItemBase):
content_object = ParentalKey("PostPage", related_name="post_tags")
class PostPage(Page):
header_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
ImageChooserPanel("header_image"),
InlinePanel("categories", label="category"),
FieldPanel("tags"),
]
1. We add ClusterTaggableManager and use through to specify the intermediary model we just cre-
ated.
2. And then add InlinePanel("categories", label="category") to the content_panels. The
categories relationship is already defined in PostPageBlogCategory.page.related_name
3. The PostPageBlogCategory.panels defines the behavior in InlinePanel, which means we can set
multiple blog_category when we create or edit page.
16 https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together
FieldPanel,
FieldRowPanel,
InlinePanel,
MultiFieldPanel,
PageChooserPanel,
StreamFieldPanel,
)
from wagtail.core.models import Page
from wagtail.images.edit_handlers import ImageChooserPanel
from wagtail.snippets.edit_handlers import SnippetChooserPanel
from wagtail.snippets.models import register_snippet
class BlogPage(Page):
description = models.CharField(max_length=255, blank=True,)
class PostPage(Page):
header_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
ImageChooserPanel("header_image"),
InlinePanel("categories", label="category"),
FieldPanel("tags"),
]
class PostPageBlogCategory(models.Model):
page = ParentalKey(
"blog.PostPage", on_delete=models.CASCADE, related_name="categories"
)
blog_category = models.ForeignKey(
"blog.BlogCategory", on_delete=models.CASCADE, related_name="post_pages"
)
panels = [
SnippetChooserPanel("blog_category"),
]
class Meta:
unique_together = ("page", "blog_category")
@register_snippet
class BlogCategory(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True, max_length=80)
panels = [
FieldPanel("name"),
FieldPanel("slug"),
]
def __str__(self):
return self.name
class Meta:
verbose_name = "Category"
verbose_name_plural = "Categories"
class PostPageTag(TaggedItemBase):
content_object = ParentalKey("PostPage", related_name="post_tags")
@register_snippet
class Tag(TaggitTag):
class Meta:
proxy = True
4.8 Migrate DB
After we finish the models part, let’s migrate our db so relevant tables would be created or migrated.
$ docker-compose up -d
1. Login on http://127.0.0.1:8000/cms-admin/
2. Go to http://127.0.0.1:8000/cms-admin/pages/ to create BlogPage beside the Welcome to your
new Wagtail site! page
3. Follow settings/site in the sidebar to change the root page of the localhost site to the BlogPage
we just cretaed.
4. Go to http://127.0.0.1:8000/cms-admin/pages/ delete the Welcome to your new Wagtail site!
page
5. Now if you visists http://127.0.0.1:8000/ you will see TemplateDoesNotExist exception. This is
correct and we will fix it later, do not worry.
4.8. Migrate DB 19
React Wagtail Tutorial, Release 1.0.0
Now you are in Django shell, and you can run some Python code to quickly check the data and code.
This is very useful during the development.
4.12 ParentalKey
Many pepole have not much exprieence on Django when they reach Wagtail. So here I’d like to talk about
a little more about the ParentalKey and the difference between with ForeignKey
Let’s assume you are building a CMS framework whch support preview, and now you have a live post
page which has category category 1
So in the table, the data would seem like this.
Some editor wants to change the page category to category 2, and he even wants to preview it before
publishing it. So what is your plan?
1. You need to create something like PostPageCategory (blog_category=2, page=1) in memory and
not write it to PostPageCategory table. (Because if you do, it will affect the live page)
2. You need to write code to convert page page data, and the above PostPageCategory to some seri-
alize format (JSON for example), and save it to some revision table as the latest revision.
3. On the preview page, fetch the data from the revision table and deserialize to a normal page
object, and then render it to HTML.
Django’s ForeignKey can not work in this case, because it needs PostPageCategory (blog_category=2,
page=1) to save to db first, so it has pk
That is why django-modelcluster17 is created and ParentalKey is introduced.
We can solve the above problem in this way.
1. Make the PostPage inherit from modelcluster.models.ClusterableModel. Actually, Wagtail Page
class already did this18
2. And define the PostPageCategory.page as ParentalKey field.
3. So the page (ClusterableModel) can hold the PostPageCategory in memory even the data is not
created in db.
4. We can then serialize the page to JSON format and save to revision table.
5. Now editor can preview the page before publishing it.
4.12.1 Tip
17 https://github.com/wagtail/django-modelcluster
18 https://github.com/wagtail/wagtail/blob/v2.11.2/wagtail/core/models.py#L721
4.12. ParentalKey 21
Chapter 5
StreamField
5.1 Objectives
5.3 Block
From my understanding, I’d like to group the Wagtail built-in blocks in this way.
1. Basic block, which is similar with Django model field types19 For example, CharBlock, TextBlock,
ChoiceBlock
2. Chooser Block, which is for object selection. For example, PageChooserBlock,
ImageChooserBlock.
3. StructBlock, which works like dict (Object in js), which contains fixed sub-blocks.
4. StreamBlock, ListBlock, which works like list (Arrays in js), which contains no-fixed sub-blocks.
5.4 Body
22
React Wagtail Tutorial, Release 1.0.0
blog/blocks.py
class CustomImageChooserBlock(ImageChooserBlock):
pass
class ImageText(StructBlock):
reverse = BooleanBlock(required=False)
text = RichTextBlock()
image = CustomImageChooserBlock()
class BodyBlock(StreamBlock):
h1 = CharBlock()
h2 = CharBlock()
paragraph = RichTextBlock()
image_text = ImageText()
image_carousel = ListBlock(CustomImageChooserBlock())
thumbnail_gallery = ListBlock(CustomImageChooserBlock())
class PostPage(Page):
header_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
ImageChooserPanel("header_image"),
InlinePanel("categories", label="category"),
FieldPanel("tags"),
StreamFieldPanel("body"),
]
5.4. Body 23
React Wagtail Tutorial, Release 1.0.0
24 Chapter 5. StreamField
React Wagtail Tutorial, Release 1.0.0
Let’s run some code to learn more about StreamField and Django shell.
>>> page.body.stream_data
[{'type': 'h1', 'value': 'The Zen of Wagtail', 'id': '0dd3e943-4cbc-4c13-94ce-423c91ab9800'}, {'type
,→': 'paragraph', 'value': '<p>Wagtail has been born out of many years of experience building�
,→websites, learning approaches that work and ones that don’t, and striking a balance between power�
,→and simplicity, structure and flexibility. We hope you’ll find that Wagtail is in that sweet spot.
,→'text': '<p>Wagtail is not an instant website in a box.</p><p>You can’t make a beautiful website�
,→by plugging off-the-shelf modules together - expect to write code.</p>', 'image': 3}, 'id':
,→': '<p>A CMS should get information out of an editor’s head and into a database, as efficiently�
I also recommend you to run some code on your local env and check the data. This can help better
understand the data structures of SteramField.
26 Chapter 5. StreamField
Chapter 6
6.1 Objectives
Django REST Framework20 (DRF) is a powerful and flexible tool for building Web APIs for Django project.
There are some basic concepts in DRF
1. routers21 , similar with Django urls.
2. viewsets22 , similar with Django view, which handle request and return response.
3. serializer23 , convert Django model instances to JSON, XML or vice versa.
requirements.txt
django==3.1
wagtail==2.10.2
psycopg2-binary
djangorestframework
Note: djangorestframework is also dependency package of Wagtail CMS, but is is good manner to add
it here.
Update INSTALLED_APPS of eact_wagtail_app/settings.py
20 https://www.django-rest-framework.org/
21 https://www.django-rest-framework.org/api-guide/routers/
22 https://www.django-rest-framework.org/api-guide/viewsets/
23 https://www.django-rest-framework.org/api-guide/serializers/
27
React Wagtail Tutorial, Release 1.0.0
INSTALLED_APPS = [
# code omitted for brevity
"rest_framework",
"blog",
]
6.4 Serializer
The serializer would serialize the Django model instances to target format.
blog/serializers.py
from rest_framework import serializers
class PostPageSerializer(serializers.ModelSerializer):
class Meta:
model = PostPage
fields = (
"id",
"slug",
"title",
)
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = BlogCategory
fields = (
"id",
"slug",
"name",
)
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = (
"id",
"slug",
"name",
)
As you can see, we can use serializers to control the serialization behavior
If we use the above PostPageSerializer get JSON string of the PostPage, we would get id, slug and
title.
>>> from blog.models import PostPage
# number 4 is the post page primary key we just created
>>> instance = PostPage.objects.get(pk=4)
So now we see a problem, we need to tell serializers how to process the data with other Django
model.
Create blog/fields.py
from rest_framework.fields import Field
class TagField(Field):
def to_representation(self, tags):
try:
return [
{"name": tag.name, "slug": tag.slug, "id": tag.id} for tag in tags.all()
]
except Exception:
return []
Update blog/serializers.py
class PostPageSerializer(serializers.ModelSerializer):
api_tags = TagField(source="tags")
class Meta:
model = PostPage
fields = (
"id",
"slug",
"title",
"api_tags",
)
1. We defined a custom TagField and overwrite to_representation method to define how the data
is represented.
2. In PostPageSerializer, we declared api_tags with TagField and add it to Meta.fields
Let’s open a new Django shell and check.
{'id': 4, 'slug': 'postpage1', 'title': 'PostPage1', 'api_tags': [{'name': 'Django', 'slug': 'django
,→', 'id': 1}]}
class PostPageSet(viewsets.ModelViewSet):
serializer_class = PostPageSerializer
queryset = PostPage.objects.all()
http_method_names = ["get"]
class CategorySet(viewsets.ModelViewSet):
queryset = BlogCategory.objects.all()
serializer_class = CategorySerializer
http_method_names = ["get"]
class TagSet(viewsets.ModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
http_method_names = ["get"]
Create blog/api.py
from rest_framework import routers
# Below is custom router which has some advanced feature not implemented by Wagtail
blog_router = routers.DefaultRouter()
blog_router.register(r"posts", PostPageSet)
blog_router.register(r"categories", CategorySet)
blog_router.register(r"tags", TagSet)
Update react_wagtail_app/urls.py
from blog.api import blog_router
urlpatterns = [
path('admin/', admin.site.urls),
path('cms-admin/', include(wagtailadmin_urls)),
path('documents/', include(wagtaildocs_urls)),
path('api/blog/', include(blog_router.urls)),
# For anything not caught by a more specific rule above, hand over to
# Wagtail's serving mechanism
re_path(r'', include(wagtail_urls)),
]
Notes
1. blog_router is working on prefix api/blog
2. blog_router registered three viewsets (posts, categories, tags)
The three API URL would look like this, you can check the URL in your browser.
1. http://127.0.0.1:8000/api/blog/posts/
2. http://127.0.0.1:8000/api/blog/categories/
3. http://127.0.0.1:8000/api/blog/tags/
Or you can test in Django shell
# please run code in new Django shell if you change something
$ docker-compose run --rm web python manage.py shell
>>> requests.get('http://web:8000/api/blog/categories/').json()
[{'id': 1, 'slug': 'programming', 'name': 'Programming'}]
Note: because the django devserver is running on web container, so here the hostname is web:8000
instead of 127.0.0.1:8000
6.7 Filter
Next, I would add the filter function to the PostPageSet, so we can filter blog posts by category and tag
class PostPageSet(viewsets.ModelViewSet):
serializer_class = PostPageSerializer
queryset = PostPage.objects.all()
http_method_names = ["get"]
def get_queryset(self):
queryset = PostPage.objects.all()
category = self.request.query_params.get("category", None)
tag = self.request.query_params.get("tag", None)
if category is not None and category != "*":
queryset = queryset.filter(categories__blog_category__slug=category)
if tag is not None and tag != "*":
queryset = queryset.filter(tags__slug=tag)
return queryset
6.8 Pagination
To make our REST API support pagination by default, let’s update react_wagtail_app/settings.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 20,
}
Notes:
24 https://docs.djangoproject.com/en/3.1/topics/db/queries/#lookups-that-span-relationships
6.9 Conclusion
6.9. Conclusion 33
Chapter 7
7.1 Objectives
7.2 Config
As I said, Wagtail’s has already supported REST API based on Django REST Framework25 (DRF)
Add wagtail.api.v2 to INSTALLED_APPS of the react_wagtail_app/settings.py
INSTALLED_APPS = [
# code omitted for brevity
"wagtail.api.v2",
"rest_framework",
"blog",
]
cms_api_router = WagtailAPIRouter("wagtailapi")
25 https://www.django-rest-framework.org/
34
React Wagtail Tutorial, Release 1.0.0
cms_api_router.register_endpoint("documents", DocumentsAPIViewSet)
# Below is custom router which has some advanced feature not implemented by Wagtail
blog_router = routers.DefaultRouter()
blog_router.register(r"posts", PostPageSet)
blog_router.register(r"categories", CategorySet)
blog_router.register(r"tags", TagSet)
urlpatterns = [
path('admin/', admin.site.urls),
path('cms-admin/', include(wagtailadmin_urls)),
path('documents/', include(wagtaildocs_urls)),
path('api/blog/', include(blog_router.urls)),
path('api/cms/', cms_api_router.urls),
# For anything not caught by a more specific rule above, hand over to
# Wagtail's serving mechanism
re_path(r'', include(wagtail_urls)),
]
{'items': [{'id': 3,
'meta': {'detail_url': 'http://localhost/api/cms/pages/3/',
'first_published_at': '2020-10-12T03:11:37.418006Z',
'html_url': 'http://localhost/',
'slug': 'blogpage',
'type': 'blog.BlogPage'},
'title': 'BlogPage'},
{'id': 4,
'meta': {'detail_url': 'http://localhost/api/cms/pages/4/',
'first_published_at': '2020-10-12T03:40:14.162646Z',
'html_url': 'http://localhost/postpage1/',
'slug': 'postpage1',
'type': 'blog.PostPage'},
'title': 'PostPage1'}],
'meta': {'total_count': 2}}
Note: the detail_url has hostname localhost because it is generated from Wagtail Site, you can
change the port number to 8000 in Wagtail admin so the url would be correct. (we can ignore this problem
if we do not follow the url)
1. By default, /api/cms/pages/ would return all wagtail.core.models.Page, that is why you can see
both blog.BlogPage and blog.PostPage here.
2. Because core logic of /api/cms/pages/ is from Wagtail, it is not easy to customize (for example,
filter posts by tag)
3. To solve the above problem, we need some other routers and viewsets. (That is why we built
blog_router)
Let’s keep checking the PostPage content in the Django shell we just opened.
>>> pprint.pprint(requests.get('http://web:8000/api/cms/pages/4/').json())
{'id': 4,
'meta': {'detail_url': 'http://localhost/api/cms/pages/4/',
'first_published_at': '2020-10-12T03:40:14.162646Z',
'html_url': 'http://localhost/postpage1/',
'parent': {'id': 3,
'meta': {'detail_url': 'http://localhost/api/cms/pages/3/',
'html_url': 'http://localhost/',
'type': 'blog.BlogPage'},
'title': 'BlogPage'},
'search_description': '',
'seo_title': '',
'show_in_menus': False,
'slug': 'postpage1',
'type': 'blog.PostPage'},
'title': 'PostPage1'}
Even the page model has header_image, tags, categories and body, we can not see them from REST API.
We can solve this problem by setting api_fields
Update blog/models.py
class PostPage(Page):
header_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
ImageChooserPanel("header_image"),
InlinePanel("categories", label="category"),
FieldPanel("tags"),
StreamFieldPanel("body"),
]
api_fields = (
"header_image",
"body",
APIField("owner"),
APIField("api_tags", serializer=TagField(source="tags")),
APIField(
"pub_date",
serializer=DateTimeField(format="%d %B %Y", source="last_published_at"),
),
)
Notes:
1. We add some custom fields to the api_fields
2. We use wagtail.api.APIField and custom TagField together to return tag info of the PostPage
3. Code in api_fields is very similar with code in blog/serializers.py, you can check and com-
poare.
Let’s check it again.
'reverse': True,
'text': '<p>A CMS should get information out of an '
'editor’s head and into a database, as '
'efficiently and directly as possible.</p>'}}],
'header_image': {'id': 1,
'meta': {'detail_url': 'http://localhost/api/cms/images/1/',
'download_url': '/media/original_images/photo-1506765515384-028b60a970df.
,→jpeg',
'type': 'wagtailimages.Image'},
'title': 'photo-1506765515384-028b60a970df.jpeg'},
'id': 4,
'meta': {'detail_url': 'http://localhost/api/cms/pages/4/',
'first_published_at': '2020-10-12T03:40:14.162646Z',
'html_url': 'http://localhost/postpage1/',
'parent': {'id': 3,
'meta': {'detail_url': 'http://localhost/api/cms/pages/3/',
'html_url': 'http://localhost/',
'type': 'blog.BlogPage'},
'title': 'BlogPage'},
'search_description': '',
'seo_title': '',
'show_in_menus': False,
'slug': 'postpage1',
'type': 'blog.PostPage'},
'owner': {'id': 1, 'meta': {'type': 'auth.User'}},
'pub_date': '12 October 2020',
'title': 'PostPage1'}
1. Now body, api_tags and header_image can be seen in the REST API response.
If you check header_image in the above JSON response, you will find the image download_url contains
the original image url.
What if you want to conrol the image size?
The answer is serializer filed!
The good news is Wagtail has already built this.
Update blog/models.py
class PostPage(Page):
header_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
content_panels = Page.content_panels + [
ImageChooserPanel("header_image"),
InlinePanel("categories", label="category"),
FieldPanel("tags"),
StreamFieldPanel("body"),
]
api_fields = (
APIField(
"header_image_url",
serializer=ImageRenditionField("max-1000x800", source="header_image"),
),
"body",
APIField("owner"),
APIField("api_tags", serializer=TagField(source="tags")),
APIField(
"pub_date",
serializer=DateTimeField(format="%d %B %Y", source="last_published_at"),
),
)
If you check the above JSON response carefully, you will see the image field in the StreamField contains
image pk value instead of image url and other info.
Let’s fix it in this section.
In Wagtail StreamField block, there is a method get_api_representation, we can use it to control block
behavior when generating api response.
Update blog/blocks.py
class CustomImageChooserBlock(ImageChooserBlock):
def __init__(self, *args, **kwargs):
self.rendition = kwargs.pop("rendition", "original")
super().__init__(**kwargs)
class ImageText(StructBlock):
reverse = BooleanBlock(required=False)
text = RichTextBlock()
image = CustomImageChooserBlock(rendition="width-800")
1. CustomImageChooserBlock now accepet parameter rendition which has default value original
# migrate db
$ docker-compose run --rm web python manage.py makemigrations
$ docker-compose run --rm web python manage.py migrate
7.7 Category
Now let’s add category to the REST API, it is easy since we already make tag work.
Update blog/fields.py
class CategoryField(Field):
def to_representation(self, categories):
try:
return [
{
"name": category.blog_category.name,
"slug": category.blog_category.slug,
"id": category.blog_category.id,
}
for category in categories.all()
]
except Exception:
return []
Update blog/models.py
class PostPage(Page):
api_fields = (
# other fields
APIField("api_categories", serializer=CategoryField(source="categories")),
)
7.7. Category 41
Chapter 8
8.1 Objectives
8.2 Workflow
8.3 Fixture
When you write unittest, you need some test data such as Site, Page in this project.
The Django doc has talked about using fixture JSON file and load it during unittest (TestCase.fixtures),
below links can help
1. Django doc26
2. create test fixtures for Wagtail27
The fixture solution is easy to understand and get started, newbie developer can dump data from the
DB to JSON file, then load it in unittest and use code to test.
However, there are some drawbacks:
1. The fixture file is hard to edit and maintain over time.
26 https://docs.djangoproject.com/en/3.1/howto/initial-data/#providing-data-with-fixtures
27 https://www.accordbox.com/blog/how-export-restore-wagtail-site/
42
React Wagtail Tutorial, Release 1.0.0
2. The test rely on the fixture file and the logic of the test seems not that straightforward. (You might
need to check the fixutre file to figure out the logic)
3. The fixture file is slow to load.
If you write many factory like the above create_tag, you will see a lot of objects.get_or_create, and
you might wonder if there is a way to improve this.
Python has a great community and you do not need to re-invent the wheel.
wagtail_factories and factory-boy can help you!
Let’s update app/requirements.txt to add them.
factory-boy==2.12.0
wagtail-factories==2.0.0
class TagFactory(DjangoModelFactory):
class Meta:
model = Tag
name = FuzzyText(length=6)
slug = LazyAttribute(lambda o: slugify(o.name))
# cleanup
>>> obj.delete()
# cleanup
>>> obj.delete()
As you can see, after we define the TagFactory, we can use it quickly create test data for Tag models
without caring the about the implementation details.
wagtail-factories provice similar functions for Wagtail built-in models and it is also built on
factory-boy
class BlogPageFactory(PageFactory):
class Meta:
model = BlogPage
class PostPageFactory(PageFactory):
class Meta:
model = PostPage
class PostPageBlogCategoryFactory(DjangoModelFactory):
class Meta:
model = PostPageBlogCategory
class BlogCategoryFactory(DjangoModelFactory):
class Meta:
model = BlogCategory
name = FuzzyText(length=6)
slug = LazyAttribute(lambda o: slugify(o.name))
class PostPageTagFactory(DjangoModelFactory):
class Meta:
model = PostPageTag
class TagFactory(DjangoModelFactory):
class Meta:
model = Tag
name = FuzzyText(length=6)
slug = LazyAttribute(lambda o: slugify(o.name))
1. For Wagtail page, the factory class inherit the PageFactory from wagtail_factories
2. For normal Django model, the factory class inherit the DjangoModelFactory from factory-boy
3. title = Sequence(lambda n: "PostPage %d" % n) would make the page has title like PostPage 1,
PostPage 2, and so on.
4. We can also pass parameters to create method to set the value directly.
# please run code in new Django shell if you change something
$ docker-compose run --rm web python manage.py shell
Note: You can use the factory function to generate data on your dev server, but please remember to
delete them soon to avoid some weird issues (for example NoReverseMatch at /cms-admin/ in Wagtail
admin).
BlogPageFactory,
PostPageTagFactory,
PostPageFactory,
TagFactory,
)
class TestView(TestCase):
"""
Test blog.views
"""
def setUp(self):
self.blog_page = BlogPageFactory.create()
self.site = Site.objects.all().first()
self.site.root_page = self.blog_page
self.site.save()
def test_category_view(self):
# arrange
category_1 = BlogCategoryFactory.create()
# act
response = self.client.get("/api/blog/categories/")
response_data = response.json()
# assert
assert response_data["results"][0]["name"] == category_1.name
assert response_data["results"][0]["slug"] == category_1.slug
# arrange
BlogCategoryFactory.create()
# act
response = self.client.get("/api/blog/categories/")
response_data = response.json()
# assert
assert len(response_data["results"]) == 2
...
test_category_view (blog.tests.TestView) ... ok
----------------------------------------------------------------------
Ran 1 test in 0.028s
OK
Note: In some cases, if you want to debug why the unittest fail, you can append option --pdb to your
command like this docker-compose run --rm web python manage.py test --noinput -v 2 --pdb
class TestView(TestCase):
"""
Test blog.views
"""
def test_tag_view(self):
tag_1 = TagFactory.create()
response = self.client.get("/api/blog/tags/")
response_data = response.json()
TagFactory.create()
response = self.client.get("/api/blog/tags/")
response_data = response.json()
assert len(response_data["results"]) == 2
def test_post_page_view(self):
post_page = PostPageFactory.create(parent=self.blog_page,)
category_1 = BlogCategoryFactory.create()
PostPageBlogCategoryFactory.create(
page=post_page, blog_category=category_1,
)
tag_1 = TagFactory.create()
PostPageTagFactory.create(
content_object=post_page, tag=tag_1,
)
response = self.client.get(
f"/api/blog/posts/?category={category_1.slug}&tag=*"
)
response_data = response.json()
assert response_data["results"][0]["id"] == post_page.pk
response = self.client.get(
f"/api/blog/posts/?category=*&tag={tag_1.slug}"
)
response_data = response.json()
assert response_data["results"][0]["id"] == post_page.pk
response = self.client.get("/api/blog/posts/")
response_data = response.json()
assert response_data["results"][0]["id"] == post_page.pk
# empty list
tag_2 = TagFactory.create()
response = self.client.get(
f"/api/blog/posts/?category=*&tag={tag_2.slug}"
)
response_data = response.json()
assert response_data["count"] == 0
category_2 = BlogCategoryFactory.create()
response = self.client.get(
f"/api/blog/posts/?category={category_2.slug}&tag=*"
)
response_data = response.json()
assert response_data["count"] == 0
----------------------------------------------------------------------
Ran 3 tests in 0.099s
9.1 Objectives
As I said in the previous chapter, wagtail-factories provides factory functions for Wagtail built-in mod-
els.
It has ImageFactory which can help us to generate Wagtail images quickly.
# If you check `image` in Wagtail admin, you will see an image that has pure color.
# remember to delete it after check
>>> img.delete()
People alwasy complain it is not easy to create test data for the StreamField.
Here I’d like to show you a simple way to solve the problem.
Let’s first get the Python representation of the sample StreamField data structure.
49
React Wagtail Tutorial, Release 1.0.0
If you check value of image_text, you will see 'image': 3, here 3 is the pk of the Wagtail image
We can copy the above Python code to our unittest and then modify the value of the image pk generated
by the ImageFactory
After that, we use json.dumps to convert it from Python list to JSON format, set it to body when creating
the page.
post_page = PostPageFactory.create(
parent=self.blog_page, body=json.dumps(body_data)
)
Then the StreamField test data problem is resolved, and you will see full code in the next section.
BlogPageFactory,
PostPageTagFactory,
PostPageFactory,
TagFactory,
)
class TestPostPageAPI(TestCase):
def setUp(self):
self.blog_page = BlogPageFactory.create()
self.site = Site.objects.all().first()
self.site.root_page = self.blog_page
self.site.save()
def test_post_page(self):
img_1 = ImageFactory(file=factory.django.ImageField(width=1000, height=1000))
img_2 = ImageFactory(file=factory.django.ImageField(width=1000, height=1000))
img_3 = ImageFactory(file=factory.django.ImageField(width=1000, height=1000))
body_data = [
{
'type': 'h1',
'value': 'The Zen of Wagtail'
},
{
'type': 'paragraph',
'value': '<p>Wagtail has been born out of many years of experience building '
'websites, learning approaches that work and ones that don’t, and '
'striking a balance between power and simplicity, structure and '
'flexibility. We hope you’ll find that Wagtail is in that sweet '
'spot.</p>'
},
{
'type': 'image_carousel',
'value': [img_1.pk, img_2.pk]
},
{
'type': 'image_text',
'value': {
'image': img_3.pk,
'reverse': False,
'text': '<p>Wagtail is not an instant website in a box.</p><p>You '
'can’t make a beautiful website by plugging off-the-shelf '
'modules together - expect to write code.</p>'
}
},
{
'type': 'image_text',
'value': {
'image': img_2.pk,
'reverse': True,
'text': '<p>A CMS should get information out of an editor’s head '
'and into a database, as efficiently and directly as '
'possible.</p>'
}
}
]
post_page = PostPageFactory.create(
parent=self.blog_page, body=json.dumps(body_data), header_image=img_3
)
response = self.client.get(f"/api/cms/pages/{post_page.pk}/")
response_data = response.json()
# check get_api_representation
assert response_data["body"][3]["type"] == "image_text"
assert response_data["body"][3]['value']['image']['width'] == 800
Note:
1. In setUp method, we changed root page of the default site to self.blog_page. Because Wagtai
would filter pages based on the current site28
2. In unittest, we created 3 images using ImageFactory, they all have width=1000
3. body_data copied from the above Django shell, we replced the image pk with the image from the
ImageFactory.
4. All id in StreamField are removed to keep the code clean. (It can still work)
5. Based on my experience, this solution is much more flexible and easy to maintain compared with
editing fixture file.
6. Some people might ask why I do not use wagtail-factories to do this, because the package does
not work well when dealing with some complex and nested data structure.
Let’s run test
----------------------------------------------------------------------
Ran 4 tests in 0.628s
OK
class TestPostPageAPI(TestCase):
def test_post_page_category(self):
post_page = PostPageFactory.create(parent=self.blog_page,)
category_1 = BlogCategoryFactory.create()
PostPageBlogCategoryFactory.create(
page=post_page, blog_category=category_1,
)
response = self.client.get(f"/api/cms/pages/{post_page.pk}/")
response_data = response.json()
28 https://github.com/wagtail/wagtail/blob/c9e740324c1a2197454274f5d18514b9a0752374/wagtail/api/v2/endpoints.py#
L427-L432
category_2 = BlogCategoryFactory.create()
PostPageBlogCategoryFactory.create(
page=post_page, blog_category=category_2,
)
response = self.client.get(f"/api/cms/pages/{post_page.pk}/")
response_data = response.json()
assert len(response_data["api_categories"]) == 2
def test_post_page_tag(self):
post_page = PostPageFactory.create(parent=self.blog_page,)
tag_1 = TagFactory.create()
PostPageTagFactory.create(
content_object=post_page, tag=tag_1,
)
response = self.client.get(f"/api/cms/pages/{post_page.pk}/")
response_data = response.json()
tag_2 = TagFactory.create()
PostPageTagFactory.create(
content_object=post_page, tag=tag_2,
)
response = self.client.get(f"/api/cms/pages/{post_page.pk}/")
response_data = response.json()
assert len(response_data["api_tags"]) == 2
1. Here we add unittests to make sure api_categories and api_tags is working as expected on the
REST API.
----------------------------------------------------------------------
Ran 6 tests in 0.817s
OK
test coverage is a measure used to describe the degree to which the source code of a pro-
gram is executed when a particular test suite runs. A program with high test coverage, mea-
sured as a percentage, has had more of its source code executed during testing, which sug-
gests it has a lower chance of containing undetected software bugs compared to a program
with low test coverage
django==3.1
wagtail==2.10.2
psycopg2-binary
djangorestframework
factory-boy==2.12.0
wagtail-factories==2.0.0
coverage
$ docker-compose up -d --build
$ docker-compose run --rm web coverage run --source='.' manage.py test --noinput -v 2
$ docker-compose run --rm web coverage report
You can also create an HTMl report to get more info (which line is not executed):
29 https://coverage.readthedocs.io/
10.1 Objectives
First, please make sure you have node installed. It is recommended to use nvm30 to install node on your
local env.
$ node -v
v12.18.4
$ npm -v
6.14.6
Next, We’ll use the Create React App31 to generate a boilerplate that’s all set up and ready to go. (It
works like cookiecutter-django32 )
# cd to the root directory
$ ls
manage.py
media
compose
30 https://github.com/nvm-sh/nvm
31 https://create-react-app.dev/https://create-react-app.dev/
32 https://github.com/pydanny/cookiecutter-django
55
React Wagtail Tutorial, Release 1.0.0
docker-compose.yml
react_wagtail_app
blog
requirements.txt
# https://create-react-app.dev/docs/getting-started
$ npx create-react-app frontend
Notes:
1. We create frontend app using npx create-react-app frontend
2. After the command finish, you will see some output like this from terminal
yarn start
Starts the development server.
yarn build
Bundles the app into static files for production.
yarn test
Starts the test runner.
yarn eject
Removes this tool and copies build dependencies, configuration files
and scripts into the app directory. If you do this, you can’t go back!
cd frontend
yarn start
Happy hacking!
If you see some command npm start, npm build when you run command on your local env, that is also
ok and I will explain in a bit.
.
├── frontend
│ ├── README.md
│ ├── node_modules
│ ├── package.json
│ ├── public
│ ├── src
│ └── yarn.lock
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-scripts": "3.4.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
1. The scripts contains all available command we can run in this project.
2. The dependencies contains all dependency and it works like requirements.txt in python
$ cd frontend
$ yarn start
Now check http://localhost:3000/ on your browser, you will see a React icon and Edit src/App.js and
save to reload., which means the setup is working without problem.
yarn start run a devserver on 3000 port and it also monitor the source code in src directory, if you make
changes to the code, it would rebuild automatically. (This behavior is similar with Django’s devserver)
If everything works without problem, please press CTRL-C to quit.
11.1 Objectives
$ rm -r frontend/node_modules/
The docker-compose.yml already has two services web and db, next, let’s add a new service frontend.
Update docker-compose.yml
services:
web:
# code omitted for brevity
db:
# code omitted for brevity
58
React Wagtail Tutorial, Release 1.0.0
frontend:
build:
context: .
dockerfile: ./compose/local/node/Dockerfile
image: react_wagtail_app_frontend
command: yarn start
volumes:
- .:/app
# http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html
- /app/frontend/node_modules
ports:
- 3000:3000
depends_on:
- web
stdin_open: true
Notes
1. We use a custom ./compose/local/node/Dockerfile to build the docker image, we will create the
file in a bit.
2. The command to run in docker conainer is yarn start, which would launch a webpack-dev-
server33
3. stdin_open: true is also required to make the app run in docker.
Here I want to talk about the node_modules
1. As you know, we already deleted frontend/node_modules on docker host.
2. Now we would install frontned packages in docker build stage.
3. To make docker container can use the node_modules created in docker build stage, we need to
mount it to the container. That is why we need to add /app/frontend/node_modules to the volumes.
You can check this link to learn more Lessons from Building a Node App in Docker34
11.3 Dockerfile
FROM node:12-stretch-slim
WORKDIR /app/frontend
33 https://webpack.js.org/guides/development/#using-webpack-dev-server
34 http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html
11.3. Dockerfile 59
React Wagtail Tutorial, Release 1.0.0
$ docker-compose build
$ docker-compose up -d
$ docker-compose logs -f
Now, check http://127.0.0.1:3000/ to see if it work, and do not close this page.
Next, edit frontend/src/App.js, change the Learn React to Learn React Test, and then save in the editor.
You would see new output in termila
frontend_1 | Compiling...
frontend_1 | Compiled successfully!
If you check the http://127.0.0.1:3000/ again, you will see the link text has changed.
You do not have to manually refresh the page to see the change and this awesome features is called
HMR (hot module replacement), which is brought by create-react-app.
12.1 Objectives
12.2 Background
I have seen many online courses or blogs teaching developers to write React Component pull data from
REST API directly when developing.
However, this brings some problems:
1. Your React Component depends on the REST API, which dependes on the data in db sometime.
(You have to write React Component after REST API is ready to serve)
2. Frontend developers can not test, manipulate specific React component in an easy way.
Storybook is an open source tool for developing UI components in isolation for React, Vue,
Angular, and more. It makes building stunning UIs organized and efficient.
Storybook can help solve the above problems in a great way, and that is why I want to show you how to
do it in my course.
61
React Wagtail Tutorial, Release 1.0.0
(container)$ pwd
/app/frontend
# https://create-react-app.dev/docs/developing-components-in-isolation/#getting-started-with-
,→storybook
The above command would update package.json, install dependency packages and create some files.
After the command finish, you should see file structures like this.
frontend
├── .gitignore
├── .storybook
│ ├── main.js
│ └── preview.js
├── public
# code omitted for brevity
├── src
│ ├── App.css
│ ├── App.js
│ ├── App.test.js
│ ├── index.css
│ ├── index.js
│ ├── logo.svg
│ ├── serviceWorker.js
│ ├── setupTests.js
│ └── stories
│ ├── Button.js
│ ├── Button.stories.js
│ ├── Header.js
│ ├── Header.stories.js
│ ├── Introduction.stories.mdx
│ ├── Page.js
│ ├── Page.stories.js
│ ├── assets
│ │ ├── code-brackets.svg
│ │ ├── colors.svg
│ │ ├── comments.svg
│ │ ├── direction.svg
│ │ ├── flow.svg
│ │ ├── plugin.svg
│ │ ├── repo.svg
│ │ └── stackalt.svg
│ ├── button.css
│ ├── header.css
│ └── page.css
└── yarn.lock
Notes:
1. frontend/.storybook contains some config file for the storybook
2. frontend/src/stories contains some sample React Compooent and the sample Storybook code.
3. New command storybook and build-storybook has been added to the scripts of the fron-
tend/package.json
4. Now check storybook command in frontend/package.json, we see start-storybook -p 6006 -s
public. 6006 is the port number of the storybook devserver.
Let’s add new service storybook which work on 6006 to the docker compose file.
Update the frontend/package.json
services:
web:
# code omitted for brevity
db:
# code omitted for brevity
frontend:
# code omitted for brevity
storybook:
build:
context: .
dockerfile: ./compose/local/node/Dockerfile
image: react_wagtail_app_storybook
command: yarn storybook
volumes:
- .:/app
# http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html
- /app/frontend/node_modules
ports:
- 6006:6006
depends_on:
- web
stdin_open: true
Notes:
1. frontend and storybook services have many things in common so here I will only tell the difference.
2. The image is react_wagtail_app_storybook
3. The command is yarn storybook
4. Port is 6006
$ docker-compose up -d --build
$ docker-compose logs -f
Check frontend/.storybook/main.js
You will see something like this
module.exports = {
"stories": [
"../src/**/*.stories.mdx",
"../src/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/preset-create-react-app"
]
}
12.6 Cleanup
In the next chapters, we will start writing our own storybooks, let’s delete the all files in fron-
tend/src/stories
$ rm app/frontend/src/stories/*
12.6. Cleanup 65
Chapter 13
13.1 Objectives
13.2 Bootstrap
This frontend app would build style based on popular open source framwork Bootstrap35
We should use SCSS instead of CSS so we can do customization work.
We would also use React Bootstrap36 to make the React component works with Bootstrap.
By default, create-react-app does not support SCSS, so we sould install some dependency here.
$ docker-compose up -d
This is dependencies of package.json, as you can see, the above packages are already added by yarn
install command.
35 https://getbootstrap.com/docs/4.0/getting-started/theming/
36 https://react-bootstrap.github.io/
66
React Wagtail Tutorial, Release 1.0.0
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"bootstrap": "4.5.3",
"node-sass": "^4.14.1",
"react": "^16.14.0",
"react-bootstrap": "^1.3.0",
"react-dom": "^16.14.0",
"react-scripts": "3.4.3"
},
@import "~bootstrap/scss/bootstrap";
/*!
* Bootstrap v4.5.3 (https://getbootstrap.com/)
* Copyright 2011-2020 The Bootstrap Authors
* Copyright 2011-2020 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
Which means bootstrap SCSS files have been compiled to the css and imported to the frontend project
sucessufly.
14.1 Objectives
Before checking the content below, I wish you have a basic understanding of props and state of React
Component.
14.2.1 props
props (short for properties) are a Component’s configuration, its options if you may. They are received
from above and immutable as far as the Component receiving them is concerned.
A Component cannot change its props, but it is responsible for putting together the props of its child
Components.
14.2.2 state
The state starts with a default value when a Component mounts and then suffers from mutations in
time (mostly generated from user events).
It’s a serializable representation of one point in time—a snapshot.
A Component manages its own state internally, but—besides setting an initial state—has no business
fiddling with the state of its children.
You could say the state is private.
We didn’t say props are also serializable because it’s pretty common to pass down callback functions
through props.
68
React Wagtail Tutorial, Release 1.0.0
Create frontend/src/components/TagWidget.js
export { TagWidget };
Notes:
1. We import index.scss which contains @import "~bootstrap/scss/bootstrap";, which means
Bootstrap styles are dependency of this component. We can also do similar things on the font,
image files.
2. React has more than one kinds of components, here we start with React.Component subclasses
because it is easy to learn and it is popular.
3. In render method, we return a something like HTML, the syntax is called JSX. (It is more readable
than pure JS code becaues you do not need to concatenate the HTML)
4. In JSX, we should use className if we want to specify class for HTML element, because class is a
reserved keyword in JS.
Next, we will create a story for the above component
Create frontend/src/stories/TagWidget.stories.js
export default {
title: "TagWidget",
component: TagWidget,
};
2. We import Container, Row, Col from react-bootstrap to make the JSX strcuture more clear than
<div className='container'>
3. The Example is a JS function, we return JSX in the function and the JSX would render in the story-
book
Now check http://127.0.0.1:6006/, and click TagWidget/Example in the sidebar, you will see component
already dispaly on the page.
Notes:
1. As you can see, we use Storybook to quickly display the React component.
2. The Component now display loading, which means it is loading data, I will talk about how to use
Ajax to pull data to the React component in the next sections.
React component has Lifeccyle, which we can override the methods to run custom code.
Let’s check below methods (we can learn step by step)
37 https://reactjs.org/docs/faq-ajax.html#where-in-the-component-lifecycle-should-i-make-an-ajax-call
componentDidMount() {
const tags = [
{
slug: "wagtail",
name: "Wagtail",
},
{
slug: "django",
name: "Django",
},
{
slug: "react",
name: "React",
},
];
this.setState({
tags,
loading: false
});
}
render() {
let content;
if (this.state.loading) {
content = 'Loading...';
} else {
content = this.state.tags.map((tag) => (
<a href={`/tag/${tag.slug}`} key={tag.slug}>
<span className="badge badge-secondary">{tag.name}</span>{" "}
</a>
))
}
return (
<div className="card my-4">
<h5 className="card-header">Tags</h5>
<div className="card-body">
{content}
</div>
</div>
);
}
}
export { TagWidget };
Notes:
1. In constructor method, we set init value to the Component state
2. In render method, we check state.loading to know if the tag data is ready.
3. In componentDidMount method, we set the tags value to the state.tags and change the
stateloading to false, which means the loading process is finished and data is ready.
4. When state has been updated, the render method would run again to return the tag HTML.
5. We use this.state.tags.map to do for-loop operation, the key is used to distinguish child in a list
React keys38
If you check in the Storybook, you would seem something like this.
Now, we will update the componentDidMount to pull data using Ajax, and then set the data of the response
to the state.tags to make the component work as expected.
Because the REST API is ready, so some people might thinkging about sending requests to the 127.0.
0.1:8000 directly.
However, this is not the best practise.
It is better use some way to mock the API so the storybook can run without the real API server.
Let’s first install axios39 , which is a popular AJAX client, and axios-mock-adapter40 , which allow to mock
request.
componentDidMount() {
axios.get("/api/blog/tags/").then((res) => {
const tags = res.data.results;
this.setState({
38 https://reactjs.org/docs/lists-and-keys.html#keys
39 https://www.npmjs.com/package/axios
40 https://github.com/ctimmerm/axios-mock-adapter
tags,
loading: false
});
});
}
Notes:
1. Import dependency import axios from "axios";
2. Use axios to send Ajax request, and set the data to the component state
Create frontend/src/stories/mockUtils.js
import MockAdapter from "axios-mock-adapter";
import axios from "axios";
export { mockTag };
Notes:
1. We create mockUtils.js and put all mock code in this file.
2. As you can see, the axios-mock-adapter is easy to use and we can define Axios response in JS
code.
Update frontend/src/stories/TagWidget.stories.js
import React from "react";
import { Container, Row, Col } from "react-bootstrap";
import { TagWidget } from "../components/TagWidget";
export default {
title: "TagWidget",
component: TagWidget,
};
mockTag(mock);
return (
<Container>
<Row>
<Col md={4}>
<TagWidget />
</Col>
</Row>
</Container>
);
};
Notes:
1. In the story, we create a MockAdapter instance, and defined the Ajax response in mockTag method.
2. And then return the JSX which contains the TagWidget
Now please check the storybook to make sure it works on your local env.
Now we have import '../index.scss'; in our TagWidget to make the style work, However, this is not a
good way.
Please remove index.scss from frontend/src/components/TagWidget.js, and then update fron-
tend/.storybook/preview.js
14.7 Reference
React Lifecycle Methods Diagram41 , which provides an awesome online diagram for refrenece.
41 https://github.com/wojtekmaj/react-lifecycle-methods-diagram
15.1 Objectives
75
React Wagtail Tutorial, Release 1.0.0
15.2 Design
Before we start, please first install DOMPurify, and I will explain in the below section.
$ docker-compose up -d
If you check blog/blocks.py, you will see we already have some blocks defined in the StreamField.
class BodyBlock(StreamBlock):
h1 = CharBlock()
h2 = CharBlock()
paragraph = RichTextBlock()
image_text = ImageText()
image_carousel = ListBlock(CustomImageChooserBlock())
thumbnail_gallery = ListBlock(CustomImageChooserBlock())
To make the code simple and easy to maintain, we can create React Component for respective blocks in
StreamField.
15.4.1 ImageCarousel
function ImageCarousel(props) {
return (
<div className="my-4">
<Carousel>
{props.value.map((item, index) => (
<Carousel.Item key={`${index}.${item}`}>
<img className="d-block w-100" src={item.url} alt="" />
</Carousel.Item>
))}
</Carousel>
</div>
);
}
export { ImageCarousel };
15.4.2 ImageText
Create frontend/src/components/StreamField/ImageText.js
42 https://reactjs.org/docs/components-and-props.html#function-and-class-components
43 https://reactjs.org/docs/hooks-intro.html
44 https://getbootstrap.com/docs/4.0/components/carousel/
function ImageText(props) {
return (
<Container className="py-4">
<Row
className={`align-items-center ${
props.value.reverse ? "flex-row-reverse" : ""
}`}
>
<Col xs={12} md={5}>
<div dangerouslySetInnerHTML= {{ __html: `${sanitize(props.value.text)}` }} />
</Col>
<Col xs={12} md={7}>
<img
className="img-fluid border"
alt=""
src={props.value.image.url}
/>
</Col>
</Row>
</Container>
);
}
export { ImageText };
Notes:
1. When we insert RichText string to React component, we need to assign it to
dangerouslySetInnerHTML
2. To remove risks from the HTML, we use DOMPurify45 which is a sanitizer for HTML to help us.
3. You can find more details here dangerouslySetInnerHTML46
15.4.3 ThumbnailGallery
Create frontend/src/components/StreamField/ThumbnailGallery.js
function ThumbnailGallery(props) {
return (
<Container>
<div className="row text-center text-lg-left">
{props.value.map((imageItem, index) => (
<div className="col-lg-3 col-md-4 col-6" key={`${index}.${imageItem}`}>
<a
href={imageItem.url}
className="d-block mb-4 h-100"
target="_blank"
rel="noopener noreferrer"
>
<img
className="img-fluid img-thumbnail"
45 https://github.com/cure53/DOMPurify
46 https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
src={imageItem.url}
alt=""
/>
</a>
</div>
))}
</div>
</Container>
);
}
export { ThumbnailGallery };
We already created some StreamField block components, next, we will make them work together.
Create frontend/src/components/StreamField/StreamField.js
function StreamField(props) {
const streamField = props.value;
let html = [];
return html;
export { StreamField };
Next, let’s get some mock data ready to use in the Storybook.
First, please download some images from unsplash48 and put them at frontend/src/stories/assets
You should have files like this below.
└── stories
├── assets
│ ├── image_1.jpeg
│ ├── image_2.jpeg
│ ├── image_3.jpeg
// top
import cardImage from "./assets/image_1.jpeg";
import cardImage2 from "./assets/image_2.jpeg";
import cardImage3 from "./assets/image_3.jpeg";
const richtext1 = `
<p>Wagtail has been born out of many years of experience building websites,
learning approaches that work and ones that don’t,
and striking a balance between power and simplicity, structure and flexibility.
We hope you’ll find that Wagtail is in that sweet spot.</p>
`;
const mockStreamFieldData = [
{
type: "h2",
value: "The Zen of Wagtail",
},
{
type: "paragraph",
value: richtext1,
},
{
type: "thumbnail_gallery",
value: [
{
url: cardImage,
},
47 https://reactjs.org/docs/lists-and-keys.html#keys
48 https://unsplash.com/
{
url: cardImage2,
},
{
url: cardImage3,
},
{
url: cardImage2,
},
{
url: cardImage,
},
],
},
{
type: "image_carousel",
value: [
{
url: cardImage2,
},
{
url: cardImage3,
},
{
url: cardImage2,
},
],
},
{
type: "h2",
value: "ImageText Example",
},
{
type: "image_text",
value: {
image: {
url: cardImage,
},
text: `<div class="rich-text"><p><b>Wagtail</b> CMS's multi-site feature is awesome! Client�
,→can edit content of
Notes:
1. richtext1 contains some RAW HTML, in most cases, this type of data is created by RichTextBlock
or RawHTMLBlock
2. mockStreamFieldData is the StreamField mock data, which is a list contains objects which have
type and value.
3. Please remember to export mockStreamFieldData using export { mockStreamFieldData, }; so
we can import in the story.
15.7 StoryBook
Create frontend/src/stories/StreamField.stories.js
export default {
title: "StreamField",
component: StreamField,
};
return (
<Container>
<Row>
<Col md={8}>
<StreamField value={mockStreamFieldData}/>
</Col>
</Row>
</Container>
);
};
Notes:
1. We pass mockStreamFieldData to the props.value of StreamField
Now you can check the StreamField in your storybook.
15.7. StoryBook 83
React Wagtail Tutorial, Release 1.0.0
15.8 Notes
1. In some projects, you can write story for each StreamField block component.
2. Storybook give us very flexible way for us to quickly check UI compooents without the backend
API. (with the help of Mock data)
16.1 Objectives
85
React Wagtail Tutorial, Release 1.0.0
16.2 Design
16.3 PostDetail
Let’s create PostDetail component, it would display post title, header_image and body (which is
StreamField)
Create frontend/src/components/PostDetail.js
componentDidMount() {
axios.get(`/api/cms/pages/1/`).then((res) => {
const post = res.data;
this.setState({
post,
loading: false
});
})
}
render() {
if (!this.state.loading) {
const post = this.state.post;
return (
<div className="col-md-8">
<img
src={post.header_image_url.url}
className="img-fluid rounded"
alt=""
/>
<hr />
<h1>{post.title}</h1>
<hr />
<StreamField value={post.body} />
</div>
);
} else {
return <div className="col-md-8">Loading...</div>;
}
}
}
export { PostDetail };
1. We build a class component because we need to send Ajax request in componentDidMount method.
2. /api/cms/pages/1/ is the REST API which can let us get the blog post detail from the PostPage.
api_fields
// const mockStreamFieldData
// code omitted for brevity
16.3. PostDetail 87
React Wagtail Tutorial, Release 1.0.0
mockAxios.onGet(`/api/cms/pages/1/`).reply(200, {
id: 1,
title: "Love React 1",
excerpt: "category: programming",
header_image_url: {
url: cardImage,
},
// py datetime.strftime('%s000')
pub_date: 1597720114000,
body: mockStreamFieldData,
});
};
export default {
title: "PostDetail",
component: PostDetail,
};
return (
<Container>
<Row>
<PostDetail />
</Row>
</Container>
);
};
│ │ ├── ImageCarousel.js
│ │ ├── ImageText.js
│ │ ├── StreamField.js
│ │ └── ThumbnailGallery.js
│ └── TagWidget.js
├── index.js
├── index.scss
├── logo.svg
├── serviceWorker.js
├── setupTests.js
└── stories
├── PostDetail.stories.js
├── StreamField.stories.js
├── TagWidget.stories.js
├── assets
│ ├── image_1.jpeg
│ ├── image_2.jpeg
│ └── image_3.jpeg
└── mockUtils.js
16.3. PostDetail 89
React Wagtail Tutorial, Release 1.0.0
The PostPage would have classic two-column layout, top banner is the navbar, left part is the post con-
tent, and the right part is the sidebar.
Let’s start from top to bottom and create frontend/src/components/TopNav.js
export { TopNav };
1. Navbar and Nav from react-bootstrap make the code structure easy to understand.
2. When using classic Bootstrap, you might need to import jQuery to make some components such
as dropdown in menu to work. This is not required when using react-bootstrap.
Let’s create frontend/src/components/SideBar.js, which is a container for sidebar widgets
function SideBar(props) {
return (
<Col md={4}>
<TagWidget />
</Col>
);
}
export { SideBar };
</p>
</div>
</footer>
);
}
}
export { Footer };
16.5 PostPage
export { PostPage };
export default {
title: "PostPage",
component: PostPage,
};
return (
<PostPage />
);
};
1. Because we need to display PostPage data and tag data in the story, so we should call methods
get mock data ready.
16.5. PostPage 93
React Wagtail Tutorial, Release 1.0.0
As you can see, we build a page component step by step and now we can also check the final page in
the storybook.
You might notice we send Ajax to /api/cms/pages/1/ which is a staic url, we will change it in later.
16.5. PostPage 95
Chapter 17
17.1 Objectives
96
React Wagtail Tutorial, Release 1.0.0
17.2 Design
17.3 Install
React Reouter provide some navigational components for us to use in our React app. Which make
navigation between different components possible.
17.2. Design 97
React Wagtail Tutorial, Release 1.0.0
$ docker-compose up -d
Notes:
1. react-router-dom contains componnetes which can be used in web projects.
2. Some core components (such as Route) are in react-router package, which is the dependency of
react-douter-dom, so here we do not need to install it again.
17.4 PostDetail
Current version of PostDetail component will send Ajax to static url /api/cms/pages/1/, let’s change it
to send Ajax request based on the route params.
First, let’s update frontend/src/stories/PostDetail.stories.js
export default {
title: "PostDetail",
component: PostDetail,
};
return (
<Container>
<Row>
<MemoryRouter initialEntries={["/post/1/"]}>
<Route path="/post/:id" component={PostDetail} />
</MemoryRouter>
</Row>
</Container>
);
};
1. We use MemoryRouter and set initialEntries to /post/1/, MemoryRouter is very helpful if we want
to test navigation in the storybook or the test.
2. We declared a Route, if the path is matched with the initialEntries, then PostDetail would be
used to render.
Let’s update frontend/src/components/PostDetail.js
componentDidMount() {
const pk = this.props.match.params.id;
axios.get(`/api/cms/pages/${pk}/`).then((res) => {
const post = res.data;
this.setState({
post,
loading: false
});
})
}
1. When MemoryRouter use PostDetail to render, it would pass match49 object to PostDetail, which
contains info about the router match information.
2. So we can get the PostPage primary key from this.props.match.params.id, and use it to send Ajax
request.
3. Please check PostDetail/Example in storybook, and then try to change initialEntries to see if it
still works.
17.5 PostPage
export default {
title: "PostPage",
component: PostPage,
};
return (
<MemoryRouter initialEntries={['/post/1/']}>
<Switch>
<Route path="/post/:id" component={PostPage}/>
</Switch>
</MemoryRouter>
);
};
return (
49 https://reactrouter.com/web/api/match
17.5. PostPage 99
React Wagtail Tutorial, Release 1.0.0
<MemoryRouter initialEntries={['/post/2/']}>
<Switch>
<Route path="/post/:id" component={PostPage}/>
</Switch>
</MemoryRouter>
);
};
Notes:
1. Here we defined two stories for the Component, they have diffrent initialEntries
2. In Example1, PostPage will send Ajax requests to /api/cms/pages/1/
3. In Example2, PostPage will send Ajax requests to /api/cms/pages/2/
Let’s update frontend/src/components/PostPage.js
import React from "react";
import { Container, Row } from "react-bootstrap";
import { TopNav } from "./TopNav";
import { Footer } from "./Footer";
import { SideBar } from "./SideBar";
import { PostDetail } from "./PostDetail";
export { PostPage };
1. Now we already know component passed to Route would have match object.
2. So PostPage.props would have match object, however, you should know parent component props
does not passed to child component by default.
3. We can use {...this.props} to pass all parent component props to child component. So
PostDetail can access the match.
Let’s update frontend/src/stories/mockUtils.js to add mock data for the /api/cms/pages/2/
mockAxios.onGet(`/api/cms/pages/1/`).reply(200, {
// code omitted for brevity
});
mockAxios.onGet(`/api/cms/pages/2/`).reply(200, {
id: 2,
title: "Love React 2",
excerpt: "tag: react",
header_image_url: {
url: cardImage2,
},
// py datetime.strftime('%s000')
pub_date: 1597720114002,
body: mockStreamFieldData,
});
Notes:
1. They have different titles and header_image
2. Now you can check PostPage in the storybook, you will see two stories which are different.
18.1 Objective
In the last chapter, we already make PostPage work with React router, in this chapter, we will start
building the BlogPage
By the end of this chapter, you should be able to:
1. Build PostPageCardContainer component
2. Use componentDidUpdate method to make pagination work and understand React Lifecycle bet-
ter.
102
React Wagtail Tutorial, Release 1.0.0
18.2 Design
<Link to="/about">About</Link>
Will generate HTML like <a href="/about">About</a>, but it can work with other react router compo-
nents.
18.4 PostPageCard
Let’s build PostPageCard component, it would contain basic info for PostPage and have links point to the
PostPage
Create frontend/src/components/PostPageCard.js
componentDidMount() {
axios.get(`/api/cms/pages/${this.props.postPk}/`).then((res) => {
this.setState({
data: res.data,
loading: false
});
});
}
renderPost(data) {
const dateStr = new Date(data.pub_date).toLocaleString();
return (
<div className="card mb-4">
<Link to={`/post/${data.id}`}>
<img
src={data.header_image_url.url}
className="card-img-top"
alt=""
/>
</Link>
<div className="card-body">
<h2 className="card-title">
<Link to={`/post/${data.id}`}>{data.title}</Link>
</h2>
<p className="card-text">{data.excerpt}</p>
<Link to={`/post/${data.id}`} className="btn btn-primary">
Read More →
</Link>
</div>
<div className="card-footer text-muted">Posted on {dateStr}</div>
</div>
);
}
render() {
if (this.state.loading) {
return 'Loading...';
} else {
return this.renderPost(this.state.data);
}
}
}
export { PostPageCard };
1. PostPageCard will query page detail info by sending Ajax request in componentDidMount
2. props.postPk tell us which page we need to query
3. We use Link from react-router-dom to represent the link instead of a tag.
Let’s create story for the component, create frontend/src/stories/PostPageCard.stories.js
export default {
title: "PostPageCard",
component: PostPageCard,
};
return (
<Container>
<Row>
<Col md={8}>
<MemoryRouter>
<PostPageCard postPk={1} />
</MemoryRouter>
</Col>
</Row>
</Container>
);
};
18.5 PostPageCardContainer
BlogPage will act like index page. It would have below functions
1. Provides pagination so user can check all blog posts
2. Provides filter function so uer can filter by using something like tag
Here let’s create a container component which support the above feature.
This is an important component in this course
Let’s create frontend/src/components/PostPageCardContainer.js
componentDidMount() {
this.getPosts();
}
getCurPage() {
// return the page number from the url
const page = this.props.match.params.page;
return page === undefined ? 1 : parseInt(page);
}
getPrePageUrl() {
const target = _.clone(this.props.match.params);
target.page = this.getCurPage() - 1;
return generatePath(this.props.match.path, target);
}
getNextPageUrl() {
const target = _.clone(this.props.match.params);
target.page = this.getCurPage() + 1;
return generatePath(this.props.match.path, target);
}
getPosts() {
let category =
this.props.match.params.category === undefined
? "*"
: this.props.match.params.category;
let tag =
this.props.match.params.tag === undefined
? "*"
: this.props.match.params.tag;
axios.get(
url
).then((res) => {
const posts = res.data.results;
this.setState({
posts,
pageCount: Math.ceil(parseInt(res.data.count) / this.state.pageStep),
});
});
}
render() {
return (
<Col md={8}>
{this.state.posts.map((post) => (
<PostPageCard postPk={post.id} key={post.id} />
))}
}
}
export { PostPageCardContainer };
Notes
1. In react component, if we want to access Component props (this.props) and state in non-default
methods, then we should bind it to component instance. That is why you see this.getPosts =
this.getPosts.bind(this); in constructor and more details can be found How do I bind a func-
tion to a component instance50
2. getCurPage, getPrePageUrl and getNextPageUrl can help us get the pre and next page index.
3. generatePath can help us generate the link url from the react router match path and match.params.
(It works like Django django.urls.reverse)
4. In getPosts, we use limit and offset to do the pagination, and use tag and category to do filter
function
5. When Ajax request get the response, it would write data to the component state, and component
would run render method, PostPageCard would be used to display detail info of the post page.
Update frontend/src/stories/mockUtils.js
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=0&category=*&tag=*`).reply(200, {
results: [{ id: 1 }, { id: 2 }],
count: 4,
});
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=2&category=*&tag=*`).reply(200, {
results: [{ id: 3 }, { id: 4 }],
count: 4,
});
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=0&category=*&tag=react`)
.reply(200, {
results: [{ id: 2 }, { id: 4 }],
count: 2,
});
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=0&category=*&tag=wagtail`)
.reply(200, {
results: [],
count: 0,
});
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=0&category=*&tag=django`)
.reply(200, {
results: [],
count: 0,
});
mockAxios.onGet(`/api/cms/pages/1/`).reply(200, {
// code omitted for brevity
});
mockAxios.onGet(`/api/cms/pages/2/`).reply(200, {
// code omitted for brevity
});
mockAxios.onGet(`/api/cms/pages/3/`).reply(200, {
id: 3,
title: "Love React 3",
excerpt: "category: programming",
header_image_url: {
url: cardImage,
},
// py datetime.strftime('%s000')
pub_date: 1597720114002,
body: mockStreamFieldData,
});
mockAxios.onGet(`/api/cms/pages/4/`).reply(200, {
id: 4,
title: "Love React 4",
excerpt: "tag: react",
header_image_url: {
url: cardImage,
},
// py datetime.strftime('%s000')
pub_date: 1597720114002,
body: mockStreamFieldData,
});
};
Notes:
1. Now we have 4 posts in the mock data.
2. Post 2 and 4 have tag react
Cretae frontend/src/stories/PostPageCardContainer.stories.js
import React from "react";
export default {
title: "PostPageCardContainer",
component: PostPageCardContainer,
};
return (
<Container>
<Row>
<MemoryRouter initialEntries={["/"]}>
<Switch>
<Route path="/tag/:tag/:page([\d]+)?" component={PostPageCardContainer}/>
<Route path="/:page([\d]+)?" component={PostPageCardContainer}/>
</Switch>
</MemoryRouter>
</Row>
</Container>
);
};
return (
<Container>
<Row>
<MemoryRouter initialEntries={["/tag/react"]}>
<Switch>
<Route path="/tag/:tag/:page([\d]+)?" component={PostPageCardContainer}/>
<Route path="/:page([\d]+)?" component={PostPageCardContainer}/>
</Switch>
</MemoryRouter>
</Row>
</Container>
);
};
Notes:
1. Here we created two stories, the difference is the MemoryRouter has different initialEntries
2. The Pagination story would not filter blog posts
3. TagFilter would filter posts which have tag=react
4. The React router also support custom regex (([\d]+)? here), you can check more details on
custom-matching-parameters51 )
componentDidUpdate(prevProps) {
if (prevProps.location !== this.props.location) {
this.getPosts();
}
}
Notes:
1. location is from react-router, here you can see it something like URL.
2. If the previous URL is different with the current URL, which means user do pagination or filter
operation, then we call getPosts to update state
3. And then render method would be called to reflect the change.
51 https://github.com/pillarjs/path-to-regexp#custom-matching-parameters
52 https://github.com/wojtekmaj/react-lifecycle-methods-diagram
19.1 Objective
19.2 BlogPage
In the previous chapter, we already built PostPageCardContainer, which is the core component of the
BlogPage
Now, let’s create frontend/src/components/BlogPage.js
export { BlogPage };
Notes:
113
React Wagtail Tutorial, Release 1.0.0
19.3 App
Now BlogPage and PostPage are both finished, let’s create the App component to make both component
work together.
Update frontend/src/App.js
function App() {
return (
<Switch>
<Route path="/post/:id([\d]+)" component={PostPage}/>
<Route path="/tag/:tag/:page([\d]+)?" component={BlogPage}/>
<Route path="/:page([\d]+)?" component={BlogPage}/>
<Route
path="*"
component={() => (
<Container>
<Row>
<h1>404</h1>
</Row>
</Container>
)}
/>
</Switch>
);
}
Notes:
1. Switch can make sure only one route would render. Switch doc53
2. In the path, we use regex expression to write flexible route rules. For example, / and /1/ will both
match /:page([\d]+)?.
3. The last route is a fallback route and show 404 message.
Considering the App.css is not needed anymore, let’s delete it.
$ rm frontend/src/App.css
53 https://reactrouter.com/web/api/Switch
export default {
title: "App",
component: App,
decorators: [],
};
return (
<MemoryRouter initialEntries={["/"]}>
<App/>
</MemoryRouter>
);
};
Notes:
1. Considering the App already contains route config, we only need to use MemoryRouter to wrap it.
19.4 TagWidget
To make the App works in storybook, we still need to update some components.
Edit frontend/src/components/TagWidget.js to replace the a with Link of react-router-dom
render() {
let content;
if (this.state.loading) {
content = 'Loading...';
} else {
content = this.state.tags.map((tag) => (
<Link to={`/tag/${tag.slug}`} key={tag.slug}>
<span className="badge badge-secondary">{tag.name}</span>{" "}
</Link>
))
}
return (
<div className="card my-4">
<h5 className="card-header">Tags</h5>
<div className="card-body">
{content}
</div>
</div>
);
}
Please note that the Link need be located in router, or you will get You should not use <Link> outside
a <Router> error.
Update frontend/src/stories/TagWidget.stories.js
return (
<MemoryRouter>
<Container>
<Row>
<Col md={4}>
<TagWidget/>
</Col>
</Row>
</Container>
</MemoryRouter>
);
};
Notes:
1. Here we put TagWidget in MemoryRouter
2. Please check TagWidget story in storybook
19.5 TopNav
Update frontend/src/components/TopNav.js
export { TopNav };
19.6 Category
The category component would work the similar way as Tag component.
componentDidMount() {
axios.get("/api/blog/categories/").then((res) => {
const categories = res.data.results;
this.setState({
categories,
loading: false
});
});
}
render() {
let content;
if (this.state.loading) {
content = 'Loading...';
} else {
content = <div className="row">
<div className="col-lg-12">
<ul className="list-unstyled mb-0">
{this.state.categories.map((category) => (
<li key={category.slug}>
<Link to={`/category/${category.slug}`}>
{category.name}
</Link>
</li>
))}
</ul>
</div>
</div>
}
return (
<div className="card my-4">
<h5 className="card-header">Categories</h5>
<div className="card-body">
{content}
</div>
</div>
);
}
}
export { CategoryWidget };
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=0&category=programming&tag=*`)
.reply(200, {
results: [{ id: 1 }, { id: 3 }],
count: 2,
});
mockAxios
.onGet(`/api/blog/posts/?limit=2&offset=0&category=life&tag=*`)
.reply(200, {
results: [],
count: 0,
});
}
mockAxios.onGet(API_REQUEST).reply(200, {
results: [
{
slug: "programming",
name: "Programming",
},
{
slug: "life",
name: "Life",
},
],
});
};
Update frontend/src/components/SideBar.js
import React from "react";
import { Col } from "react-bootstrap";
import { TagWidget } from "./TagWidget";
import { CategoryWidget } from "./CategoryWidget";
function SideBar(props) {
return (
<Col md={4}>
<CategoryWidget/>
<TagWidget />
</Col>
);
}
export { SideBar };
Update frontend/src/stories/App.stories.js
import React from "react";
import { MemoryRouter } from "react-router-dom";
export default {
title: "App",
component: App,
decorators: [],
};
return (
<MemoryRouter initialEntries={["/"]}>
<App/>
</MemoryRouter>
);
};
Notes:
1. We should also do the same thing on frontend/src/stories/PostPage.stories.js
19.8 Storybook
As you can see, we build the whole App in the storybook step by step, without sending requests to our
backend.
1. Storybook provides us isolated env for our components.
2. axios-mock-adapter let us create mock data in very elegant way.
20.1 Objectvie
20.2 Jest
Notes:
1. App.test.js means it is test file for App.js
2. We are using jest54 to run test in CRA55 , jest is a simple JavaScript Testing Framework and CRA
already config it for us, so here we can just use it directly.
3. renders learn react link is the name of the test.
4. The anonymous function contains the logic of the test, you can ignore the first lines here.
5. expect(linkElement).toBeInTheDocument(); is an assert statement.
Let’s try to run test
54 https://jestjs.io/en/
55 https://github.com/facebook/create-react-app
121
React Wagtail Tutorial, Release 1.0.0
yarn test is the command to run test, the test fail becaues we already modified App.js, that is not a
problem.
Now, please delete the frontend/src/App.test.js and we will add it back soon.
jest provides basic features for us to test normal JS code, if we want to test UI componetns in clean
way, we still need testing-library56
The @testing-library family of packages helps you test UI components in a user-centric way.
1. @testing-library/dom is a very light-weight solution for testing DOM nodes
2. @testing-library/react builds on top of DOM Testing Library by adding APIs for working with
React components.
Some people who are new to testing-library feel confused becasue they can not get the props and
state of component. Let’s check the words from testing-library doc57
Testing Library encourages you to avoid testing implementation details like the internals of
a component you’re testing (though it’s still possible). The Guiding Principles of this library
emphasize a focus on tests that closely resemble how your web pages are interacted by the
users.
So testing-library is more focused on the DOM and user interactions, instead of the component intenal
details.
If you want to test by checking component props and state, you can take a look at another framework
Enzyme58 , but we will not use it in this course.
CRA already include @testing-library/react, so next we will use it to test our application.
The first component we built is TagWidget, so let’s write our first test for it.
Create frontend/src/components/TagWidget.test.js (We can keep the test file beside the component file)
56 https://testing-library.com/docs/
57 https://testing-library.com/docs/
58 https://enzymejs.github.io/enzyme/
Notes:
1. Because TagWidget contains Link from react-router-dom, so we wrap it using MemoryRouter to
avoid error.
2. We render the component, and check if there is Loading text in the document.
As you know, in TagWidget, we use Ajax to query data and save it to state of the component.
To test the behavior, we need to do two things
1. Mock data
2. Let the test wait for the component to finish loading
As a test framework, jest provides a simple way for us to create mock function. Mock Functions59
And we would use Async/Await to make testing asynchronous code possible. Testing Asynchronous
Code60
jest.mock('axios');
59 https://jestjs.io/docs/en/mock-functions
60 https://jestjs.io/docs/en/asynchronous#asyncawait
{
slug: "django",
name: "Django",
},
{
slug: "react",
name: "React",
},
],
}
};
axios.get.mockResolvedValue(resp);
render(
<MemoryRouter>
<TagWidget />
</MemoryRouter>
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
resp.data.results.map((tag) =>
expect(screen.getByText(tag.name)).toBeInTheDocument()
);
});
Notes:
1. We use async in front of the test function, so we can use await in it.
2. We use jest.mock to mock the axios modules.
3. In the arrange state, we create mock data and make it work by using axios.get.
mockResolvedValue. (This would make the axios.get method return the mock data)
4. In assert stage, we use await and wait to let jest wait and keep running after expect statement
return true
Snapshot tests are a very useful tool whenever you want to make sure your UI does not
change unexpectedly.
The logic of Snapshot tests is it would compare snapshot or the component and make sure the UI would
be consistent during the test.
Update frontend/src/components/TagWidget.test.js
import React from "react";
import { render, screen, wait} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import axios from 'axios';
jest.mock('axios');
resp.data.results.map((tag) =>
expect(screen.getByText(tag.name)).toBeInTheDocument()
);
expect(asFragment()).toMatchSnapshot();
});
</a>
<a
href="/tag/django"
>
<span
class="badge badge-secondary"
>
Django
</span>
</a>
<a
href="/tag/react"
>
<span
class="badge badge-secondary"
>
React
</span>
</a>
</div>
</div>
</DocumentFragment>
`;
Notes:
1. If you change HTML in TagWidget, the snapshot test will fail, this can make sure you would not
bring unexpected change to the HTMl of the component.
2. When talking about snapshot test for React component, many oneline resources61 would also
61 https://jestjs.io/docs/en/snapshot-testing
62 https://reactjs.org/docs/test-renderer.html
21.1 Objectives
Create frontend/src/App.test.js
render(
<MemoryRouter initialEntries={[ '/' ]}>
<App/>
</MemoryRouter>,
);
128
React Wagtail Tutorial, Release 1.0.0
const el = getByText('Programming');
fireEvent.click(el);
});
Notes:
1. Here we we use axios-mock-adapter to help us mock response for axios.get, which can make us
reuse the mock data in mockUtils
2. The test would wait and check if there is Programming text appare in the Category widget. If it
exists, it would click the link (fireEvent.click(el))
3. After it click the link, it would wait and check there is Love React 1 and Love React 3 in the new
page. (It would check if the category filter function work as expected.)
$ docker-compose exec frontend bash
$ yarn test
render(
<MemoryRouter initialEntries={[ '/' ]}>
<App/>
</MemoryRouter>,
);
fireEvent.click(el);
});
Notes:
1. The logic is very similar with the above test
$ docker-compose exec frontend bash
$ yarn test
render(
<MemoryRouter initialEntries={[ '/' ]}>
<App/>
</MemoryRouter>,
);
const el = screen.getByText("Next");
fireEvent.click(el);
});
render(
<MemoryRouter initialEntries={[ '/' ]}>
<App/>
</MemoryRouter>,
);
});
Notes:
1. Here we wail for the Love React 1 appare on the page and click the link.
2. And then we check if the PostDetail page is working as expected.
Test coverage report can print stats about the test, which can give us confidence.
Notes: If you see Nothing was returned from render. This usually means a return statement is
missing error, please find solution in Frontend FAQ
-----------------------------------|----------|----------|----------|----------|-------------------|
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s |
-----------------------------------|----------|----------|----------|----------|-------------------|
All files | 56.12 | 48.48 | 62.86 | 55.9 | |
src | 2.33 | 0 | 5.56 | 2.33 | |
App.js | 50 | 100 | 50 | 50 | 17 |
index.js | 0 | 100 | 100 | 0 | 7,17 |
serviceWorker.js | 0 | 0 | 0 | 0 |... 32,133,135,138 |
src/components | 100 | 100 | 100 | 100 | |
BlogPage.js | 100 | 100 | 100 | 100 | |
CategoryWidget.js | 100 | 100 | 100 | 100 | |
Footer.js | 100 | 100 | 100 | 100 | |
PostDetail.js | 100 | 100 | 100 | 100 | |
PostPage.js | 100 | 100 | 100 | 100 | |
PostPageCard.js | 100 | 100 | 100 | 100 | |
PostPageCardContainer.js | 100 | 100 | 100 | 100 | |
SideBar.js | 100 | 100 | 100 | 100 | |
TagWidget.js | 100 | 100 | 100 | 100 | |
TopNav.js | 100 | 100 | 100 | 100 | |
src/components/StreamField | 91.67 | 85.71 | 100 | 91.3 | |
ImageCarousel.js | 100 | 100 | 100 | 100 | |
22.1 Objective
In this chapter, we will config to make our frontend app work with our REST API.
22.2 Index.js
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App/>
</BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);
1. index.scss contains style code for our app (for now, it only contains bootstrap)
2. We use BrowserRouter to wrap our App component instead of MemoryRouter, so you can check the
route change through the address bar of web browser.
3. We use ReactDOM.render to render component into the DOM element.
$ docker-compose up --build -d
133
React Wagtail Tutorial, Release 1.0.0
But wait, the REST API is working on http://127.0.0.1:8000, the port number is not correct here.
Let’s fix it.
Some people might think we can add domain and port number to the axios request to make it work, but
there is another way to solve this in an elegant way.
From CRA Doc63
To tell the development server to proxy any unknown requests to your API server in develop-
ment, add a proxy field to your package.json
Let’s update frontend/package.json
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"storybook": "start-storybook -p 6006 -s public",
"build-storybook": "build-storybook -s public"
},
"proxy": "http://web:8000",
"eslintConfig": {
"extends": "react-app"
},
As you can see, we add proxy which has value http://web:8000, and the CRA dev server would proxy
the REST API request to http://web:8000 and we can still write relative url in our frontend code, which
is clean.
$ docker-compose up --build -d
Now if you check http://127.0.0.1:3000/ you will see it can work without problem.
63 https://create-react-app.dev/docs/proxying-api-requests-in-development/
23.1 Objective
23.2 WorkFlow
Update requirements.txt
# other packages
wagtail-headless-preview==0.1.4
Update react_wagtail_app/settings.py
INSTALLED_APPS = [
# other packages
"wagtail_headless_preview",
]
HEADLESS_PREVIEW_CLIENT_URLS = {
"default": "http://localhost:3000/",
}
Notes:
1. In HEADLESS_PREVIEW_CLIENT_URLS we tell wagtail-headless-preview the domain of frontend app.
136
React Wagtail Tutorial, Release 1.0.0
import urllib.parse
from wagtail_headless_preview.models import HeadlessPreviewMixin
class Meta:
abstract = True
class BlogPage(BasePage):
# for brevity
class PostPage(BasePage):
# for brevity
Notes:
1. We create a BasePage class, which inherit HeadlessPreviewMixin.
2. PostPage and BlogPage inherit the above BasePage.
3. We overwrite get_preview_url method to generate the preview post url according to the React
Routes in our frontend app.
4. get_client_root_url would return the value we defined in HEADLESS_PREVIEW_CLIENT_URLS ()
5. So if the post has pk 4, the get_preview_url would return something like http://localhost:3000/
post/4/?content_type=blog.postpage&token=xxxxxxx, people can only view the preview content
if the token is correct.
class PagePreviewAPIViewSet(PagesAPIViewSet):
known_query_parameters = PagesAPIViewSet.known_query_parameters.union(
["content_type", "token"]
)
page = self.get_object()
serializer = self.get_serializer(page)
return Response(serializer.data)
def get_object(self):
app_label, model = self.request.GET["content_type"].split(".")
content_type = ContentType.objects.get(app_label=app_label, model=model)
page_preview = PagePreview.objects.get(
content_type=content_type, token=self.request.GET["token"]
)
page = page_preview.as_page()
if not page.pk:
# fake primary key to stop API URL routing from complaining
page.pk = 0
return page
cms_api_router = WagtailAPIRouter("wagtailapi")
cms_api_router.register_endpoint("pages", PagesAPIViewSet)
cms_api_router.register_endpoint("images", ImagesAPIViewSet)
cms_api_router.register_endpoint("documents", DocumentsAPIViewSet)
cms_api_router.register_endpoint("page_preview", PagePreviewAPIViewSet)
Notes:
1. We create an endpoint api/cms/page_preview
2. The key point is PagePreviewAPIViewSet.get_object, which would get content_type and token
from the querystring, and find the PagePreview instance, which contains json representation of
the draft content.
After all those are done, let’s migrate db
$ docker-compose up --build -d
$ docker-compose logs -f
$ docker-compose exec web bash
(container) $ ./manage.py migrate
Update frontend/src/components/PostDetail.js
componentDidMount() {
const pk = this.props.match.params.id;
if (params.token) {
// preview
axios.get(`/api/cms/page_preview/${pk}/${this.props.location.search}`)
.then((res) => {
Notes:
1. As we know, this.props.location.search contains info about the querystring of URL.
2. We check if querystring contains token, if it does, we send AJAX request to api/cms/page_preview,
the request URL contains content_type and token passed by Wagtail admin.
Now if you check draft in Wagtail admin, it can work with our React app
If you click View live button, you will get template does not exist error, let’s fix it to make it work.
class BlogPage(BasePage):
class PostPage(BasePage):
Notes:
1. In serve method, we return HttpResponseRedirect to redirect user to the relevant frontend url.
23.7 Conclusion
1. The serve method can help redirect visitor to relevant frontend component.
2. The HeadlessPreviewMixin.get_preview_url can help pass token and content_type to the fron-
tend app, and then frontend app can use them to send AJAX to REST API to fetch preview page
data
24.1 Objective
In this chapter, we will learn how to deploy our REST API to DigitalOcean64 with Docker Compose.
24.2 Workflow
Let’s create docker-compose.prod.yml, the prod means this compose file is for our production app.
64 https://www.digitalocean.com/
140
React Wagtail Tutorial, Release 1.0.0
version: '3.7'
services:
nginx:
build:
context: .
dockerfile: ./compose/production/nginx/Dockerfile
volumes:
- staticfiles:/app/static
- mediafiles:/app/media
ports:
- 80:80
depends_on:
- web
web:
build:
context: .
dockerfile: ./compose/production/django/Dockerfile
command: /start
volumes:
- staticfiles:/app/static
- mediafiles:/app/media
env_file:
- ./.env/.prod-sample
depends_on:
- db
db:
image: postgres:12.0-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_DB=react_wagtail_dev
- POSTGRES_USER=react_wagtail
- POSTGRES_PASSWORD=react_wagtail
volumes:
postgres_data:
staticfiles:
mediafiles:
Notes:
1. Here we create 3 services, nginx is reverse proxy for web service, we only need to expose 80 port
for nginx
2. For web service, we created staticfiles and mediafiles docker volumn to store the assets.
3. All env variables are stored in .env/.prod-sample
Create production directory under the compose, and then create sub directory django and nginx.
So you would have file structure like this.
├── compose
│ ├── local
│ │ ├── django
│ │ └── node
│ └── production
│ ├── django
│ └── nginx
Create compose/production/nginx/Dockerfile:
FROM nginx:1.19.2-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY ./compose/production/nginx/nginx.conf /etc/nginx/conf.d
Create compose/production/nginx/nginx.conf:
upstream hello_django {
server web:8000;
}
server {
listen 80;
location / {
proxy_pass http://hello_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
client_max_body_size 20M;
}
location /static/ {
alias /app/static/;
}
location /media/ {
alias /app/media/;
}
}
Notes:
1. client_max_body_size 20M; is to solve “Request Entity Too Large” error when we upload image
in Wagtail admin.
2. /app/static/ and /app/media/ point to the docker volume staticfiles and mediafiles, where
Nginx would find files.
├── compose
│ └── production
│ ├── django
│ │ ├── Dockerfile
│ │ ├── entrypoint
│ │ └── start
24.5.1 DockerFile
Create compose/production/django/Dockerfile
FROM python:3.8-slim-buster
ENV PYTHONUNBUFFERED 1
WORKDIR /app
USER django
ENTRYPOINT ["/entrypoint"]
Notes:
1. We added a django user and used it to run the entrypoint command for security.
2. We use RUN mkdir /app/static, RUN mkdir /app/media combined with RUN chown -R
django:django /app to solve the permission denied problem.
24.5.2 Entrypoint
Create compose/production/django/entrypoint
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
postgres_ready() {
python << END
import sys
import psycopg2
try:
psycopg2.connect(
dbname="${SQL_DATABASE}",
user="${SQL_USER}",
password="${SQL_PASSWORD}",
host="${SQL_HOST}",
port="${SQL_PORT}",
)
except psycopg2.OperationalError:
sys.exit(-1)
sys.exit(0)
END
}
until postgres_ready; do
>&2 echo 'Waiting for PostgreSQL to become available...'
sleep 1
done
>&2 echo 'PostgreSQL is available'
exec "$@"
Notes:
1. We defined a postgres_ready function which is called in loop.
2. The exec "$@" is used to make the entrypoint a pass through to ensure that Docker runs the com-
mand the user passes in (command: /start, in our case). For more, check this Stack Overflow
answer65 .
Update compose/production/django/start
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
Notes:
1. We should collecstatic to collect static assets for production app Serving static files in produc-
tion66
2. We use gunicorn to run Django app
65 https://stackoverflow.com/a/39082923/2371995
66 https://docs.djangoproject.com/en/3.1/howto/static-files/deployment/
Create .env/.prod-sample, which contains env variables for our production app
DEBUG=0
SECRET_KEY=dbaa1_i7%*3r9-=z-+_mz4r-!qeed@(-a_r(g@k8jo8y3r27%m
DJANGO_ALLOWED_HOSTS=*
SQL_ENGINE=django.db.backends.postgresql
SQL_DATABASE=react_wagtail_dev
SQL_USER=react_wagtail
SQL_PASSWORD=react_wagtail
SQL_HOST=db
SQL_PORT=5432
Please make sure .env is not excluded in the .gitignore, so it can be added to Git repo
Update react_wagtail_app/settings.py to read the above SECRET_KEY, DEBUG and ALLOWED_HOSTS environ-
ment variables:
24.6.1 Config
STATIC_URL = '/static/'
STATIC_ROOT = str(BASE_DIR / 'static')
STATIC_URL and MEDIA_URL should match the above Nginx location config.
Add gunicorn to requirements.txt
django==3.1
wagtail==2.10.2
wagtail-headless-preview==0.1.4
psycopg2-binary
djangorestframework
factory-boy==2.12.0
wagtail-factories==2.0.0
coverage
gunicorn
Let’s test the config on local env. (This can help us find problem)
# cleanup
$ docker-compose stop
$ docker-compose down
$ docker-compose ps
Name Command State Ports
------------------------------
Command above can help us remove the dev docker containers, while keeping the docker volumes.
Notes:
1. We specify docker compose file by using -f docker-compose.prod.yml (please note default
docker-compose.yml is for dev app)
2. -p react-wagtail-prod specify the value prepended along with the service name. So the test
would not ruin our local development env. You can check Docker doc67 for more details.
3. If you visit http://localhost/cms-admin in your browser, you will see Wagtail admin login page. This
means the setup was correct.
(container)$ ls media
images original_images
Here we see some problems, the media directory was copied to /app when docker build image.
So we should tell docker to ignore it. dockerignore-file68 can help us solve this problem
Create .dockerignore
db.sqlite3
node_modules
/media
Now if you build the image, rerun the application and check, media directory should not contains the
local media files.
# cleanup
$ docker-compose -f docker-compose.prod.yml -p react-wagtail-prod stop
# delete containers and volumes
$ docker-compose -f docker-compose.prod.yml -p react-wagtail-prod down -v
$ docker-compose -f docker-compose.prod.yml -p react-wagtail-prod ps
67 https://docs.docker.com/compose/reference/envvars/#compose_project_name
68 https://docs.docker.com/engine/reference/builder/#dockerignore-file
In this section:
1. (local)$ means that the command should be ran on your local environment
2. (server)$ means that the command should be ran on the remote server.
First, sign up for a DigitalOcean account69 (if you don’t already have one), and then generate70 an API
token so you can access the DigitalOcean API.
Add the token to your environment:
Next, create a Droplet with Docker pre-installed71 , you can copy shell code from that page and update
the command:
# create Droplet
curl -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' -d \
'{"name":"react-wagtail-project","region":"sfo2","size":"s-2vcpu-4gb","image":"docker-20-04"}' \
"https://api.digitalocean.com/v2/droplets"
# check status
curl \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer '$DIGITAL_OCEAN_ACCESS_TOKEN'' \
"https://api.digitalocean.com/v2/droplets?react-wagtail-project"
Notes:
1. We use react-wagtail-project as the Droplet name, you can modify it.
2. When the Droplet is available, you should receive an email which contains the login credentials.
Notes: This section assume you have no experience with SSH, and it would help you get it work quicikly.
# type the root password in the email and set the new password
This will generate a public and private key – .ssh.id_rsa.pub and .ssh/id_rsa, respectively.
Copy the above private key to your system clipboard and then set it as an environment variable on your
local machine:
69 https://m.do.co/c/b585bd8722ec
70 https://www.digitalocean.com/docs/apis-clis/api/
71 https://marketplace.digitalocean.com/apps/docker
VXL9Pdbo8X7PtCmvdD/lvuhcg8iFhwJR8YqxeZhRvds5PzwIhYx9/n7f3y6goR0s
8J71z47xZs6phQD96o3dG692E8gUBbt525p08+ysOQBLbv8DTdv0xoCOkV83I2z1
...
-----END RSA PRIVATE KEY-----'
Identity added
To test, run:
root
Notes:
1. You can save the SSH private key to $HOME/.ssh/id_rsa locally, so SSH still works after you restart
your local machine. Github Doc73
2. To keep your server secure, if you can log in using the SSH private key, you should disable SSH
password login.
Before we start, please make sure to use git commit to commit your code.
Next, let’s write a bash script to upload source code to DigitalOcean.
Create compose/auto_deploy_do.sh:
#! /bin/bash
if [ -z "$DIGITAL_OCEAN_IP_ADDRESS" ]
then
echo "DIGITAL_OCEAN_IP_ADDRESS not defined"
exit 0
fi
Notes:
72 https://en.wikipedia.org/wiki/Ssh-agent
73 https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent
1. First, we export the latest version from the master branch to project.tar. (Please update if you use
new main branch)
2. Then, we upload project.tar to the server, clean up the “/app” directory, and decompress the source
code to “/app”.
3. Finally, we re-build the Docker image.
Uploading project...
Uploaded complete.
Building image...
...
Build complete.
(server)$ cd /app
(server)$ docker-compose -f docker-compose.prod.yml up
Now you can visit http://<YOUR_INSTANCE_IP>/cms-admin/74 , to see if the Wagtail admin is up and
running. Press Ctrl+c to stop the running containers.
# check
(server)$ docker-compose -f docker-compose.prod.yml ps
Next, rather than manually running the containers, let’s let Supervisor handle this for us.
[program:react-wagtail-project]
directory=/app
command=docker-compose -f docker-compose.prod.yml up
autostart=true
autorestart=true
Restart:
(server)$ supervisorctl
supervisor> reload
Really restart the remote supervisord process y/N? y
Restarted supervisord
supervisor> status
react-wagtail-project STARTING
74 http://%3CYOUR_INSTANCE_IP%3E/cms-admin/
Now the containers should automatically run on boot. We can also restart the containers via the
supervisorctl restart react-wagtail-project command. Let’s run the command after the image is
built.
#! /bin/bash
if [ -z "$DIGITAL_OCEAN_IP_ADDRESS" ]
then
echo "DIGITAL_OCEAN_IP_ADDRESS not defined"
exit 0
fi
Now, after the new images are built, supervisorctl is used to restart the containers:
Uploading project...
Uploaded complete.
Building image...
...
react-wagtail-project: stopped
react-wagtail-project: started
Build complete.
(server)$ cd /app
(server)$ docker-compose -f docker-compose.prod.yml exec web python manage.py createsuperuser
After you are done, you can use the login credential to login Wagtail admin
http://<YOUR_INSTANCE_IP>/cms-admin/75 and setup your site, upload images and create pages.
75 http://%3CYOUR_INSTANCE_IP%3E/cms-admin/
To make the site work with domain, let’s create DNS records to point to the IP address of the server.
Here I config react-wagtail-api.accordbox.com (you need change it) to point to the IP of the server,
after waiting for some minutes, let’s run test.
$ curl http://react-wagtail-api.accordbox.com/
<!DOCTYPE HTML>
<html>
<head>
<title>Welcome to your new Wagtail site!</title>
</head>
<body>
<h1>Welcome to your new Wagtail site!</h1>
</body>
</html>
Please remember to config Wagtail site hostname to make the generated url from REST API has the
correct hostname.
Deploy Storybook
25.1 Objectives
In this chapter, we will learn how to deploy Storybook to DigitalOcean76 with Docker Compose.
25.2 Workflow
154
React Wagtail Tutorial, Release 1.0.0
25.4 Workflow
25.5 DockerFile
WORKDIR /app/frontend
COPY ./frontend .
# build storybook
RUN yarn build-storybook
###############################################
FROM nginx:1.19.2-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY ./compose/production/nginx/nginx.conf /etc/nginx/conf.d
# copy storybook
COPY --from=frontend-builder /app/frontend/storybook-static /usr/share/nginx/html/storybook-static
Notes:
1. For the first build stage, we assign it a name frontend-builder
2. In the first build stage, we install frontend denendpency packages and use yarn build-storybook
to build storybook, after this command is finished, the built assets would be available in /app/
frontend/storybook-static
3. In the second build stage, when building image for nginx service, we copy storybook-static from
the first stage and put it in /usr/share/nginx/html/storybook-static
77 https://docs.docker.com/develop/develop-images/multistage-build/#use-multi-stage-builds
4. --from has the value of the build stage, here the value is frontend-builder, which we set in FROM
node:12-stretch-slim as frontend-builder
5. Next we can config nginx serve storybook like normal static site (HTML, JS and other assets).
25.6 Nginx
upstream hello_django {
server web:8000; }
server {
listen 80;
server_name react-wagtail-api.accordbox.com;
location / {
proxy_pass http://hello_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
client_max_body_size 20M;
}
location /static/ {
alias /app/static/;
}
location /media/ {
alias /app/media/;
}
}
server {
listen 80;
server_name react-wagtail-storybook.accordbox.com;
location / {
root /usr/share/nginx/html/storybook-static;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
Notes:
1. We add server_name to the nginx location so multiple sites can work on the same 80 port.
2. The REST API has domain react-wagtail-api.accordbox.com
3. the storybook has domain react-wagtail-storybook.accordbox.com
4. /usr/share/nginx/html/storybook-static contains the built asstes of storybook, we copy the
files during the docker build stage.
25.7 Deploy
Now, please git add and git commit the above files, and then deploy to the server.
$ bash compose/auto_deploy_do.sh
26.1 Objective
In this chapter, we will learn how to deploy React App to DigitalOcean78 with Docker Compose.
26.2 Workflow
Before we start, please config react-wagtail.accordbox.com (please use a different domain you own)
to point it to the IP of the server. So we do not have to wait after a while.
78 https://www.digitalocean.com/
157
React Wagtail Tutorial, Release 1.0.0
26.4 DockerFile
We already understand what is docker multi-stage builds79 , let’s keep using it to deploy our frontend
app.
Update compose/production/nginx/Dockerfile
WORKDIR /app/frontend
COPY ./frontend .
# build storybook
RUN yarn build-storybook
###############################################
FROM nginx:1.19.2-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY ./compose/production/nginx/nginx.conf /etc/nginx/conf.d
# copy storybook
COPY --from=frontend-builder /app/frontend/storybook-static /usr/share/nginx/html/storybook-static
# copy the frontend build
COPY --from=frontend-builder /app/frontend/build /usr/share/nginx/html/build
Notes:
1. We run yarn build to build frontend app after yarn build-storybook
2. After frontapp is built, we copy the static assets to /usr/share/nginx/html/build of nginx image.
26.5 Nginx
Update compose/production/nginx/nginx.conf
upstream hello_django {
server web:8000;
}
server {
listen 80;
server_name react-wagtail-api.accordbox.com;
location / {
proxy_pass http://hello_django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
79 https://docs.docker.com/develop/develop-images/multistage-build/#use-multi-stage-builds
server {
listen 80;
server_name react-wagtail-storybook.accordbox.com;
location / {
root /usr/share/nginx/html/storybook-static;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
server {
listen 80;
server_name react-wagtail.accordbox.com;
location / {
root /usr/share/nginx/html/build;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
Notes:
1. We need try_files $uri $uri/ /index.html; to make react-router work with url after browser
refresh. Github issue80
Now, please git add and git commit the above files, and then deploy to the server.
$ bash compose/auto_deploy_do.sh
If you visit http://react-wagtail.accordbox.com, you will see the Ajax requests all fail.
Let’s figure out why this happen.
When we develop the frontend app on local env, we learned we can Proxying API Requests in Develop-
ment81 by adding proxy field to our package.json
For production apps, frontend app and REST API app are deployed on different domains, so we should
tell frontend app to send Ajax request to the REST API app.
Create React App has already provided solution for us Adding Custom Environment Variables82
1. We can add the REST API domain to the ENV file
2. CRA would write the value to the final bundles when building. (This happen in docker build stage)
80 https://github.com/react-boilerplate/react-boilerplate/issues/1480
81 https://create-react-app.dev/docs/proxying-api-requests-in-development/
82 https://create-react-app.dev/docs/adding-custom-environment-variables/
Create frontend/.env.production
REACT_APP_API_URL=http://react-wagtail-api.accordbox.com
if (process.env.REACT_APP_API_URL) {
axios.defaults.baseURL = process.env.REACT_APP_API_URL;
}
ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<App/>
</BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);
26.7 CORS
django==3.1
wagtail==2.10.2
wagtail-headless-preview==0.1.4
psycopg2-binary
djangorestframework
factory-boy==2.12.0
wagtail-factories==2.0.0
coverage
gunicorn
django-cors-headers
Update react_wagtail_app/settings.py
83 https://en.wikipedia.org/wiki/Same-origin_policy
84 https://github.com/adamchainz/django-cors-headers
INSTALLED_APPS = [
# code omitted for brevity
"corsheaders",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
# code omitted for brevity
]
CORS_ORIGIN_ALLOW_ALL = True
Notes:
1. We update INSTALLED_APPS and MIDDLEWARE to make it work in our project.
2. CORS_ORIGIN_ALLOW_ALL = True means all origins will be allowed. (We can change to only allow
specific origins later)
Now, please git add and git commit the above files, and then deploy to the server.
$ bash compose/auto_deploy_do.sh
To quickly check if django-cors-headers is working as expected. We can test on local env by using
command below
Access-Control-Allow-Origin: *
If we visit http://react-wagtail.accordbox.com, we will see the Ajax request now is working as expected.
But we also find one problem, the images can not display.
If we check them in devtool, we see the problem.
1. The JSON data from the REST API has relative image url
2. So the web browser would try to download the image from the react-wagtail.accordbox.com/
media instead of the react-wagtail-api.accordbox.com/media
Let’s fix it in the next section.
26.9 Nginx
Update compose/production/nginx/nginx.conf
server {
listen 80;
server_name react-wagtail.accordbox.com;
location / {
root /usr/share/nginx/html/build;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
location /media/ {
return 301 http://react-wagtail-api.accordbox.com$request_uri;
}
}
Notes:
1. On react-wagtail.accordbox.com site, all media requests http://react-wagtail.accordbox.com/
media/xxx would be redirected to react-wagtail-api.accordbox.com
Now, please git add and git commit the above files, and then deploy to the server.
$ bash compose/auto_deploy_do.sh
To make editor can check preview and live version of the PostPage.
Update react_wagtail_app/settings.py
HEADLESS_PREVIEW_CLIENT_URLS = {
"default": os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000/"),
}
FRONTEND_BASE_URL=http://react-wagtail.accordbox.com
Now, please git add and git commit the above files, and then deploy to the server.
$ bash compose/auto_deploy_do.sh
Now if you click Live button or View Draft button, you should be able to redirected to the correct url
which contains the page content.
27.1 Troubleshoot
If you run into problems, you can view the logs at:
$ docker-compose logs -f
Sometimes, you may want to remove the docker-compose app to start over again.
If you want to also remove the data in docker volume (db data in this project)
To enter the shell of a container that’s up and running, run the following command:
# for example:
# docker-compose exec web bash
If you want to run a command against a new container that’s not currently running, run:
The --rm option tells docker to delete the container after you exit the shell.
To stop the docker compose application
163
Chapter 28
Frontend FAQ
Please note that the docker container has its own node_modules directory when we develop our app.
(Because of /app/frontend/node_modules in docker-compose.yml)
So when you add some package to one container node_modules, the other container might not be synced.
Since you know the cause of the problem so you can solve in this way.
Another approach is
1. Rebuild docker image.
2. docker-compose stop XXX and docker-compose rm XXX the service container.
3. docker-compose up -d (it will mount node_modeuls from recently built docker image)
28.2 Nothing was returned from render. This usually means a return
statement is missing
This is the problem of the dependency package of CRA, and the relevant issue is
https://github.com/facebook/create-react-app/issues/8689
I solved this by rollback the react-scripts to 3.4.0 and use npm install to install the dependency
packages. (https://github.com/facebook/create-react-app/issues/8689#issuecomment-602233612)
164