Add support for merging cells in TableBlock.

Handsontable has support for merging table cells using the `mergeCells`
plugin but there was no support in Wagtail for storing which cells have
been merged or rendering them in the output template.

The client JavaScript will now save which cells have been merged and
the output template will merge or hide cells.

See https://handsontable.com/docs/6.2.0/Options.html#mergeCells
pull/10878/head
Gareth Palmer 2022-04-05 14:34:15 +12:00 zatwierdzone przez LB (Ben Johnston)
rodzic 28d55f8c24
commit a63689869e
9 zmienionych plików z 178 dodań i 41 usunięć

Wyświetl plik

@ -21,6 +21,7 @@ Changelog
* Allow overriding `IndexView.export_headings` via `ModelViewSet` (Christer Jensen, Sage Abdullah) * Allow overriding `IndexView.export_headings` via `ModelViewSet` (Christer Jensen, Sage Abdullah)
* Support specifying a `get_object_list` method on `ChooserViewSet` (Matt Westcott) * Support specifying a `get_object_list` method on `ChooserViewSet` (Matt Westcott)
* Add `linked_fields` mechanism on chooser widgets to allow choices to be limited by fields on the calling page (Matt Westcott) * Add `linked_fields` mechanism on chooser widgets to allow choices to be limited by fields on the calling page (Matt Westcott)
* Add support for merging cells within `TableBlock` with the `mergedCells` option (Gareth Palmer)
* Fix: Ensure that StreamField's `FieldBlock`s correctly set the `required` and `aria-describedby` attributes (Storm Heg) * Fix: Ensure that StreamField's `FieldBlock`s correctly set the `required` and `aria-describedby` attributes (Storm Heg)
* Fix: Avoid an error when the moderation panel (admin dashboard) contains both snippets and private pages (Matt Westcott) * Fix: Avoid an error when the moderation panel (admin dashboard) contains both snippets and private pages (Matt Westcott)
* Fix: When deleting collections, ensure the collection name is correctly shown in the success message (LB (Ben) Johnston) * Fix: When deleting collections, ensure the collection name is correctly shown in the success message (LB (Ben) Johnston)

Wyświetl plik

@ -726,6 +726,7 @@
* Shreshth Srivastava * Shreshth Srivastava
* Sandeep Choudhary * Sandeep Choudhary
* Antoni Martyniuk * Antoni Martyniuk
* Gareth Palmer
## Translators ## Translators

Wyświetl plik

