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):
DRAFT = "draft"
PUBLISHED = "published"
@ -85,16 +97,7 @@ class Article(AdminUrlMixin, models.Model):
@cached_property
def get_formatted_content(self):
md = markdown.Markdown(
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)
return format_article_content(self.content)
def publish(self):
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" %}
{% load i18n admin_urls %}
{% load static %}
{% block submit-row %}
{{ block.super }}
{% 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 %}
<input type="submit" value="Save & unpublish" name="_unpublish">
<input type="submit" value="Save and unpublish" name="_unpublish">
{% 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 %}

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.views.generic import TemplateView
from articles.views import feeds, html
from articles.views import api, feeds, html
from blog import settings
urlpatterns = [
@ -32,6 +32,7 @@ urlpatterns = [
path("", html.ArticlesListView.as_view(), name="articles-list"),
path("drafts/", html.DraftsListView.as_view(), name="drafts-list"),
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"),
]