diff --git a/client/src/components/StreamField/blocks/StaticBlock.js b/client/src/components/StreamField/blocks/StaticBlock.js new file mode 100644 index 0000000000..c9fd290ed1 --- /dev/null +++ b/client/src/components/StreamField/blocks/StaticBlock.js @@ -0,0 +1,46 @@ +/* global $ */ + +import { escapeHtml as h } from '../../../utils/text'; + +export class StaticBlock { + constructor(blockDef, placeholder) { + this.blockDef = blockDef; + + const element = document.createElement('div'); + + if (this.blockDef.meta.html) { + element.innerHTML = this.blockDef.meta.html; + } else { + element.innerHTML = h(this.blockDef.meta.text); + } + + placeholder.replaceWith(element); + } + + // eslint-disable-next-line no-unused-vars + setState(_state) {} + + // eslint-disable-next-line no-unused-vars + setError(_errorList) {} + + getState() { + return null; + } + + getValue() { + return null; + } + + focus() {} +} + +export class StaticBlockDefinition { + constructor(name, meta) { + this.name = name; + this.meta = meta; + } + + render(placeholder) { + return new StaticBlock(this, placeholder); + } +} diff --git a/client/src/components/StreamField/blocks/StaticBlock.test.js b/client/src/components/StreamField/blocks/StaticBlock.test.js new file mode 100644 index 0000000000..a0429dde0d --- /dev/null +++ b/client/src/components/StreamField/blocks/StaticBlock.test.js @@ -0,0 +1,90 @@ +/* eslint-disable no-unused-vars */ + +import { StaticBlockDefinition } from './StaticBlock'; + +import $ from 'jquery'; +window.$ = $; + +describe('telepath: wagtail.blocks.StaticBlock', () => { + let boundBlock; + + beforeEach(() => { + // Define a test block + const blockDef = new StaticBlockDefinition( + 'test_field', + { + text: 'The admin text', + icon: 'icon', + label: 'The label', + } + ); + + // Render it + document.body.innerHTML = '
'; + boundBlock = blockDef.render($('#placeholder')); + }); + + test('it renders correctly', () => { + expect(document.body.innerHTML).toMatchSnapshot(); + }); +}); + +describe('telepath: wagtail.blocks.StaticBlock HTML escaping', () => { + let boundBlock; + + beforeEach(() => { + window.somethingBad = jest.fn(); + + // Define a test block + const blockDef = new StaticBlockDefinition( + 'test_field', + { + text: 'The admin text ', + icon: 'icon', + label: 'The label', + } + ); + + // Render it + document.body.innerHTML = '
'; + boundBlock = blockDef.render($('#placeholder')); + }); + + test('it renders correctly', () => { + expect(document.body.innerHTML).toMatchSnapshot(); + }); + + test('javascript cant execute', () => { + expect(window.somethingBad.mock.calls.length).toBe(0); + }); +}); + +describe('telepath: wagtail.blocks.StaticBlock allows safe HTML', () => { + let boundBlock; + + beforeEach(() => { + window.somethingBad = jest.fn(); + + // Define a test block + const blockDef = new StaticBlockDefinition( + 'test_field', + { + html: 'The admin text ', + icon: 'icon', + label: 'The label', + } + ); + + // Render it + document.body.innerHTML = '
'; + boundBlock = blockDef.render($('#placeholder')); + }); + + test('it renders correctly', () => { + expect(document.body.innerHTML).toMatchSnapshot(); + }); + + test('javascript can execute', () => { + expect(window.somethingBad.mock.calls.length).toBe(1); + }); +}); diff --git a/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap b/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap new file mode 100644 index 0000000000..a25477aedd --- /dev/null +++ b/client/src/components/StreamField/blocks/__snapshots__/StaticBlock.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`telepath: wagtail.blocks.StaticBlock HTML escaping it renders correctly 1`] = `"
The admin text <script>somethingBad();</script>
"`; + +exports[`telepath: wagtail.blocks.StaticBlock allows safe HTML it renders correctly 1`] = `"
The admin text
"`; + +exports[`telepath: wagtail.blocks.StaticBlock it renders correctly 1`] = `"
The admin text
"`; diff --git a/client/src/entrypoints/admin/telepath/blocks.js b/client/src/entrypoints/admin/telepath/blocks.js index b3018dca95..a0bde9960c 100644 --- a/client/src/entrypoints/admin/telepath/blocks.js +++ b/client/src/entrypoints/admin/telepath/blocks.js @@ -3,6 +3,7 @@ /* global $ */ import { FieldBlockDefinition } from '../../../components/StreamField/blocks/FieldBlock'; +import { StaticBlockDefinition } from '../../../components/StreamField/blocks/StaticBlock'; import { StructBlockDefinition, StructBlockValidationError } from '../../../components/StreamField/blocks/StructBlock'; import { ListBlockDefinition, ListBlockValidationError } from '../../../components/StreamField/blocks/ListBlock'; import { StreamBlockDefinition, StreamBlockValidationError } from '../../../components/StreamField/blocks/StreamBlock'; @@ -32,6 +33,7 @@ function initBlockWidget(id) { window.initBlockWidget = initBlockWidget; window.telepath.register('wagtail.blocks.FieldBlock', FieldBlockDefinition); +window.telepath.register('wagtail.blocks.StaticBlock', StaticBlockDefinition); window.telepath.register('wagtail.blocks.StructBlock', StructBlockDefinition); window.telepath.register('wagtail.blocks.StructBlockValidationError', StructBlockValidationError); window.telepath.register('wagtail.blocks.ListBlock', ListBlockDefinition); diff --git a/wagtail/core/blocks/static_block.py b/wagtail/core/blocks/static_block.py index c4a150f653..4f959bb7ae 100644 --- a/wagtail/core/blocks/static_block.py +++ b/wagtail/core/blocks/static_block.py @@ -1,3 +1,9 @@ +from django.utils.safestring import SafeString +from django.utils.translation import gettext as _ + +from wagtail.admin.staticfiles import versioned_static +from wagtail.core.telepath import Adapter, register + from .base import Block @@ -8,9 +14,45 @@ class StaticBlock(Block): """ A block that just 'exists' and has no fields. """ + def get_admin_text(self): + if self.meta.admin_text is None: + if self.label: + return _('%(label)s: this block has no options.') % {'label': self.label} + else: + return _('This block has no options.') + + return self.meta.admin_text + def value_from_datadict(self, data, files, prefix): return None class Meta: admin_text = None default = None + + +class StaticBlockAdapter(Adapter): + js_constructor = 'wagtail.blocks.StaticBlock' + + def js_args(self, block): + admin_text = block.get_admin_text() + + if isinstance(admin_text, SafeString): + text_or_html = 'html' + else: + text_or_html = 'text' + + return [ + block.name, + { + text_or_html: admin_text, + 'icon': block.meta.icon, + 'label': block.label, + }, + ] + + class Media: + js = [versioned_static('wagtailadmin/js/telepath/blocks.js')] + + +register(StaticBlockAdapter(), StaticBlock) diff --git a/wagtail/core/tests/test_blocks.py b/wagtail/core/tests/test_blocks.py index b1a5611e6d..617a0b1a75 100644 --- a/wagtail/core/tests/test_blocks.py +++ b/wagtail/core/tests/test_blocks.py @@ -18,6 +18,7 @@ from django.utils.translation import gettext_lazy as __ from wagtail.core import blocks from wagtail.core.blocks.field_block import FieldBlockAdapter from wagtail.core.blocks.list_block import ListBlockAdapter +from wagtail.core.blocks.static_block import StaticBlockAdapter from wagtail.core.blocks.stream_block import StreamBlockAdapter from wagtail.core.blocks.struct_block import StructBlockAdapter from wagtail.core.models import Page @@ -3517,56 +3518,86 @@ class TestPageChooserBlock(TestCase): class TestStaticBlock(unittest.TestCase): - @unittest.expectedFailure # TODO(telepath) - def test_render_form_with_constructor(self): + def test_adapt_with_constructor(self): block = blocks.StaticBlock( admin_text="Latest posts - This block doesn't need to be configured, it will be displayed automatically", template='tests/blocks/posts_static_block.html') - rendered_html = block.render_form(None) - self.assertEqual(rendered_html, "Latest posts - This block doesn't need to be configured, it will be displayed automatically") + block.set_name('posts_static_block') + js_args = StaticBlockAdapter().js_args(block) - @unittest.expectedFailure # TODO(telepath) - def test_render_form_with_subclass(self): + self.assertEqual(js_args[0], 'posts_static_block') + self.assertEqual(js_args[1], { + 'text': "Latest posts - This block doesn't need to be configured, it will be displayed automatically", + 'icon': 'placeholder', + 'label': 'Posts static block' + }) + + def test_adapt_with_subclass(self): class PostsStaticBlock(blocks.StaticBlock): class Meta: admin_text = "Latest posts - This block doesn't need to be configured, it will be displayed automatically" template = "tests/blocks/posts_static_block.html" block = PostsStaticBlock() - rendered_html = block.render_form(None) - self.assertEqual(rendered_html, "Latest posts - This block doesn't need to be configured, it will be displayed automatically") + block.set_name('posts_static_block') + js_args = StaticBlockAdapter().js_args(block) - @unittest.expectedFailure # TODO(telepath) - def test_render_form_with_subclass_displays_default_text_if_no_admin_text(self): + self.assertEqual(js_args[0], 'posts_static_block') + self.assertEqual(js_args[1], { + 'text': "Latest posts - This block doesn't need to be configured, it will be displayed automatically", + 'icon': 'placeholder', + 'label': 'Posts static block' + }) + + def test_adapt_with_subclass_displays_default_text_if_no_admin_text(self): class LabelOnlyStaticBlock(blocks.StaticBlock): class Meta: label = "Latest posts" block = LabelOnlyStaticBlock() - rendered_html = block.render_form(None) - self.assertEqual(rendered_html, "Latest posts: this block has no options.") + block.set_name('posts_static_block') + js_args = StaticBlockAdapter().js_args(block) - @unittest.expectedFailure # TODO(telepath) - def test_render_form_with_subclass_displays_default_text_if_no_admin_text_and_no_label(self): + self.assertEqual(js_args[0], 'posts_static_block') + self.assertEqual(js_args[1], { + 'text': "Latest posts: this block has no options.", + 'icon': 'placeholder', + 'label': 'Latest posts' + }) + + def test_adapt_with_subclass_displays_default_text_if_no_admin_text_and_no_label(self): class NoMetaStaticBlock(blocks.StaticBlock): pass block = NoMetaStaticBlock() - rendered_html = block.render_form(None) - self.assertEqual(rendered_html, "This block has no options.") + block.set_name('posts_static_block') + js_args = StaticBlockAdapter().js_args(block) - @unittest.expectedFailure # TODO(telepath) - def test_render_form_works_with_mark_safe(self): + self.assertEqual(js_args[0], 'posts_static_block') + self.assertEqual(js_args[1], { + 'text': "Posts static block: this block has no options.", + 'icon': 'placeholder', + 'label': 'Posts static block' + }) + + def test_adapt_works_with_mark_safe(self): block = blocks.StaticBlock( admin_text=mark_safe("Latest posts - This block doesn't need to be configured, it will be displayed automatically"), template='tests/blocks/posts_static_block.html') - rendered_html = block.render_form(None) - self.assertEqual(rendered_html, "Latest posts - This block doesn't need to be configured, it will be displayed automatically") + block.set_name('posts_static_block') + js_args = StaticBlockAdapter().js_args(block) + + self.assertEqual(js_args[0], 'posts_static_block') + self.assertEqual(js_args[1], { + 'html': "Latest posts - This block doesn't need to be configured, it will be displayed automatically", + 'icon': 'placeholder', + 'label': 'Posts static block' + }) def test_get_default(self): block = blocks.StaticBlock()