diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7f43a..f14d9f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added + +- Added common styling options (#27) + ## [0.4.5] - 2026-05-19 Note: this is the same as v0.4.2 - 0.4.4 with PyPI Trusted publishing fixes after updating GitHub Actions versions @@ -50,7 +54,6 @@ Note: this is the same as v0.4.2 - 0.4.4 with PyPI Trusted publishing fixes afte - Line breaks were stripped out ([#11](https://github.com/torchbox/wagtail-tinytableblock/pull/11)) - ## [0.3.1] - 2025-04-02 ### Added @@ -105,7 +108,6 @@ Note: this is the same as v0.4.2 - 0.4.4 with PyPI Trusted publishing fixes afte Initial release - [unreleased]: https://github.com/torchbox/wagtail-tinytableblock/compare/v0.4.5...HEAD [0.4.5]: https://github.com/torchbox/wagtail-tinytableblock/compare/v0.4.1...v0.4.5 [0.4.1]: https://github.com/torchbox/wagtail-tinytableblock/compare/v0.4.0...v0.4.1 diff --git a/README.md b/README.md index 23e28c0..0336ed0 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ TinyTableBlock is a StreamField block powered by [TinyMCE](https://www.tiny.clou Wagtail provides [`TableBlock`](https://docs.wagtail.org/en/stable/reference/contrib/table_block.html) and [`TypedTableBlock`](https://docs.wagtail.org/en/stable/reference/contrib/typed_table_block.html) which are good options if you want basic tables with some cell merging capability or StreamField-powered cell, but they have their limitations: + - `TableBlock` is using an old version of [handsontable](https://github.com/handsontable/handsontable/tree/6.2.2). It doesn't support multi-row header, column headers, nor pasting complex tables. - `TypedTableBlock` gets complex quickly depending on the types of blocks you add, and pasting is limited to single cells. - + Wagtail TinyTableBlock (this package) provides the TinyMCE table editor which has improved copy/paste, multi-row and column headers, external link support and more. It does not currently support the Wagtail rich text [data format](https://docs.wagtail.org/en/stable/extending/rich_text_internals.html#data-format) for page and document links, nor does it support embedding images. @@ -84,13 +86,46 @@ class ContentBlocks(StreamBlock): table_block = TinyTableBlock(enable_context_menu=True) ``` +### Configuring rich text features allowed in table cells + +You can customize which text formatting tools are available inside the table cells. By default, formatting features are turned **off** to keep table content clean. You can enable them per block or globally across your entire site. + +#### Supported features + +The following formatting identifiers can be passed to the configuration arrays: + +- `bold` +- `italic` +- `strikethrough` +- `subscript` +- `superscript` +- `blockquote` + +#### Block configuration + +To enable per-block configuration, pass a `features` list directly to the `TinyTableBlock` definition in your `models.py`. This aligns directly with standard Wagtail [`RichTextField` formatting controls](https://docs.wagtail.org/en/stable/advanced_topics/customization/page_editing_interface.html#limiting-features-in-a-rich-text-field). + + +#### Global configuration (Django settings) + +If you want to define a fallback list of formatting features for all tables across your website without repeating the code in every model, define `WAGTAIL_TINYTABLE` in your `settings.py`: + +```python +# settings.py + +WAGTAIL_TINYTABLE = { + "features": ["bold", "italic", "strikethrough"] +} +``` + +*Note: If no global settings are defined and no per-block features are provided, the features list defaults to empty (`[]`), disabling rich text formatting choices completely.* + ### Content Security Policy For [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) configuration guidance, follow the [TinyMCE documentation](https://www.tiny.cloud/docs/tinymce/latest/security/#configuring-content-security-policy-csp-for-tinymce) with the self-hosted option. - ## Data representation The table data is saved as a JSON-serialized dictionary with the following keys: diff --git a/src/wagtail_tinytableblock/blocks.py b/src/wagtail_tinytableblock/blocks.py index dd0b0b8..9423f7f 100644 --- a/src/wagtail_tinytableblock/blocks.py +++ b/src/wagtail_tinytableblock/blocks.py @@ -4,11 +4,13 @@ from typing import Any from django import forms +from django.conf import settings from django.forms import Media from django.utils.functional import cached_property from wagtail.blocks import Block, FieldBlock, StructBlock from wagtail.blocks.field_block import CharBlock, FieldBlockAdapter +from .constants import TEXT_FEATURE_MAPPING from .utils import html_table_to_dict @@ -47,7 +49,9 @@ def value_from_form(self, value: str) -> dict: try: return dict(json.loads(value)) except (json.decoder.JSONDecodeError, TypeError, ValueError): - return html_table_to_dict(value, allow_links=self.meta.allow_links) + return html_table_to_dict( + value, allow_links=self.meta.allow_links, features=self.meta.features + ) def value_for_form(self, value: dict | None) -> str: return json.dumps(value) @@ -67,9 +71,15 @@ class TinyTableBlockAdapter(FieldBlockAdapter): def js_args(self, block) -> list: the_args = super().js_args(block) + features = block.meta.features + + # for tinymce we need both: tinymce and html representations + tinymce_features = [ + feature for feature in TEXT_FEATURE_MAPPING if feature[0] in features + ] the_args[2]["enableLinks"] = block.meta.allow_links the_args[2]["enableContextMenu"] = block.meta.enable_context_menu - + the_args[2]["features"] = tinymce_features return the_args @cached_property @@ -97,16 +107,21 @@ def __init__( *, allow_links: bool = False, enable_context_menu: bool = False, + features: list[str] = None, **kwargs, ) -> None: if local_blocks is None: local_blocks = () + if not features: + features = getattr(settings, "WAGTAIL_TINYTABLE", {}).get("features", []) + # Manually define the data block so we can pass on configuration kwargs. data_block = TinyTableFieldBlock( required=False, allow_links=allow_links, enable_context_menu=enable_context_menu, + features=features, ) local_blocks = (*local_blocks, ("data", data_block)) @@ -117,3 +132,4 @@ class Meta: template = "wagtail_tinytableblock/table_block.html" allow_links = False enable_context_menu = False + features = None diff --git a/src/wagtail_tinytableblock/constants.py b/src/wagtail_tinytableblock/constants.py new file mode 100644 index 0000000..b4468c5 --- /dev/null +++ b/src/wagtail_tinytableblock/constants.py @@ -0,0 +1,11 @@ +# Mapping of Wagtail's richtext features and TinyMCE button identifiers +# to their corresponding html tags +TEXT_FEATURE_MAPPING = [ + # ("wagtail/tinymce", "html") + ("bold", "strong"), + ("italic", "em"), + ("strikethrough", "s"), + ("subscript", "sub"), + ("superscript", "sup"), + ("blockquote", "blockquote"), +] diff --git a/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js b/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js index f54b918..f6f4f09 100644 --- a/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js +++ b/src/wagtail_tinytableblock/static/wagtail_tinytableblock/js/tiny-table-block.js @@ -20,6 +20,14 @@ class TinyTableBlockDefinition extends window.wagtailStreamField.blocks.FieldBlo valid_elements += ",a[href|rel|title|target]"; } + if (this.meta.features) { + const toolbar_buttons = this.meta.features.map(f => f[0]); + const elements = this.meta.features.map(f => f[1]); + + toolbar = toolbar_buttons.join(" ") + " | " + toolbar; + valid_elements += "," + elements.join(","); + } + let contextmenu_never_use_native = true; if (!this.meta.enableContextMenu) { contextmenu = false; diff --git a/src/wagtail_tinytableblock/utils.py b/src/wagtail_tinytableblock/utils.py index 319bee2..badcd4a 100644 --- a/src/wagtail_tinytableblock/utils.py +++ b/src/wagtail_tinytableblock/utils.py @@ -6,6 +6,8 @@ from bs4 import BeautifulSoup from nh3 import Cleaner +from .constants import TEXT_FEATURE_MAPPING + if TYPE_CHECKING: from bs4.element import Tag @@ -13,7 +15,9 @@ Cell = Literal["td", "th"] -def sanitise_html(content: str, *, allow_links: bool = False) -> str: +def sanitise_html( + content: str, *, allow_links: bool = False, features: list[str] = None +) -> str: tags: set[str] = {"table", "tr", "th", "td", "thead", "tbody", "caption", "br"} attributes: dict[str, set[str]] = { "*": {"class"}, @@ -25,6 +29,14 @@ def sanitise_html(content: str, *, allow_links: bool = False) -> str: tags |= {"a"} attributes["a"] = {"href", "rel", "title", "target"} + if features: + feature_tags = { + html_tag + for text_feature, html_tag in TEXT_FEATURE_MAPPING + if text_feature in features + } + tags |= feature_tags + sanitizer = Cleaner( tags=tags, attributes=attributes, @@ -105,7 +117,9 @@ def check_all_cells_are_empty(rows: list[list[dict[str, str | int]]]) -> bool: return True -def html_table_to_dict(content: str, *, allow_links: bool = False) -> dict: +def html_table_to_dict( + content: str, *, allow_links: bool = False, features: list[str] = None +) -> dict: """Take an HTML table and convert it to a dictionary. The dictionary has the following structure: @@ -113,7 +127,7 @@ def html_table_to_dict(content: str, *, allow_links: bool = False) -> dict: - rows - a list of row lists, each containing the cell info - html - the original html """ - content = sanitise_html(content, allow_links=allow_links) + content = sanitise_html(content, allow_links=allow_links, features=features) soup = BeautifulSoup(content, "html.parser") table = soup.find("table") diff --git a/tests/test_blocks.py b/tests/test_blocks.py index 3149e55..afebdc4 100644 --- a/tests/test_blocks.py +++ b/tests/test_blocks.py @@ -1,12 +1,13 @@ from unittest import skipIf -from django.test import TestCase +from django.test import TestCase, override_settings from wagtail import VERSION as WAGTAIL_VERSION +from wagtail.test.utils import WagtailTestUtils -from wagtail_tinytableblock.blocks import TinyTableBlock +from wagtail_tinytableblock.blocks import TinyTableBlock, TinyTableFieldBlock -class BlockTestCase(TestCase): +class BlockTestCase(WagtailTestUtils, TestCase): @classmethod def setUpTestData(cls): cls.simple_table_data = { @@ -78,3 +79,79 @@ def test_form_layout_includes_all_fields(self): block = TinyTableBlock(allow_links=True) form_children = block.get_form_layout().children self.assertEqual(["title", "caption", "data"], form_children) + + def test_features_are_disabled_by_default_when_no_param_provided(self): + html = """ + + + + + + + +
bold textCell 2
+ """ + block = TinyTableBlock() + data = block.child_blocks["data"].value_from_form(html) + + rendered = block.render({"data": data}) + self.assertNotInHTML("bold text", rendered) + self.assertNotInHTML("Cell 2", rendered) + + def test_features_are_enabled_when_block_param_is_passed(self): + html = """ + + + + + + + +
bold textCell 2
+ """ + block = TinyTableBlock(features=["bold"]) + data = block.child_blocks["data"].value_from_form(html) + + rendered = block.render({"data": data}) + self.assertInHTML("bold text", rendered) + self.assertNotInHTML("Cell 2", rendered) + + @override_settings(WAGTAIL_TINYTABLE={"features": ["bold", "italic"]}) + def test_features_load_from_global_settings_when_no_param_provided(self): + html = """ + + + + + + + +
bold textCell 2
+ """ + # field_block = TinyTableFieldBlock(allow_links=False, features=[]) + block = TinyTableBlock() + data = block.child_blocks["data"].value_from_form(html) + + rendered = block.render({"data": data}) + self.assertInHTML("bold text", rendered) + self.assertInHTML("Cell 2", rendered) + + @override_settings(WAGTAIL_TINYTABLE={"features": ["bold", "italic"]}) + def test_features_param_takes_priority_over_global_settings(self): + html = """ + + + + + + + +
bold textCell 2
+ """ + # field_block = TinyTableFieldBlock(allow_links=False, features=[]) + block = TinyTableBlock(features=["bold"]) + data = block.child_blocks["data"].value_from_form(html) + + rendered = block.render({"data": data}) + self.assertInHTML("bold text", rendered) + self.assertNotInHTML("Cell 2", rendered) diff --git a/tests/test_utils.py b/tests/test_utils.py index 07a0f46..5a8f65a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -342,3 +342,53 @@ def test_sanitisation__no_links(self): sanitised = sanitise_html(html) self.assertEqual(sanitised.strip(), expected.strip()) + + def test_sanitisation__no_features(self): + html = """ + + + + + + + +
Cell on
on two lines
Cell 2
+ """ + expected = """ + + + + + + + +
Cell on
on two lines
Cell 2
+ """ + + sanitised = sanitise_html(html, features=["bold"]) + self.assertEqual(sanitised.strip(), expected.strip()) + + def test_sanitisation__with_features(self): + html = """ + + + + + + + +
Cell on
on two lines
Cell 2
+ """ + expected = """ + + + + + + + +
Cell on
on two lines
Cell 2
+ """ + + sanitised = sanitise_html(html) + self.assertEqual(sanitised.strip(), expected.strip())