Initial version of functionality.
This commit is contained in:
parent
7460187502
commit
417ecd8464
14 changed files with 454 additions and 3 deletions
2
CHANGELOG.md
Normal file
2
CHANGELOG.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# 0.1.2
|
||||||
|
Initial release.
|
33
README.md
Normal file
33
README.md
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# wagtail-draftail-snippet
|
||||||
|
|
||||||
|
Wagtail has support for adding numerous types of links to `RichTextBlock` content, there is not a way to tie a link to an arbitrary `snippet` model currently. `wagtail-draftail-snippet` provides a way to add a new button to the Draftail rich text editor, which creates an `href` from text for a specific `snippet` model.
|
||||||
|
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
1. Add `wagtail_draftail_snippet` to `INSTALLED_APPS` in Django settings
|
||||||
|
1. Add `"snippet"` to the `features` keyword list argument when instantiating a `RichTextBlock`, e.g. `paragraph = RichTextBlock(features=["bold", "italic", "h1", "h2", "h3", "snippet"])`
|
||||||
|
1. Create a frontend template to determine how the snippet model will be rendered. Frontend templates are required for a snippet to be selected and are discovered when they match a path like `{app_name}/{model_name}_snippet.html`. For example, if you have an `Affiliate` snippet model in `affiliates/models.py`, then a file in `affiliates/templates/affiliates/affiliate_snippet.html` would be required.
|
||||||
|
|
||||||
|
|
||||||
|
## Example use-case
|
||||||
|
|
||||||
|
Wagtail is used for a content site that will display articles that have affiliate links embedded inside the content. Affiliate links have a snippet data model to store information with a URL, start, and end dates; the urls need to be rendered in such a way that JavaScript can attach an event listener to their clicks for analytics.
|
||||||
|
|
||||||
|
When the content gets rendered, it uses the specific affiliate model to get the URL stored in the snippet model. If the affiliate's URL ever changes, the snippet can be changed in the Wagtail admin, and the all of the content will use the correct link when rendered.
|
||||||
|
|
||||||
|
An example frontend template in `affiliates/templates/affiliates/affiliate_snippet.html` could be the following.
|
||||||
|
```
|
||||||
|
<a href="{{ object.url }}" data-vars-action="content-cta" data-vars-label="{{ object.slug }}" rel="sponsored">
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Build library
|
||||||
|
|
||||||
|
1. `poetry build`
|
||||||
|
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
- [Parbhat Puri](https://github.com/Parbhat)
|
||||||
|
- [Adam Hill](https://github.com/adamghill/)
|
|
@ -1,11 +1,13 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "wagtail-draftail-snippet"
|
name = "wagtail-draftail-snippet"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
description = ""
|
description = "Associate RichTextBlock text content to a snippet model."
|
||||||
authors = ["Adam Hill <ahill@fool.com>"]
|
authors = ["Adam Hill <ahill@fool.com>"]
|
||||||
|
repository = "https://github.com/themotleyfool/wagtail-draftail-snippet"
|
||||||
|
readme = "README.md"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^2.7"
|
python = "^3.6"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^3.0"
|
pytest = "^3.0"
|
||||||
|
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
5
tests/test_wagtail_draftail_snippet.py
Normal file
5
tests/test_wagtail_draftail_snippet.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from wagtail_draftail_snippet import __version__
|
||||||
|
|
||||||
|
|
||||||
|
def test_version():
|
||||||
|
assert __version__ == '0.1.0'
|
84
wagtail_draftail_snippet/richtext.py
Normal file
84
wagtail_draftail_snippet/richtext.py
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
from django.apps import apps
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
from draftjs_exporter.dom import DOM
|
||||||
|
from wagtail.admin.rich_text.converters.html_to_contentstate import LinkElementHandler
|
||||||
|
from wagtail.core.rich_text import LinkHandler
|
||||||
|
|
||||||
|
from .utils import get_snippet_frontend_template
|
||||||
|
|
||||||
|
|
||||||
|
# Front-end conversion
|
||||||
|
class SnippetLinkHandler(LinkHandler):
|
||||||
|
identifier = "snippet"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, attrs):
|
||||||
|
model = apps.get_model(attrs["data-app-name"], attrs["data-model-name"])
|
||||||
|
return model.objects.get(id=attrs["id"])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_template(cls, attrs):
|
||||||
|
return get_snippet_frontend_template(
|
||||||
|
attrs["data-app-name"], attrs["data-model-name"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def expand_db_attributes(cls, attrs):
|
||||||
|
try:
|
||||||
|
snippet_obj = cls.get_instance(attrs)
|
||||||
|
template = cls.get_template(attrs)
|
||||||
|
return render_to_string(template, {"object": snippet_obj})
|
||||||
|
except Exception:
|
||||||
|
return "<a>"
|
||||||
|
|
||||||
|
|
||||||
|
# draft.js / contentstate conversion
|
||||||
|
def snippet_link_entity(props):
|
||||||
|
"""
|
||||||
|
Helper to construct elements of the form
|
||||||
|
<a id="1" linktype="snippet">snippet link</a>
|
||||||
|
when converting from contentstate data
|
||||||
|
"""
|
||||||
|
|
||||||
|
# props["children"] defaults to the string representation of the model if it's missing
|
||||||
|
selected_text = props["children"]
|
||||||
|
|
||||||
|
elem = DOM.create_element(
|
||||||
|
"a",
|
||||||
|
{
|
||||||
|
"linktype": "snippet",
|
||||||
|
"id": props.get("id"),
|
||||||
|
"data-string": props.get("string"),
|
||||||
|
"data-edit-link": props.get("edit_link"),
|
||||||
|
"data-app-name": props.get("app_name"),
|
||||||
|
"data-model-name": props.get("model_name"),
|
||||||
|
},
|
||||||
|
selected_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return elem
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetLinkElementHandler(LinkElementHandler):
|
||||||
|
"""
|
||||||
|
Rule for populating the attributes of a snippet link when converting from database representation
|
||||||
|
to contentstate
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_attribute_data(self, attrs):
|
||||||
|
return {
|
||||||
|
"id": attrs.get("id"),
|
||||||
|
"string": attrs.get("data-string"),
|
||||||
|
"edit_link": attrs.get("data-edit-link"),
|
||||||
|
"app_name": attrs.get("data-app-name"),
|
||||||
|
"model_name": attrs.get("data-model-name"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ContentstateSnippetLinkConversionRule = {
|
||||||
|
"from_database_format": {
|
||||||
|
'a[linktype="snippet"]': SnippetLinkElementHandler("SNIPPET")
|
||||||
|
},
|
||||||
|
"to_database_format": {"entity_decorators": {"SNIPPET": snippet_link_entity}},
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
SNIPPET_MODEL_CHOOSER_MODAL_ONLOAD_HANDLERS = {
|
||||||
|
'choose': function(modal, jsonData) {
|
||||||
|
function getSelectedModelMeta(context) {
|
||||||
|
$('a.snippet-model-choice', modal.body).on('click', function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
let modelMeta = {'appName': this.dataset.appName, 'modelName': this.dataset.modelName};
|
||||||
|
modal.respond('snippetModelChosen', modelMeta);
|
||||||
|
modal.close();
|
||||||
|
$(".modal-backdrop").remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedModelMeta(modal.body);
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,180 @@
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const React = window.React;
|
||||||
|
const Modifier = window.DraftJS.Modifier;
|
||||||
|
const AtomicBlockUtils = window.DraftJS.AtomicBlockUtils;
|
||||||
|
const RichUtils = window.DraftJS.RichUtils;
|
||||||
|
const EditorState = window.DraftJS.EditorState;
|
||||||
|
|
||||||
|
const TooltipEntity = window.draftail.TooltipEntity;
|
||||||
|
|
||||||
|
const $ = global.jQuery;
|
||||||
|
|
||||||
|
const getSnippetModelChooserConfig = () => {
|
||||||
|
let url;
|
||||||
|
let urlParams;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: global.chooserUrls.snippetModelChooser,
|
||||||
|
urlParams: {},
|
||||||
|
onload: global.SNIPPET_MODEL_CHOOSER_MODAL_ONLOAD_HANDLERS,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSnippetModelObjectChooserConfig = () => {
|
||||||
|
let url;
|
||||||
|
let urlParams;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: global.chooserUrls.snippetChooser.concat(window.snippetModelMeta.appName, '/', window.snippetModelMeta.modelName, '/'),
|
||||||
|
urlParams: {},
|
||||||
|
onload: global.SNIPPET_CHOOSER_MODAL_ONLOAD_HANDLERS,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterSnippetEntityData = (entityType, data) => {
|
||||||
|
return {
|
||||||
|
edit_link: data.edit_link,
|
||||||
|
string: data.string,
|
||||||
|
id: data.id,
|
||||||
|
app_name: window.snippetModelMeta.appName,
|
||||||
|
model_name: window.snippetModelMeta.modelName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interfaces with Wagtail's ModalWorkflow to open the chooser,
|
||||||
|
* and create new content in Draft.js based on the data.
|
||||||
|
*/
|
||||||
|
class SnippetModalWorkflowSource extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.onChosen = this.onChosen.bind(this);
|
||||||
|
this.onClose = this.onClose.bind(this);
|
||||||
|
this.onModelChosen = this.onModelChosen.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { onClose, entityType, entity, editorState } = this.props;
|
||||||
|
const { url, urlParams, onload } = getSnippetModelChooserConfig();
|
||||||
|
|
||||||
|
$(document.body).on('hidden.bs.modal', this.onClose);
|
||||||
|
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
this.model_workflow = global.ModalWorkflow({
|
||||||
|
url,
|
||||||
|
urlParams,
|
||||||
|
onload,
|
||||||
|
responses: {
|
||||||
|
snippetModelChosen: this.onModelChosen,
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
window.alert(global.wagtailConfig.STRINGS.SERVER_ERROR);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.model_workflow = null;
|
||||||
|
this.workflow = null;
|
||||||
|
|
||||||
|
$(document.body).off('hidden.bs.modal', this.onClose);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModelChosen(snippetModelMeta) {
|
||||||
|
window.snippetModelMeta = snippetModelMeta;
|
||||||
|
const { url, urlParams, onload } = getSnippetModelObjectChooserConfig();
|
||||||
|
|
||||||
|
this.model_workflow.close();
|
||||||
|
|
||||||
|
// eslint-disable-next-line new-cap
|
||||||
|
this.workflow = global.ModalWorkflow({
|
||||||
|
url,
|
||||||
|
urlParams,
|
||||||
|
onload,
|
||||||
|
responses: {
|
||||||
|
snippetChosen: this.onChosen,
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
window.alert(global.wagtailConfig.STRINGS.SERVER_ERROR);
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onChosen(data) {
|
||||||
|
const { editorState, entityType, onComplete } = this.props;
|
||||||
|
const content = editorState.getCurrentContent();
|
||||||
|
const selection = editorState.getSelection();
|
||||||
|
|
||||||
|
const entityData = filterSnippetEntityData(entityType, data);
|
||||||
|
const mutability = 'MUTABLE';
|
||||||
|
const contentWithEntity = content.createEntity(entityType.type, mutability, entityData);
|
||||||
|
const entityKey = contentWithEntity.getLastCreatedEntityKey();
|
||||||
|
|
||||||
|
let nextState;
|
||||||
|
|
||||||
|
if (entityType.block) {
|
||||||
|
// Only supports adding entities at the moment, not editing existing ones.
|
||||||
|
// See https://github.com/springload/draftail/blob/cdc8988fe2e3ac32374317f535a5338ab97e8637/examples/sources/ImageSource.js#L44-L62.
|
||||||
|
// See https://github.com/springload/draftail/blob/cdc8988fe2e3ac32374317f535a5338ab97e8637/examples/sources/EmbedSource.js#L64-L91
|
||||||
|
nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' ');
|
||||||
|
} else {
|
||||||
|
// Replace text if the chooser demands it, or if there is no selected text in the first place.
|
||||||
|
const shouldReplaceText = data.prefer_this_title_as_link_text || selection.isCollapsed();
|
||||||
|
|
||||||
|
if (shouldReplaceText) {
|
||||||
|
// If there is a title attribute, use it. Otherwise we inject the URL.
|
||||||
|
const newText = data.string;
|
||||||
|
const newContent = Modifier.replaceText(content, selection, newText, null, entityKey);
|
||||||
|
nextState = EditorState.push(editorState, newContent, 'insert-characters');
|
||||||
|
} else {
|
||||||
|
nextState = RichUtils.toggleLink(editorState, selection, entityKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IE11 crashes when rendering the new entity in contenteditable if the modal is still open.
|
||||||
|
// Other browsers do not mind. This is probably a focus management problem.
|
||||||
|
// From the user's perspective, this is all happening too fast to notice either way.
|
||||||
|
if (this.workflow) {
|
||||||
|
this.workflow.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onComplete(nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(e) {
|
||||||
|
const { onClose } = this.props;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SnippetDecorator = (props) => {
|
||||||
|
const { entityKey, contentState } = props;
|
||||||
|
const data = contentState.getEntity(entityKey).getData();
|
||||||
|
|
||||||
|
return React.createElement('a', {
|
||||||
|
role: 'button',
|
||||||
|
onMouseUp: () => {
|
||||||
|
window.open(`${data.edit_link}`);
|
||||||
|
},
|
||||||
|
}, props.children);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.draftail.registerPlugin({
|
||||||
|
type: 'SNIPPET',
|
||||||
|
source: SnippetModalWorkflowSource,
|
||||||
|
decorator: SnippetDecorator,
|
||||||
|
});
|
||||||
|
})();
|
|
@ -0,0 +1,10 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% trans "Choose" as choose_str %}
|
||||||
|
{% include "wagtailadmin/shared/header.html" with title=choose_str subtitle="Snippet Model" icon="snippet" %}
|
||||||
|
|
||||||
|
<div class="nice-padding">
|
||||||
|
<div id="search-results" class="listing snippets">
|
||||||
|
{% include "wagtail_draftail_snippet/results_snippet_model.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% load i18n wagtailadmin_tags %}
|
||||||
|
{% if snippet_model_opts %}
|
||||||
|
|
||||||
|
<table class="listing">
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
<col width="16%" />
|
||||||
|
<thead>
|
||||||
|
<tr class="table-headers">
|
||||||
|
<th>{% trans "Snippets" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for model_opts in snippet_model_opts %}
|
||||||
|
<tr>
|
||||||
|
<td class="title">
|
||||||
|
<div class="title-wrapper"><a class="snippet-model-choice" data-app-name="{{ model_opts.app_label }}" data-model-name="{{ model_opts.model_name }}" href="#">{{ model_opts.verbose_name_plural|capfirst }}</a></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p>{% blocktrans %}No snippet model configured to use in Rich Text editor.{% endblocktrans %}</p>
|
||||||
|
{% endif %}
|
10
wagtail_draftail_snippet/urls.py
Normal file
10
wagtail_draftail_snippet/urls.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from .views import choose_snippet_model
|
||||||
|
|
||||||
|
|
||||||
|
app_name = "wagtaildraftailsnippet"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
url(r"^choose-model/$", choose_snippet_model, name="choose_snippet_model")
|
||||||
|
]
|
5
wagtail_draftail_snippet/utils.py
Normal file
5
wagtail_draftail_snippet/utils.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from wagtail.core.utils import camelcase_to_underscore
|
||||||
|
|
||||||
|
|
||||||
|
def get_snippet_frontend_template(app_name, model_name):
|
||||||
|
return "%s/%s_snippet.html" % (app_name, camelcase_to_underscore(model_name))
|
29
wagtail_draftail_snippet/views.py
Normal file
29
wagtail_draftail_snippet/views.py
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
from django.template.loader import TemplateDoesNotExist, get_template
|
||||||
|
|
||||||
|
from wagtail.admin.modal_workflow import render_modal_workflow
|
||||||
|
from wagtail.snippets.models import get_snippet_models
|
||||||
|
|
||||||
|
from .utils import get_snippet_frontend_template
|
||||||
|
|
||||||
|
|
||||||
|
def choose_snippet_model(request):
|
||||||
|
snippet_model_opts = []
|
||||||
|
|
||||||
|
# Only display those snippet models which have snippet frontend template
|
||||||
|
for snippet_model in get_snippet_models():
|
||||||
|
snippet_frontend_template = get_snippet_frontend_template(
|
||||||
|
snippet_model._meta.app_label, snippet_model._meta.model_name
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
get_template(snippet_frontend_template)
|
||||||
|
snippet_model_opts.append(snippet_model._meta)
|
||||||
|
except TemplateDoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_modal_workflow(
|
||||||
|
request,
|
||||||
|
"wagtail_draftail_snippet/choose_snippet_model.html",
|
||||||
|
None,
|
||||||
|
{"snippet_model_opts": snippet_model_opts},
|
||||||
|
json_data={"step": "choose"},
|
||||||
|
)
|
50
wagtail_draftail_snippet/wagtail_hooks.py
Normal file
50
wagtail_draftail_snippet/wagtail_hooks.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
from django.conf.urls import include, url
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.translation import ugettext
|
||||||
|
|
||||||
|
import wagtail.admin.rich_text.editors.draftail.features as draftail_features
|
||||||
|
from wagtail.core import hooks
|
||||||
|
|
||||||
|
from . import urls
|
||||||
|
from .richtext import ContentstateSnippetLinkConversionRule, SnippetLinkHandler
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_rich_text_features")
|
||||||
|
def register_snippet_feature(features):
|
||||||
|
feature_name = "snippet"
|
||||||
|
type_ = "SNIPPET"
|
||||||
|
|
||||||
|
features.register_link_type(SnippetLinkHandler)
|
||||||
|
|
||||||
|
features.register_editor_plugin(
|
||||||
|
"draftail",
|
||||||
|
feature_name,
|
||||||
|
draftail_features.EntityFeature(
|
||||||
|
{"type": type_, "icon": "snippet", "description": ugettext("Snippet")},
|
||||||
|
js=[
|
||||||
|
"wagtailsnippets/js/snippet-chooser-modal.js",
|
||||||
|
"wagtail_draftail_snippet/js/snippet-model-chooser-modal.js",
|
||||||
|
"wagtail_draftail_snippet/js/wagtail_draftail_snippet.js",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
features.register_converter_rule(
|
||||||
|
"contentstate", feature_name, ContentstateSnippetLinkConversionRule
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("insert_editor_js")
|
||||||
|
def editor_js():
|
||||||
|
return format_html(
|
||||||
|
"""
|
||||||
|
<script>window.chooserUrls.snippetModelChooser = '{0}';</script>
|
||||||
|
""",
|
||||||
|
reverse("wagtaildraftailsnippet:choose_snippet_model"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@hooks.register("register_admin_urls")
|
||||||
|
def register_admin_urls():
|
||||||
|
return [url(r"^snippets/", include(urls, namespace="wagtaildraftailsnippet"))]
|
Reference in a new issue