@ -77,26 +77,52 @@ function initTable(id, tableOptions) {
}); });
} }
const getCellsClassnames = function () { const persist = function () {
const meta = hot.getCellsMeta(); const cell = [];
const cellsClassnames = []; const mergeCells = [];
for (let i = 0; i < meta.length; i += 1) { const cellsMeta = hot.getCellsMeta();
if (hasOwn(meta[i], 'className')) {
cellsClassnames.push({ cellsMeta.forEach((meta) => {
row: meta[i].row, let className;
col: meta[i].col, let hidden;
className: meta[i].className,
if (hasOwn(meta, 'className')) {
className = meta.className;
}
if (hasOwn(meta, 'hidden')) {
// Cells are hidden if they have been merged
hidden = true;
}
// Undefined values won't be included in the output
if (className !== undefined || hidden) {
cell.push({
row: meta.row,
col: meta.col,
className: className,
hidden: hidden,
});
}
});
if (hot.getPlugin('mergeCells').isEnabled()) {
const collection = hot.getPlugin('mergeCells').mergedCellsCollection;
collection.mergedCells.forEach((merge) => {
mergeCells.push({
row: merge.row,
col: merge.col,
rowspan: merge.rowspan,
colspan: merge.colspan,
});
}); });
} }
}
return cellsClassnames;
};
const persist = function () {
hiddenStreamInput.val( hiddenStreamInput.val(
JSON.stringify({ JSON.stringify({
data: hot.getData(), data: hot.getData(),
cell: getCellsClassnames(), cell: cell,
mergeCells: mergeCells,
first_row_is_table_header: tableHeaderCheckbox.prop('checked'), first_row_is_table_header: tableHeaderCheckbox.prop('checked'),
first_col_is_header: colHeaderCheckbox.prop('checked'), first_col_is_header: colHeaderCheckbox.prop('checked'),
table_caption: tableCaption.val(), table_caption: tableCaption.val(),
@ -105,7 +131,7 @@ function initTable(id, tableOptions) {
}; };
const cellEvent = function (change, source) { const cellEvent = function (change, source) {
if (source === 'loadData') { if (!isInitialized || source === 'loadData' || source === 'MergeCells') {
return; // don't save this change return; // don't save this change
} }
@ -119,6 +145,20 @@ function initTable(id, tableOptions) {
} }
}; };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const mergeEvent = function (cellRange, mergeParent, auto) {
if (isInitialized) {
persist();
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const unmergeEvent = function (cellRange, auto) {
if (isInitialized) {
persist();
}
};
const initEvent = function () { const initEvent = function () {
isInitialized = true; isInitialized = true;
}; };
@ -148,6 +188,8 @@ function initTable(id, tableOptions) {
afterRemoveCol: structureEvent, afterRemoveCol: structureEvent,
afterRemoveRow: structureEvent, afterRemoveRow: structureEvent,
afterSetCellMeta: metaEvent, afterSetCellMeta: metaEvent,
afterMergeCells: mergeEvent,
afterUnmergeCells: unmergeEvent,
afterInit: initEvent, afterInit: initEvent,
// contextMenu set via init, from server defaults // contextMenu set via init, from server defaults
}; };
@ -169,6 +211,13 @@ function initTable(id, tableOptions) {
finalOptions[key] = tableOptions[key]; finalOptions[key] = tableOptions[key];
}); });
if (hasOwn(finalOptions, 'mergeCells') && finalOptions.mergeCells === true) {
// If mergeCells is enabled and true then use the value from the database
if (dataForForm !== null && hasOwn(dataForForm, 'mergeCells')) {
finalOptions.mergeCells = dataForForm.mergeCells;
}
}
hot = new Handsontable(document.getElementById(containerId), finalOptions); hot = new Handsontable(document.getElementById(containerId), finalOptions);
window.addEventListener('load', () => { window.addEventListener('load', () => {
// Render the table. Calling render also removes 'null' literals from empty cells. // Render the table. Calling render also removes 'null' literals from empty cells.

Wyświetl plik

@ -85,6 +85,8 @@ default_table_options = {
} }
``` ```
(table_block_options)=
### Configuration Options ### Configuration Options
Every key in the `table_options` dictionary maps to a [handsontable](https://handsontable.com/) option. These settings can be changed to alter the behaviour of tables in Wagtail. The following options are available: Every key in the `table_options` dictionary maps to a [handsontable](https://handsontable.com/) option. These settings can be changed to alter the behaviour of tables in Wagtail. The following options are available:
@ -101,6 +103,7 @@ Every key in the `table_options` dictionary maps to a [handsontable](https://han
- [language](https://handsontable.com/docs/6.2.2/Options.html#language) - The default language setting. By default TableBlock tries to get the language from `django.utils.translation.get_language`. If needed, this setting can be overridden here. - [language](https://handsontable.com/docs/6.2.2/Options.html#language) - The default language setting. By default TableBlock tries to get the language from `django.utils.translation.get_language`. If needed, this setting can be overridden here.
- [renderer](https://handsontable.com/docs/6.2.2/Options.html#renderer) - The default setting Handsontable uses to render the content of table cells. - [renderer](https://handsontable.com/docs/6.2.2/Options.html#renderer) - The default setting Handsontable uses to render the content of table cells.
- [autoColumnSize](https://handsontable.com/docs/6.2.2/Options.html#autoColumnSize) - Enables or disables the `autoColumnSize` plugin. The TableBlock default setting is `False`. - [autoColumnSize](https://handsontable.com/docs/6.2.2/Options.html#autoColumnSize) - Enables or disables the `autoColumnSize` plugin. The TableBlock default setting is `False`.
- [mergedCells](https://handsontable.com/docs/6.2.0/Options.html#mergeCells) - Can be set to `True` or `False`, determined if merging cells is allowed. Remember to add `'mergeCells'` to the `'contextMenu'` option also.
A [complete list of handsontable options](https://handsontable.com/docs/6.2.2/Options.html) can be found on the Handsontable website. A [complete list of handsontable options](https://handsontable.com/docs/6.2.2/Options.html) can be found on the Handsontable website.

Wyświetl plik

@ -31,6 +31,7 @@ depth: 1
* Allow overriding `IndexView.export_headings` via `ModelViewSet` (Christer Jensen, Sage Abdullah) * Allow overriding `IndexView.export_headings` via `ModelViewSet` (Christer Jensen, Sage Abdullah)
* Support specifying a `get_object_list` method on `ChooserViewSet` (Matt Westcott) * Support specifying a `get_object_list` method on `ChooserViewSet` (Matt Westcott)
* Add `linked_fields` mechanism on chooser widgets to allow choices to be limited by fields on the calling page (Matt Westcott) * Add `linked_fields` mechanism on chooser widgets to allow choices to be limited by fields on the calling page (Matt Westcott)
* Add support for merging cells within `TableBlock` with the [`mergedCells` option](table_block_options) (Gareth Palmer)
### Bug fixes ### Bug fixes

Wyświetl plik

@ -164,11 +164,24 @@ class TableBlock(FieldBlock):
if value.get("cell"): if value.get("cell"):
new_context["classnames"] = {} new_context["classnames"] = {}
new_context["hidden"] = {}
for meta in value["cell"]: for meta in value["cell"]:
if "className" in meta: if "className" in meta:
new_context["classnames"][(meta["row"], meta["col"])] = meta[ new_context["classnames"][(meta["row"], meta["col"])] = meta[
"className" "className"
] ]
if "hidden" in meta:
new_context["hidden"][(meta["row"], meta["col"])] = meta[
"hidden"
]
if value.get("mergeCells"):
new_context["spans"] = {}
for merge in value["mergeCells"]:
new_context["spans"][(merge["row"], merge["col"])] = {
"rowspan": merge["rowspan"],
"colspan": merge["colspan"],
}
return render_to_string(template, new_context) return render_to_string(template, new_context)
else: else:

Wyświetl plik

@ -9,7 +9,9 @@
<tr> <tr>
{% for column in table_header %} {% for column in table_header %}
{% with forloop.counter0 as col_index %} {% with forloop.counter0 as col_index %}
<th scope="col" {% cell_classname 0 col_index %}> {% cell_hidden 0 col_index as is_hidden %}
{% if not is_hidden %}
<th scope="col" {% cell_classname 0 col_index %} {% cell_span 0 col_index %}>
{% if column.strip %} {% if column.strip %}
{% if html_renderer %} {% if html_renderer %}
{{ column.strip|safe|linebreaksbr }} {{ column.strip|safe|linebreaksbr }}
@ -18,6 +20,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</th> </th>
{% endif %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</tr> </tr>
@ -29,8 +32,10 @@
<tr> <tr>
{% for column in row %} {% for column in row %}
{% with forloop.counter0 as col_index %} {% with forloop.counter0 as col_index %}
{% cell_hidden row_index col_index table_header as is_hidden %}
{% if not is_hidden %}
{% if first_col_is_header and forloop.first %} {% if first_col_is_header and forloop.first %}
<th scope="row" {% cell_classname row_index col_index table_header %}> <th scope="row" {% cell_classname row_index col_index table_header %} {% cell_span row_index col_index table_header %}>
{% if column.strip %} {% if column.strip %}
{% if html_renderer %} {% if html_renderer %}
{{ column.strip|safe|linebreaksbr }} {{ column.strip|safe|linebreaksbr }}
@ -40,7 +45,7 @@
{% endif %} {% endif %}
</th> </th>
{% else %} {% else %}
<td {% cell_classname row_index col_index table_header %}> <td {% cell_classname row_index col_index table_header %} {% cell_span row_index col_index table_header %}>
{% if column.strip %} {% if column.strip %}
{% if html_renderer %} {% if html_renderer %}
{{ column.strip|safe|linebreaksbr }} {{ column.strip|safe|linebreaksbr }}
@ -50,6 +55,7 @@
{% endif %} {% endif %}
</td> </td>
{% endif %} {% endif %}
{% endif %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</tr> </tr>

Wyświetl plik

@ -15,3 +15,32 @@ def cell_classname(context, row_index, col_index, table_header=None):
if cell_class: if cell_class:
return mark_safe(f'class="{cell_class}"') return mark_safe(f'class="{cell_class}"')
return "" return ""
@register.simple_tag(takes_context=True)
def cell_hidden(context, row_index, col_index, table_header=None):
hidden = context.get("hidden")
if hidden:
if table_header is not None:
row_index += 1
index = (row_index, col_index)
return hidden.get(index, False)
return False
@register.simple_tag(takes_context=True)
def cell_span(context, row_index, col_index, table_header=None):
spans = context.get("spans")
if spans:
if table_header is not None:
row_index += 1
index = (row_index, col_index)
cell_span = spans.get(index)
if cell_span:
return mark_safe(
'rowspan="{}" colspan="{}"'.format(
cell_span["rowspan"],
cell_span["colspan"],
)
)
return ""

Wyświetl plik

@ -348,6 +348,40 @@ class TestTableBlock(TestCase):
self.assertHTMLEqual(result, expected) self.assertHTMLEqual(result, expected)
self.assertNotIn("None", result) self.assertNotIn("None", result)
def test_merge_cells_render(self):
"""
Test that merged table cells are rendered.
"""
value = {
"first_row_is_table_header": False,
"first_col_is_header": False,
"data": [
["one", None, "two"],
["three", "four", "five"],
["six", "seven", None],
],
"cell": [
{"row": 0, "col": 1, "hidden": True},
{"row": 2, "col": 2, "hidden": True},
],
"mergeCells": [
{"row": 0, "col": 0, "rowspan": 1, "colspan": 2},
{"row": 1, "col": 2, "rowspan": 2, "colspan": 1},
],
}
block = TableBlock()
result = block.render(value)
expected = """
<table>
<tbody>
<tr><td rowspan="1" colspan="2">one</td><td>two</td></tr>
<tr><td>three</td><td>four</td><td rowspan="2" colspan="1">five</td></tr>
<tr><td>six</td><td>seven</td></tr>
</tbody>
</table>
"""
self.assertHTMLEqual(result, expected)
class TestTableBlockForm(WagtailTestUtils, SimpleTestCase): class TestTableBlockForm(WagtailTestUtils, SimpleTestCase):
def setUp(self): def setUp(self):