Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 18 additions & 2 deletions src/wagtail_tinytableblock/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -117,3 +132,4 @@ class Meta:
template = "wagtail_tinytableblock/table_block.html"
allow_links = False
enable_context_menu = False
features = None
11 changes: 11 additions & 0 deletions src/wagtail_tinytableblock/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Mapping of Wagtail's richtext features and TinyMCE button identifiers
Comment thread
dest81 marked this conversation as resolved.
# to their corresponding html tags
TEXT_FEATURE_MAPPING = [
# ("wagtail/tinymce", "html")
("bold", "strong"),
("italic", "em"),
("strikethrough", "s"),
("subscript", "sub"),
("superscript", "sup"),
("blockquote", "blockquote"),
]
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions src/wagtail_tinytableblock/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
from bs4 import BeautifulSoup
from nh3 import Cleaner

from .constants import TEXT_FEATURE_MAPPING


if TYPE_CHECKING:
from bs4.element import Tag

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"},
Expand All @@ -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,
Expand Down Expand Up @@ -105,15 +117,17 @@ 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:
- headers - a list of header row lists, each containing the cell info
- 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")
Expand Down
83 changes: 80 additions & 3 deletions tests/test_blocks.py
Original file line number Diff line number Diff line change
@@ -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

Comment thread
zerolab marked this conversation as resolved.
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 = {
Expand Down Expand Up @@ -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 = """
<table>
<tbody>
<tr>
<td><strong>bold text</strong></td>
<td><em>Cell 2</em></td>
</tr>
</tbody>
</table>
"""
block = TinyTableBlock()
data = block.child_blocks["data"].value_from_form(html)

rendered = block.render({"data": data})
self.assertNotInHTML("<strong>bold text</strong>", rendered)
Comment thread
zerolab marked this conversation as resolved.
self.assertNotInHTML("<em>Cell 2</em>", rendered)

def test_features_are_enabled_when_block_param_is_passed(self):
html = """
<table>
<tbody>
<tr>
<td><strong>bold text</strong></td>
<td><em>Cell 2</em></td>
</tr>
</tbody>
</table>
"""
block = TinyTableBlock(features=["bold"])
data = block.child_blocks["data"].value_from_form(html)

rendered = block.render({"data": data})
self.assertInHTML("<strong>bold text</strong>", rendered)
self.assertNotInHTML("<em>Cell 2</em>", rendered)

@override_settings(WAGTAIL_TINYTABLE={"features": ["bold", "italic"]})
def test_features_load_from_global_settings_when_no_param_provided(self):
html = """
<table>
<tbody>
<tr>
<td><strong>bold text</strong></td>
<td><em>Cell 2</em></td>
</tr>
</tbody>
</table>
"""
# 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("<strong>bold text</strong>", rendered)
self.assertInHTML("<em>Cell 2</em>", rendered)

@override_settings(WAGTAIL_TINYTABLE={"features": ["bold", "italic"]})
def test_features_param_takes_priority_over_global_settings(self):
html = """
<table>
<tbody>
<tr>
<td><strong>bold text</strong></td>
<td><em>Cell 2</em></td>
</tr>
</tbody>
</table>
"""
# 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("<strong>bold text</strong>", rendered)
self.assertNotInHTML("<em>Cell 2</em>", rendered)
50 changes: 50 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<table>
<tbody>
<tr>
<td><strong>Cell on<br> on two lines</strong></td>
<td>Cell 2</td>
</tr>
</tbody>
</table>
"""
expected = """
<table>
<tbody>
<tr>
<td><strong>Cell on<br> on two lines</strong></td>
<td>Cell 2</td>
</tr>
</tbody>
</table>
"""

sanitised = sanitise_html(html, features=["bold"])
self.assertEqual(sanitised.strip(), expected.strip())

def test_sanitisation__with_features(self):
html = """
<table>
<tbody>
<tr>
<td><strong>Cell on<br> on two lines</strong></td>
<td>Cell 2</td>
</tr>
</tbody>
</table>
"""
expected = """
<table>
<tbody>
<tr>
<td>Cell on<br> on two lines</td>
<td>Cell 2</td>
</tr>
</tbody>
</table>
"""

sanitised = sanitise_html(html)
self.assertEqual(sanitised.strip(), expected.strip())