Add live preview in admin

This commit is contained in:
Gabriel Augendre 2020-12-29 13:16:54 +01:00
parent 8f1ce6f7bd
commit 4e89c1798d
No known key found for this signature in database
GPG key ID: 1E693F4CE4AEE7B4
6 changed files with 160 additions and 15 deletions

View file

@ -30,6 +30,18 @@ class AdminUrlMixin:
) )
def format_article_content(content):
md = markdown.Markdown(
extensions=[
"extra",
CodeHiliteExtension(linenums=False, guess_lang=False),
LazyLoadingImageExtension(),
]
)
content = re.sub(r"(\s)#(\w+)", r"\1\#\2", content)
return md.convert(content)
class Article(AdminUrlMixin, models.Model): class Article(AdminUrlMixin, models.Model):
DRAFT = "draft" DRAFT = "draft"
PUBLISHED = "published" PUBLISHED = "published"
@ -85,16 +97,7 @@ class Article(AdminUrlMixin, models.Model):
@cached_property @cached_property
def get_formatted_content(self): def get_formatted_content(self):
md = markdown.Markdown( return format_article_content(self.content)
extensions=[
"extra",
CodeHiliteExtension(linenums=False, guess_lang=False),
LazyLoadingImageExtension(),
]
)
content = self.content
content = re.sub(r"(\s)#(\w+)", r"\1\#\2", content)
return md.convert(content)
def publish(self): def publish(self):
if not self.published_at: if not self.published_at:

View file

@ -0,0 +1,56 @@
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
function debounce(func, wait, immediate) {
var timeout;
return function () {
var context = this, args = arguments;
var later = function () {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
let preview = null;
window.onload = function () {
const previewButton = document.querySelector("input#_live_preview");
previewButton.addEventListener("click", event => {
event.preventDefault();
const params = "width=800,height=1000,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes";
if (preview !== null) {
preview.close();
}
preview = window.open("about:blank", "Preview", params);
const id = Number(window.location.pathname.match(/\d+/)[0]);
const loadPreview = debounce(function () {
const body = new FormData();
const articleContent = document.getElementById("id_content").value;
body.set("content", articleContent);
const csrfToken = document.querySelector("input[name=csrfmiddlewaretoken]").value;
body.set("csrfmiddlewaretoken", csrfToken);
fetch(`/api/render/${id}/`, {method: "POST", body: body})
.then(function (response) {
response.text().then(value => {
preview.document.querySelector("html").innerHTML = value
});
})
}, 500);
preview.onload = loadPreview;
const content = document.getElementById("id_content");
content.addEventListener("input", event => {
event.preventDefault();
loadPreview();
});
})
};
window.onbeforeunload = function () {
if (preview !== null) {
preview.close();
}
};

View file

@ -1,11 +1,15 @@
{% extends "admin/submit_line.html" %} {% extends "admin/submit_line.html" %}
{% load i18n admin_urls %} {% load static %}
{% block submit-row %} {% block submit-row %}
{{ block.super }} {{ block.super }}
{% if original.status != original.PUBLISHED %} {% if original.status != original.PUBLISHED %}
<input type="submit" value="Save & publish" name="_publish"> <input type="submit" value="Save and publish" name="_publish">
{% elif original.status != original.DRAFT %} {% elif original.status != original.DRAFT %}
<input type="submit" value="Save & unpublish" name="_unpublish"> <input type="submit" value="Save and unpublish" name="_unpublish">
{% endif %} {% endif %}
<input type="submit" value="Save & preview" name="_preview"> <input type="submit" value="Save and view" name="_preview">
{% if original %}
<input type="button" value="Live preview" id="_live_preview">
{% endif %}
<script type="text/javascript" src="{% static "live-preview.js" %}"></script>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,65 @@
import pytest
from django.test import Client
from django.urls import reverse
from articles.models import Article, format_article_content
@pytest.mark.django_db
def test_unauthenticated_render_redirects(published_article: Article, client: Client):
api_res = client.post(
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
data={"content": published_article.content},
)
assert api_res.status_code == 302
@pytest.mark.django_db
def test_render_article_same_content(published_article: Article, client: Client):
client.force_login(published_article.author)
api_res = client.post(
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
data={"content": published_article.content},
)
standard_res = client.get(
reverse("article-detail", kwargs={"slug": published_article.slug})
)
assert api_res.status_code == 200
assert standard_res.status_code == 200
# ignore an expected difference
api_content = api_res.content.decode("utf-8") # type: str
standard_content = standard_res.content.decode("utf-8")
api_content = api_content.replace(
"?next=/api/render/1/",
"?next=/some-article-slug/",
)
assert api_content == standard_content
@pytest.mark.django_db
def test_render_article_change_content(published_article: Article, client: Client):
client.force_login(published_article.author)
preview_content = "This is a different content **with strong emphasis**"
api_res = client.post(
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
data={"content": preview_content},
)
assert api_res.status_code == 200
api_content = api_res.content.decode("utf-8") # type: str
html_preview_content = format_article_content(preview_content)
assert html_preview_content in api_content
@pytest.mark.django_db
def test_render_article_doesnt_save(published_article, client: Client):
client.force_login(published_article.author)
original_content = published_article.content
preview_content = "This is a different content **with strong emphasis**"
api_res = client.post(
reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
data={"content": preview_content},
)
assert api_res.status_code == 200
published_article.refresh_from_db()
assert published_article.content == original_content

16
articles/views/api.py Normal file
View file

@ -0,0 +1,16 @@
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render
from django.views.decorators.http import require_POST
from articles.models import Article
@login_required
@require_POST
def render_article(request, article_pk):
template = "articles/article_detail.html"
article = Article.objects.get(pk=article_pk)
article.content = request.POST.get("content")
html = render(request, template, context={"article": article})
return HttpResponse(html)

View file

@ -18,7 +18,7 @@ from django.contrib import admin
from django.urls import path from django.urls import path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from articles.views import feeds, html from articles.views import api, feeds, html
from blog import settings from blog import settings
urlpatterns = [ urlpatterns = [
@ -32,6 +32,7 @@ urlpatterns = [
path("", html.ArticlesListView.as_view(), name="articles-list"), path("", html.ArticlesListView.as_view(), name="articles-list"),
path("drafts/", html.DraftsListView.as_view(), name="drafts-list"), path("drafts/", html.DraftsListView.as_view(), name="drafts-list"),
path("feed/", feeds.CompleteFeed(), name="complete-feed"), path("feed/", feeds.CompleteFeed(), name="complete-feed"),
path("api/render/<int:article_pk>/", api.render_article, name="api-render-article"),
path("<slug:slug>", html.ArticleDetailView.as_view(), name="article-detail-old"), path("<slug:slug>", html.ArticleDetailView.as_view(), name="article-detail-old"),
path("<slug:slug>/", html.ArticleDetailView.as_view(), name="article-detail"), path("<slug:slug>/", html.ArticleDetailView.as_view(), name="article-detail"),
] ]