Add live preview in admin
This commit is contained in:
parent
8f1ce6f7bd
commit
4e89c1798d
6 changed files with 160 additions and 15 deletions
|
@ -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:
|
||||
|
|
56
articles/static/live-preview.js
Normal file
56
articles/static/live-preview.js
Normal 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();
|
||||
}
|
||||
};
|
|
@ -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 %}
|
||||
|
|
65
articles/tests/test_api_views.py
Normal file
65
articles/tests/test_api_views.py
Normal 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
16
articles/views/api.py
Normal 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)
|
|
@ -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"),
|
||||
]
|
||||
|
|
Reference in a new issue