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:
parent
c49d6a7572
commit
211abd19bf
@ -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', {}))
|
||||
|
||||
|
@ -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 = []
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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>
|
||||
''')
|
||||
|
Loading…
Reference in New Issue
Block a user