1
Fork 0

Initial version of functionality.

This commit is contained in:
Adam Hill 2020-01-07 23:41:21 -05:00
parent 7460187502
commit 417ecd8464
14 changed files with 454 additions and 3 deletions

2
CHANGELOG.md Normal file
View file

@ -0,0 +1,2 @@
# 0.1.2
Initial release.

33
README.md Normal file
View 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/)

View file

@ -1,11 +1,13 @@
[tool.poetry]
name = "wagtail-draftail-snippet"
version = "0.1.0"
description = ""
version = "0.1.2"
description = "Associate RichTextBlock text content to a snippet model."
authors = ["Adam Hill <ahill@fool.com>"]
repository = "https://github.com/themotleyfool/wagtail-draftail-snippet"
readme = "README.md"
[tool.poetry.dependencies]
python = "^2.7"
python = "^3.6"
[tool.poetry.dev-dependencies]
pytest = "^3.0"

0
tests/__init__.py Normal file
View file

View file

@ -0,0 +1,5 @@
from wagtail_draftail_snippet import __version__
def test_version():
assert __version__ == '0.1.0'

View 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}},
}

View file

@ -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);
},
};

View file

@ -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,
});
})();

View file

@ -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>

View file

@ -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 %}

View 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")
]

View 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))

View 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"},
)

View 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"))]