0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-29 01:22:07 +01:00

Move to a select field for determining the way TableBlock will use headers

- Make the new table_header_choice select backwards compatible with tables stored before Wagtail 6.0
- Built on previous PRs #9673 & #6763
- Fixes #5989
This commit is contained in:
DevilsAutumn 2023-12-09 16:20:57 +05:30 committed by LB (Ben Johnston)
parent a4c18b4957
commit fe1a306285
7 changed files with 194 additions and 96 deletions

View File

@ -38,6 +38,7 @@ Changelog
* Move locale selector in generic IndexView to a filter (Sage Abdullah) * Move locale selector in generic IndexView to a filter (Sage Abdullah)
* Add ability to customise a page's copy form (Neeraj Yetheendran) * Add ability to customise a page's copy form (Neeraj Yetheendran)
* Add optional caption field to `TypedTableBlock` (Tommaso Amici, Cynthia Kiser) * Add optional caption field to `TypedTableBlock` (Tommaso Amici, Cynthia Kiser)
* Switch the `TableBlock` header controls to a field that requires user input (Bhuvnesh Sharma, Aman Pandey, Cynthia Kiser)
* Fix: Update system check for overwriting storage backends to recognise the `STORAGES` setting introduced in Django 4.2 (phijma-leukeleu) * Fix: Update system check for overwriting storage backends to recognise the `STORAGES` setting introduced in Django 4.2 (phijma-leukeleu)
* Fix: Prevent password change form from raising a validation error when browser autocomplete fills in the "Old password" field (Chiemezuo Akujobi) * Fix: Prevent password change form from raising a validation error when browser autocomplete fills in the "Old password" field (Chiemezuo Akujobi)
* Fix: Ensure that the legacy dropdown options, when closed, do not get accidentally clicked by other interactions wide viewports (CheesyPhoenix, Christer Jensen) * Fix: Ensure that the legacy dropdown options, when closed, do not get accidentally clicked by other interactions wide viewports (CheesyPhoenix, Christer Jensen)

View File

