0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-12-01 11:41:20 +01:00

Persist block key between Draftail blocks and database html. This will allow inline comment positions to be stable between revisions

This commit is contained in:
jacobtoppm 2021-03-05 17:28:27 +00:00 committed by Matt Westcott
parent c49d6a7572
commit 211abd19bf
4 changed files with 139 additions and 37 deletions

View File

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

View File

@ -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 = []

View File

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

View File

@ -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(
'''
<p>Hello world!</p>
<p>Goodbye world!</p>
<p data-block-key='00000'>Hello world!</p>
<p data-block-key='00001'>Goodbye world!</p>
'''
))
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(
'''
<h1>The rules of Fight Club</h1>
<h1 data-block-key='00000'>The rules of Fight Club</h1>
<ol>
<li>You do not talk about Fight Club.</li>
<li>You <b>do <em>not</em> talk</b> about Fight Club.</li>
<li data-block-key='00001'>You do not talk about Fight Club.</li>
<li data-block-key='00002'>You <b>do <em>not</em> talk</b> about Fight Club.</li>
</ol>
'''
))
@ -197,31 +200,31 @@ class TestHtmlToContentState(TestCase):
'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': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00001', 'entityRanges': []},
{
'inlineStyleRanges': [
{'offset': 4, 'length': 11, 'style': 'BOLD'}, {'offset': 7, 'length': 3, 'style': 'ITALIC'}
],
'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00000', 'entityRanges': []
'text': 'You do not talk about Fight Club.', 'depth': 0, 'type': 'ordered-list-item', 'key': '00002', 'entityRanges': []
},
]
})
}, match_keys=True)
def test_nested_list(self):
converter = ContentstateConverter(features=['h1', 'ul'])
result = json.loads(converter.from_database_format(
'''
<h1>Shopping list</h1>
<h1 data-block-key='00000'>Shopping list</h1>
<ul>
<li>Milk</li>
<li>
<li data-block-key='00001'>Milk</li>
<li data-block-key='00002'>
Flour
<ul>
<li>Plain</li>
<li>Self-raising</li>
<li data-block-key='00003'>Plain</li>
<li data-block-key='00004'>Self-raising</li>
</ul>
</li>
<li>Eggs</li>
<li data-block-key='00005'>Eggs</li>
</ul>
'''
))
@ -229,13 +232,13 @@ class TestHtmlToContentState(TestCase):
'entityMap': {},
'blocks': [
{'inlineStyleRanges': [], 'text': 'Shopping list', 'depth': 0, 'type': 'header-one', 'key': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Flour', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Plain', 'depth': 1, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Self-raising', 'depth': 1, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00000', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Milk', 'depth': 0, 'type': 'unordered-list-item', 'key': '00001', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Flour', 'depth': 0, 'type': 'unordered-list-item', 'key': '00002', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Plain', 'depth': 1, 'type': 'unordered-list-item', 'key': '00003', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Self-raising', 'depth': 1, 'type': 'unordered-list-item', 'key': '00004', 'entityRanges': []},
{'inlineStyleRanges': [], 'text': 'Eggs', 'depth': 0, 'type': 'unordered-list-item', 'key': '00005', 'entityRanges': []},
]
})
}, match_keys=True)
def test_external_link(self):
converter = ContentstateConverter(features=['link'])
@ -878,3 +881,66 @@ class TestContentStateToHtml(TestCase):
result = converter.to_database_format(contentstate_json)
self.assertEqual(result, '<p>an <a>external</a> link</p>')
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, '''
<p data-block-key='00000'>Hello world!</p>
<p data-block-key='00001'>Goodbye world!</p>
''')
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, '''
<h1 data-block-key='00000'>The rules of Fight Club</h1>
<ol>
<li data-block-key='00001'>You do not talk about Fight Club.</li>
<li data-block-key='00002'>You do not talk about Fight Club.</li>
</ol>
''')
def test_wrap_block_function(self):
# Draft JS exporter's block_map config can also contain a function to handle a particular block
# Test that persist_key_for_block still works with such a function, making the resultant conversion
# keep the same block key between html and contentstate
exporter_config = {
'block_map': {
'unstyled': persist_key_for_block(lambda props: DOM.create_element('p', {}, props['children'])),
},
'style_map': {},
'entity_decorators': {},
'composite_decorators': [],
'engine': DOM.STRING,
}
contentState = {
'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 = HTMLExporter(exporter_config).render(contentState)
self.assertHTMLEqual(result, '''
<p data-block-key='00000'>Hello world!</p>
<p data-block-key='00001'>Goodbye world!</p>
''')