diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c9740e1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +# 0.1.2 +Initial release. diff --git a/README.md b/README.md new file mode 100644 index 0000000..24824ca --- /dev/null +++ b/README.md @@ -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. +``` + +``` + + +## Build library + +1. `poetry build` + + +## Contributors + +- [Parbhat Puri](https://github.com/Parbhat) +- [Adam Hill](https://github.com/adamghill/) diff --git a/pyproject.toml b/pyproject.toml index 9911cbf..8e8bd93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] +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" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_wagtail_draftail_snippet.py b/tests/test_wagtail_draftail_snippet.py new file mode 100644 index 0000000..7e66b31 --- /dev/null +++ b/tests/test_wagtail_draftail_snippet.py @@ -0,0 +1,5 @@ +from wagtail_draftail_snippet import __version__ + + +def test_version(): + assert __version__ == '0.1.0' diff --git a/wagtail_draftail_snippet/richtext.py b/wagtail_draftail_snippet/richtext.py new file mode 100644 index 0000000..2f528ff --- /dev/null +++ b/wagtail_draftail_snippet/richtext.py @@ -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 "" + + +# draft.js / contentstate conversion +def snippet_link_entity(props): + """ + Helper to construct elements of the form + snippet link + 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}}, +} diff --git a/wagtail_draftail_snippet/static/wagtail_draftail_snippet/js/snippet-model-chooser-modal.js b/wagtail_draftail_snippet/static/wagtail_draftail_snippet/js/snippet-model-chooser-modal.js new file mode 100644 index 0000000..640be2a --- /dev/null +++ b/wagtail_draftail_snippet/static/wagtail_draftail_snippet/js/snippet-model-chooser-modal.js @@ -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); + }, +}; diff --git a/wagtail_draftail_snippet/static/wagtail_draftail_snippet/js/wagtail_draftail_snippet.js b/wagtail_draftail_snippet/static/wagtail_draftail_snippet/js/wagtail_draftail_snippet.js new file mode 100644 index 0000000..64c4488 --- /dev/null +++ b/wagtail_draftail_snippet/static/wagtail_draftail_snippet/js/wagtail_draftail_snippet.js @@ -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, + }); +})(); diff --git a/wagtail_draftail_snippet/templates/wagtail_draftail_snippet/choose_snippet_model.html b/wagtail_draftail_snippet/templates/wagtail_draftail_snippet/choose_snippet_model.html new file mode 100644 index 0000000..124ba56 --- /dev/null +++ b/wagtail_draftail_snippet/templates/wagtail_draftail_snippet/choose_snippet_model.html @@ -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" %} + +
+
+ {% include "wagtail_draftail_snippet/results_snippet_model.html" %} +
+
diff --git a/wagtail_draftail_snippet/templates/wagtail_draftail_snippet/results_snippet_model.html b/wagtail_draftail_snippet/templates/wagtail_draftail_snippet/results_snippet_model.html new file mode 100644 index 0000000..60abcb2 --- /dev/null +++ b/wagtail_draftail_snippet/templates/wagtail_draftail_snippet/results_snippet_model.html @@ -0,0 +1,26 @@ +{% load i18n wagtailadmin_tags %} +{% if snippet_model_opts %} + + + + + + + + + + + + {% for model_opts in snippet_model_opts %} + + + + {% endfor %} + +
{% trans "Snippets" %}
+ +
+ +{% else %} +

{% blocktrans %}No snippet model configured to use in Rich Text editor.{% endblocktrans %}

+{% endif %} diff --git a/wagtail_draftail_snippet/urls.py b/wagtail_draftail_snippet/urls.py new file mode 100644 index 0000000..326fc28 --- /dev/null +++ b/wagtail_draftail_snippet/urls.py @@ -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") +] diff --git a/wagtail_draftail_snippet/utils.py b/wagtail_draftail_snippet/utils.py new file mode 100644 index 0000000..fa369c4 --- /dev/null +++ b/wagtail_draftail_snippet/utils.py @@ -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)) diff --git a/wagtail_draftail_snippet/views.py b/wagtail_draftail_snippet/views.py new file mode 100644 index 0000000..827306d --- /dev/null +++ b/wagtail_draftail_snippet/views.py @@ -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"}, + ) diff --git a/wagtail_draftail_snippet/wagtail_hooks.py b/wagtail_draftail_snippet/wagtail_hooks.py new file mode 100644 index 0000000..0a001cf --- /dev/null +++ b/wagtail_draftail_snippet/wagtail_hooks.py @@ -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( + """ + + """, + reverse("wagtaildraftailsnippet:choose_snippet_model"), + ) + + +@hooks.register("register_admin_urls") +def register_admin_urls(): + return [url(r"^snippets/", include(urls, namespace="wagtaildraftailsnippet"))]