@ -3,26 +3,23 @@
exports[`telepath: wagtail.widgets.TableInput it renders correctly 1`] = ` exports[`telepath: wagtail.widgets.TableInput it renders correctly 1`] = `
"<div> "<div>
<div class="w-field__wrapper" data-field-wrapper=""> <div class="w-field__wrapper" data-field-wrapper="">
<label class="w-field__label" for="the-id-handsontable-header">Row header</label> <label class="w-field__label" for="the-id-table-header-choice">Table headers</label>
<div class="w-field w-field--boolean_field w-field--checkbox_input" data-field=""> <select id="the-id-table-header-choice" name="table-header-choice">
<div class="w-field__help" id="the-id-handsontable-header-helptext" data-field-help=""> <option value="">Select a header option</option>
<div class="help">Display the first row as a header.</div> <option value="row">
</div> Display the first row as a header
<div class="w-field__input" data-field-input=""> </option>
<input type="checkbox" id="the-id-handsontable-header" name="handsontable-header" aria-describedby="the-id-handsontable-header-helptext"> <option value="column">
</div> Display the first column as a header
</div> </option>
</div> <option value="both">
<div class="w-field__wrapper" data-field-wrapper=""> Display the first row AND first column as headers
<label class="w-field__label" for="the-id-handsontable-col-header">Column header</label> </option>
<div class="w-field w-field--boolean_field w-field--checkbox_input" data-field=""> <option value="neither">
<div class="w-field__help" id="the-id-handsontable-col-header-helptext" data-field-help=""> No headers
<div class="help">Display the first column as a header.</div> </option>
</div> </select>
<div class="w-field__input" data-field-input=""> <p class="help">Which cells should be displayed as headers?</p>
<input type="checkbox" id="the-id-handsontable-col-header" name="handsontable-col-header" aria-describedby="the-id-handsontable-col-header-helptext">
</div>
</div>
</div> </div>
<div class="w-field__wrapper" data-field-wrapper=""> <div class="w-field__wrapper" data-field-wrapper="">
<label class="w-field__label" for="the-id-handsontable-col-caption">Table caption</label> <label class="w-field__label" for="the-id-handsontable-col-caption">Table caption</label>
@ -43,26 +40,23 @@ exports[`telepath: wagtail.widgets.TableInput it renders correctly 1`] = `
exports[`telepath: wagtail.widgets.TableInput translation 1`] = ` exports[`telepath: wagtail.widgets.TableInput translation 1`] = `
"<div> "<div>
<div class="w-field__wrapper" data-field-wrapper=""> <div class="w-field__wrapper" data-field-wrapper="">
<label class="w-field__label" for="the-id-handsontable-header">En-tête de ligne</label> <label class="w-field__label" for="the-id-table-header-choice">En-têtes de tableau</label>
<div class="w-field w-field--boolean_field w-field--checkbox_input" data-field=""> <select id="the-id-table-header-choice" name="table-header-choice">
<div class="w-field__help" id="the-id-handsontable-header-helptext" data-field-help=""> <option value="">Select a header option</option>
<div class="help">Affichez la première ligne sous forme d'en-tête.</div> <option value="row">
</div> Afficher la première ligne sous forme d'en-tête
<div class="w-field__input" data-field-input=""> </option>
<input type="checkbox" id="the-id-handsontable-header" name="handsontable-header" aria-describedby="the-id-handsontable-header-helptext"> <option value="column">
</div> Afficher la première colonne sous forme d'en-tête
</div> </option>
</div> <option value="both">
<div class="w-field__wrapper" data-field-wrapper=""> Afficher la première ligne ET la première colonne sous forme d'en-têtes
<label class="w-field__label" for="the-id-handsontable-col-header">En-tête de colonne</label> </option>
<div class="w-field w-field--boolean_field w-field--checkbox_input" data-field=""> <option value="neither">
<div class="w-field__help" id="the-id-handsontable-col-header-helptext" data-field-help=""> Pas d'en-têtes
<div class="help">Affichez la première colonne sous forme d'en-tête.</div> </option>
</div> </select>
<div class="w-field__input" data-field-input=""> <p class="help">Quelles cellules doivent être affichées en tant qu'en-têtes?</p>
<input type="checkbox" id="the-id-handsontable-col-header" name="handsontable-col-header" aria-describedby="the-id-handsontable-col-header-helptext">
</div>
</div>
</div> </div>
<div class="w-field__wrapper" data-field-wrapper=""> <div class="w-field__wrapper" data-field-wrapper="">
<label class="w-field__label" for="the-id-handsontable-col-caption">Légende du tableau</label> <label class="w-field__label" for="the-id-handsontable-col-caption">Légende du tableau</label>

View File

@ -7,12 +7,14 @@ import { hasOwn } from '../../../utils/hasOwn';
function initTable(id, tableOptions) { function initTable(id, tableOptions) {
const containerId = id + '-handsontable-container'; const containerId = id + '-handsontable-container';
const tableHeaderCheckboxId = id + '-handsontable-header'; var tableHeaderId = id + '-handsontable-header';
const colHeaderCheckboxId = id + '-handsontable-col-header'; var colHeaderId = id + '-handsontable-col-header';
var headerChoiceId = id + '-table-header-choice';
const tableCaptionId = id + '-handsontable-col-caption'; const tableCaptionId = id + '-handsontable-col-caption';
const hiddenStreamInput = $('#' + id); const hiddenStreamInput = $('#' + id);
const tableHeaderCheckbox = $('#' + tableHeaderCheckboxId); var tableHeader = $('#' + tableHeaderId);
const colHeaderCheckbox = $('#' + colHeaderCheckboxId); var colHeader = $('#' + colHeaderId);
var headerChoice = $('#' + headerChoiceId);
const tableCaption = $('#' + tableCaptionId); const tableCaption = $('#' + tableCaptionId);
const finalOptions = {}; const finalOptions = {};
let hot = null; let hot = null;
@ -52,18 +54,12 @@ function initTable(id, tableOptions) {
} }
if (dataForForm !== null) { if (dataForForm !== null) {
if (hasOwn(dataForForm, 'first_row_is_table_header')) {
tableHeaderCheckbox.prop(
'checked',
dataForForm.first_row_is_table_header,
);
}
if (hasOwn(dataForForm, 'first_col_is_header')) {
colHeaderCheckbox.prop('checked', dataForForm.first_col_is_header);
}
if (hasOwn(dataForForm, 'table_caption')) { if (hasOwn(dataForForm, 'table_caption')) {
tableCaption.prop('value', dataForForm.table_caption); tableCaption.prop('value', dataForForm.table_caption);
} }
if (hasOwn(dataForForm, 'table_header_choice')) {
headerChoice.prop('value', dataForForm.table_header_choice);
}
} }
if (!hasOwn(tableOptions, 'width') || !hasOwn(tableOptions, 'height')) { if (!hasOwn(tableOptions, 'width') || !hasOwn(tableOptions, 'height')) {
@ -123,8 +119,9 @@ function initTable(id, tableOptions) {
data: hot.getData(), data: hot.getData(),
cell: cell, cell: cell,
mergeCells: mergeCells, mergeCells: mergeCells,
first_row_is_table_header: tableHeaderCheckbox.prop('checked'), first_row_is_table_header: tableHeader.val(),
first_col_is_header: colHeaderCheckbox.prop('checked'), first_col_is_header: colHeader.val(),
table_header_choice: headerChoice.val(),
table_caption: tableCaption.val(), table_caption: tableCaption.val(),
}), }),
); );
@ -169,11 +166,7 @@ function initTable(id, tableOptions) {
persist(); persist();
}; };
tableHeaderCheckbox.on('change', () => { headerChoice.on('change', () => {
persist();
});
colHeaderCheckbox.on('change', () => {
persist(); persist();
}); });
@ -238,26 +231,23 @@ class TableInput {
const container = document.createElement('div'); const container = document.createElement('div');
container.innerHTML = ` container.innerHTML = `
<div class="w-field__wrapper" data-field-wrapper> <div class="w-field__wrapper" data-field-wrapper>
<label class="w-field__label" for="${id}-handsontable-header">${this.strings['Row header']}</label> <label class="w-field__label" for="${id}-table-header-choice">${this.strings['Table headers']}</label>
<div class="w-field w-field--boolean_field w-field--checkbox_input" data-field> <select id="${id}-table-header-choice" name="table-header-choice">
<div class="w-field__help" id="${id}-handsontable-header-helptext" data-field-help> <option value="">Select a header option</option>
<div class="help">${this.strings['Display the first row as a header.']}</div> <option value="row">
</div> ${this.strings['Display the first row as a header']}
<div class="w-field__input" data-field-input> </option>
<input type="checkbox" id="${id}-handsontable-header" name="handsontable-header" aria-describedby="${id}-handsontable-header-helptext" /> <option value="column">
</div> ${this.strings['Display the first column as a header']}
</div> </option>
</div> <option value="both">
<div class="w-field__wrapper" data-field-wrapper> ${this.strings['Display the first row AND first column as headers']}
<label class="w-field__label" for="${id}-handsontable-col-header">${this.strings['Column header']}</label> </option>
<div class="w-field w-field--boolean_field w-field--checkbox_input" data-field> <option value="neither">
<div class="w-field__help" id="${id}-handsontable-col-header-helptext" data-field-help> ${this.strings['No headers']}
<div class="help">${this.strings['Display the first column as a header.']}</div> </option>
</div> </select>
<div class="w-field__input" data-field-input> <p class="help">${this.strings['Which cells should be displayed as headers?']}</p>
<input type="checkbox" id="${id}-handsontable-col-header" name="handsontable-col-header" aria-describedby="${id}-handsontable-col-header-helptext" />
</div>
</div>
</div> </div>
<div class="w-field__wrapper" data-field-wrapper> <div class="w-field__wrapper" data-field-wrapper>
<label class="w-field__label" for="${id}-handsontable-col-caption">${this.strings['Table caption']}</label> <label class="w-field__label" for="${id}-handsontable-col-caption">${this.strings['Table caption']}</label>

View File

@ -33,11 +33,15 @@ const TEST_OPTIONS = {
}; };
const TEST_STRINGS = { const TEST_STRINGS = {
'Row header': 'Row header', 'Table headers': 'Table headers',
'Display the first row as a header.': 'Display the first row as a header.', 'Display the first row as a header': 'Display the first row as a header',
'Column header': 'Column header', 'Display the first column as a header':
'Display the first column as a header.': 'Display the first column as a header',
'Display the first column as a header.', 'Display the first row AND first column as headers':
'Display the first row AND first column as headers',
'No headers': 'No headers',
'Which cells should be displayed as headers?':
'Which cells should be displayed as headers?',
'Table caption': 'Table caption', 'Table caption': 'Table caption',
'A heading that identifies the overall topic of the table, and is useful for screen reader users.': 'A heading that identifies the overall topic of the table, and is useful for screen reader users.':
@ -155,12 +159,16 @@ describe('telepath: wagtail.widgets.TableInput', () => {
test('translation', () => { test('translation', () => {
testStrings = { testStrings = {
'Row header': 'En-tête de ligne', 'Table headers': 'En-têtes de tableau',
'Display the first row as a header.': 'Display the first row as a header':
"Affichez la première ligne sous forme d'en-tête.", "Afficher la première ligne sous forme d'en-tête",
'Column header': 'En-tête de colonne', 'Display the first column as a header':
'Display the first column as a header.': "Afficher la première colonne sous forme d'en-tête",
"Affichez la première colonne sous forme d'en-tête.", 'Display the first row AND first column as headers':
"Afficher la première ligne ET la première colonne sous forme d'en-têtes",
'No headers': "Pas d'en-têtes",
'Which cells should be displayed as headers?':
"Quelles cellules doivent être affichées en tant qu'en-têtes?",
'Table caption': 'Légende du tableau', 'Table caption': 'Légende du tableau',
'A heading that identifies the overall topic of the table, and is useful for screen reader users.': 'A heading that identifies the overall topic of the table, and is useful for screen reader users.':

View File

@ -60,6 +60,7 @@ Thank you to Thibaud Colas and Badr Fourane for their work on this feature.
* Show character counts on RichTextBlock with `max_length` (Elhussein Almasri) * Show character counts on RichTextBlock with `max_length` (Elhussein Almasri)
* Move locale selector in generic IndexView to a filter (Sage Abdullah) * Move locale selector in generic IndexView to a filter (Sage Abdullah)
* Add optional caption field to `TypedTableBlock` (Tommaso Amici, Cynthia Kiser) * Add optional caption field to `TypedTableBlock` (Tommaso Amici, Cynthia Kiser)
* Switch the `TableBlock` header controls to a field that requires user input (Bhuvnesh Sharma, Aman Pandey, Cynthia Kiser)
### Bug fixes ### Bug fixes

View File

@ -1,6 +1,9 @@
import json import json
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.forms.fields import Field
from django.forms.utils import ErrorList
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils import translation from django.utils import translation
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -68,12 +71,18 @@ class TableInputAdapter(WidgetAdapter):
def js_args(self, widget): def js_args(self, widget):
strings = { strings = {
"Row header": _("Row header"), "Row header": _("Row header"),
"Display the first row as a header.": _( "Table headers": _("Table headers"),
"Display the first row as a header." "Display the first row as a header": _("Display the first row as a header"),
"Display the first column as a header": _(
"Display the first column as a header"
), ),
"Column header": _("Column header"), "Column header": _("Column header"),
"Display the first column as a header.": _( "Display the first row AND first column as headers": _(
"Display the first column as a header." "Display the first row AND first column as headers"
),
"No headers": _("No headers"),
"Which cells should be displayed as headers?": _(
"Which cells should be displayed as headers?"
), ),
"Table caption": _("Table caption"), "Table caption": _("Table caption"),
"A heading that identifies the overall topic of the table, and is useful for screen reader users.": _( "A heading that identifies the overall topic of the table, and is useful for screen reader users.": _(
@ -117,6 +126,44 @@ class TableBlock(FieldBlock):
def value_for_form(self, value): def value_for_form(self, value):
return json.dumps(value) return json.dumps(value)
def to_python(self, value):
"""
If value came from a table block stored before Wagtail 6.0, we need to set an appropriate
value for the header choice. I would really like to have this default to "" and force the
editor to reaffirm they don't want any headers, but that woud be a breaking change.
"""
if not value.get("table_header_choice", ""):
if value.get("first_row_is_table_header", False) and value.get(
"first_col_is_header", False
):
value["table_header_choice"] = "both"
elif value.get("first_row_is_table_header", False):
value["table_header_choice"] = "row"
elif value.get("first_col_is_header", False):
value["table_header_choice"] = "col"
else:
value["table_header_choice"] = "neither"
return value
def clean(self, value):
if not value:
return value
if value.get("table_header_choice", ""):
value["first_row_is_table_header"] = value["table_header_choice"] in [
"row",
"both",
]
value["first_col_is_header"] = value["table_header_choice"] in [
"column",
"both",
]
else:
# Ensure we have a choice for the table_header_choice
errors = ErrorList(Field.default_error_messages["required"])
raise ValidationError("Validation error in TableBlock", params=errors)
return self.value_from_form(self.field.clean(self.value_for_form(value)))
def get_form_state(self, value): def get_form_state(self, value):
# pass state to frontend as a JSON-ish dict - do not serialise to a JSON string # pass state to frontend as a JSON-ish dict - do not serialise to a JSON string
return value return value

View File

@ -116,7 +116,7 @@ class TestTableBlock(TestCase):
""" """
self.assertHTMLEqual(result, expected) self.assertHTMLEqual(result, expected)
def test_do_not_render_html(self): def test_do_not_render_html_by_default(self):
""" """
Ensure that raw html doesn't render Ensure that raw html doesn't render
by default. by default.
@ -145,6 +145,36 @@ class TestTableBlock(TestCase):
result = block.render(value) result = block.render(value)
self.assertHTMLEqual(result, expected) self.assertHTMLEqual(result, expected)
def test_does_render_html_if_allowed(self):
"""
Ensure html renders if table_options set renderer to allow html
"""
value = {
"first_row_is_table_header": False,
"first_col_is_header": False,
"data": [
["<p><strong>Test</strong></p>", None, None],
[None, None, None],
[None, None, None],
],
}
expected = """
<table>
<tbody>
<tr><td><p><strong>Test</strong></p></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
<tr><td></td><td></td><td></td></tr>
</tbody>
</table>
"""
new_options = self.default_table_options.copy()
new_options["renderer"] = "html"
block = TableBlock(table_options=new_options)
result = block.render(value)
self.assertHTMLEqual(result, expected)
def test_row_headers(self): def test_row_headers(self):
""" """
Ensure that row headers are properly rendered. Ensure that row headers are properly rendered.
@ -226,6 +256,33 @@ class TestTableBlock(TestCase):
result = block.render(value) result = block.render(value)
self.assertHTMLEqual(result, expected) self.assertHTMLEqual(result, expected)
def test_no_headers(self):
"""
Test table without headers.
"""
value = {
"first_row_is_table_header": False,
"first_col_is_header": False,
"data": [
["Foo", "Bar", "Baz"],
["one", "two", "three"],
["four", "five", "six"],
],
}
expected = """
<table>
<tbody>
<tr><td>Foo</td><td>Bar</td><td>Baz</td></tr>
<tr><td>one</td><td>two</td><td>three</td></tr>
<tr><td>four</td><td>five</td><td>six</td></tr>
</tbody>
</table>
"""
block = TableBlock()
result = block.render(value)
self.assertHTMLEqual(result, expected)
def test_value_for_and_from_form(self): def test_value_for_and_from_form(self):
""" """
Make sure we get back good json and make Make sure we get back good json and make