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):
|
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:
|
||||||
|
|
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" %}
|
{% 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 %}
|
||||||
|
|
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.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"),
|
||||||
]
|
]
|
||||||
|
|
Reference in a new issue