diff --git a/wagtail/admin/rich_text/converters/contentstate.py b/wagtail/admin/rich_text/converters/contentstate.py index 4853d0d490..2032dd48b7 100644 --- a/wagtail/admin/rich_text/converters/contentstate.py +++ b/wagtail/admin/rich_text/converters/contentstate.py @@ -6,7 +6,8 @@ from draftjs_exporter.defaults import render_children from draftjs_exporter.dom import DOM from draftjs_exporter.html import HTML as HTMLExporter -from wagtail.admin.rich_text.converters.html_to_contentstate import HtmlToContentStateHandler +from wagtail.admin.rich_text.converters.html_to_contentstate import ( + BLOCK_KEY_NAME, HtmlToContentStateHandler) from wagtail.core.rich_text import features as feature_registry from wagtail.core.whitelist import check_url @@ -46,6 +47,38 @@ def entity_fallback(props): return None +def persist_key_for_block(config): + # For any block level element config for draft js exporter, return a config that retains the + # block key in a data attribute + if isinstance(config, dict): + # Wrapper elements don't retain a key - we can keep them in the config as-is + new_config = {key: value for key, value in config.items() if key in {'wrapper', 'wrapper_props'}} + element = config.get('element') + element_props = config.get('props', {}) + else: + # The config is either a simple string element name, or a function + new_config = {} + element_props = {} + element = config + + def element_with_uuid(props): + added_props = {BLOCK_KEY_NAME: props['block'].get('key')} + try: + # See if the element is a function - if so, we can only run it and modify its return value to include the data attribute + elt = element(props) + if elt is not None: + elt.attr.update(added_props) + return elt + except TypeError: + # Otherwise we can do the normal process of creating a DOM element with the right element type + # and simply adding the data attribute to its props + added_props.update(element_props) + return DOM.create_element(element, added_props, props['children']) + + new_config['element'] = element_with_uuid + return new_config + + class ContentstateConverter(): def __init__(self, features=None): self.features = features @@ -53,7 +86,7 @@ class ContentstateConverter(): exporter_config = { 'block_map': { - 'unstyled': 'p', + 'unstyled': persist_key_for_block('p'), 'atomic': render_children, 'fallback': block_fallback, }, @@ -74,7 +107,7 @@ class ContentstateConverter(): rule = feature_registry.get_converter_rule('contentstate', feature) if rule is not None: feature_config = rule['to_database_format'] - exporter_config['block_map'].update(feature_config.get('block_map', {})) + exporter_config['block_map'].update({block_type: persist_key_for_block(config) for block_type, config in feature_config.get('block_map', {}).items()}) exporter_config['style_map'].update(feature_config.get('style_map', {})) exporter_config['entity_decorators'].update(feature_config.get('entity_decorators', {})) diff --git a/wagtail/admin/rich_text/converters/contentstate_models.py b/wagtail/admin/rich_text/converters/contentstate_models.py index 8052187b3e..020a1b8f35 100644 --- a/wagtail/admin/rich_text/converters/contentstate_models.py +++ b/wagtail/admin/rich_text/converters/contentstate_models.py @@ -7,11 +7,11 @@ ALPHANUM = string.ascii_lowercase + string.digits class Block: - def __init__(self, typ, depth=0): + def __init__(self, typ, depth=0, key=None): self.type = typ self.depth = depth self.text = "" - self.key = ''.join(random.choice(ALPHANUM) for _ in range(5)) + self.key = key if key else ''.join(random.choice(ALPHANUM) for _ in range(5)) self.inline_style_ranges = [] self.entity_ranges = [] diff --git a/wagtail/admin/rich_text/converters/html_to_contentstate.py b/wagtail/admin/rich_text/converters/html_to_contentstate.py index b50ebd9c11..8dff0a2fbb 100644 --- a/wagtail/admin/rich_text/converters/html_to_contentstate.py +++ b/wagtail/admin/rich_text/converters/html_to_contentstate.py @@ -17,6 +17,9 @@ FORCE_WHITESPACE = 2 # match one or more consecutive normal spaces, new-lines, tabs and form-feeds WHITESPACE_RE = re.compile(r'[ \t\n\f\r]+') +# the attribute name to persist the Draftail block key between FE and db +BLOCK_KEY_NAME = 'data-block-key' + class HandlerState: def __init__(self): @@ -97,7 +100,7 @@ class BlockElementHandler: self.block_type = block_type def create_block(self, name, attrs, state, contentstate): - return Block(self.block_type, depth=state.list_depth) + return Block(self.block_type, depth=state.list_depth, key=attrs.get(BLOCK_KEY_NAME)) def handle_starttag(self, name, attrs, state, contentstate): attr_dict = dict(attrs) # convert attrs from list of (name, value) tuples to a dict @@ -121,7 +124,7 @@ class ListItemElementHandler(BlockElementHandler): def create_block(self, name, attrs, state, contentstate): assert state.list_item_type is not None, "%s element found outside of an enclosing list element" % name - return Block(state.list_item_type, depth=state.list_depth) + return Block(state.list_item_type, depth=state.list_depth, key=attrs.get(BLOCK_KEY_NAME)) class InlineStyleElementHandler: diff --git a/wagtail/admin/tests/test_contentstate.py b/wagtail/admin/tests/test_contentstate.py index d618c42024..f46cd83066 100644 --- a/wagtail/admin/tests/test_contentstate.py +++ b/wagtail/admin/tests/test_contentstate.py @@ -3,13 +3,16 @@ import json from unittest.mock import patch from django.test import TestCase +from draftjs_exporter.dom import DOM +from draftjs_exporter.html import HTML as HTMLExporter -from wagtail.admin.rich_text.converters.contentstate import ContentstateConverter +from wagtail.admin.rich_text.converters.contentstate import ( + ContentstateConverter, persist_key_for_block) from wagtail.embeds.models import Embed -def content_state_equal(v1, v2): - "Test whether two contentState structures are equal, ignoring 'key' properties" +def content_state_equal(v1, v2, match_keys=False): + "Test whether two contentState structures are equal, ignoring 'key' properties if match_keys=False" if type(v1) != type(v2): return False @@ -17,14 +20,14 @@ def content_state_equal(v1, v2): if set(v1.keys()) != set(v2.keys()): return False return all( - k == 'key' or content_state_equal(v, v2[k]) + (k == 'key' and not match_keys) or content_state_equal(v, v2[k], match_keys=match_keys) for k, v in v1.items() ) elif isinstance(v1, list): if len(v1) != len(v2): return False return all( - content_state_equal(a, b) for a, b in zip(v1, v2) + content_state_equal(a, b, match_keys=match_keys) for a, b in zip(v1, v2) ) else: return v1 == v2 @@ -33,25 +36,25 @@ def content_state_equal(v1, v2): class TestHtmlToContentState(TestCase): fixtures = ['test.json'] - def assertContentStateEqual(self, v1, v2): - "Assert that two contentState structures are equal, ignoring 'key' properties" - self.assertTrue(content_state_equal(v1, v2), "%r does not match %r" % (v1, v2)) + def assertContentStateEqual(self, v1, v2, match_keys=False): + "Assert that two contentState structures are equal, ignoring 'key' properties if match_keys is False" + self.assertTrue(content_state_equal(v1, v2, match_keys=match_keys), "%r does not match %r" % (v1, v2)) def test_paragraphs(self): converter = ContentstateConverter(features=[]) result = json.loads(converter.from_database_format( ''' -
Hello world!
-Goodbye world!
+Hello world!
+Goodbye world!
''' )) self.assertContentStateEqual(result, { 'entityMap': {}, 'blocks': [ {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, - {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, + {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00001', 'entityRanges': []}, ] - }) + }, match_keys=True) def test_unknown_block_becomes_paragraph(self): converter = ContentstateConverter(features=[]) @@ -186,10 +189,10 @@ class TestHtmlToContentState(TestCase): converter = ContentstateConverter(features=['h1', 'ol', 'bold', 'italic']) result = json.loads(converter.from_database_format( ''' -an external link
') + + def test_paragraphs_retain_keys(self): + converter = ContentstateConverter(features=[]) + contentState = json.dumps({ + 'entityMap': {}, + 'blocks': [ + {'inlineStyleRanges': [], 'text': 'Hello world!', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []}, + {'inlineStyleRanges': [], 'text': 'Goodbye world!', 'depth': 0, 'type': 'unstyled', 'key': '00001', 'entityRanges': []}, + ] + }) + result = converter.to_database_format(contentState) + self.assertHTMLEqual(result, ''' +Hello world!
+Goodbye world!
+ ''') + + def test_wrapped_block_retains_key(self): + # Test a block which uses a wrapper correctly receives the key defined on the inner element + converter = ContentstateConverter(features=['h1', 'ol', 'bold', 'italic']) + result = converter.to_database_format(json.dumps({ + 'entityMap': {}, + 'blocks': [ + {'inlineStyleRanges': [], 'text': 'The rules of Fight Club', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []}, + {'inlineStyleRanges': [], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00001', 'entityRanges': []}, + { + 'inlineStyleRanges': [], + 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00002', 'entityRanges': [] + }, + ] + })) + self.assertHTMLEqual(result, ''' +Hello world!
+Goodbye world!
+ ''')