267 lines
9.3 KiB
Python
267 lines
9.3 KiB
Python
import json
|
|
|
|
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.utils import translation
|
|
from django.utils.functional import cached_property
|
|
from django.utils.translation import gettext as _
|
|
|
|
from wagtail.admin.staticfiles import versioned_static
|
|
from wagtail.blocks import FieldBlock
|
|
from wagtail.telepath import register
|
|
from wagtail.widget_adapters import WidgetAdapter
|
|
|
|
DEFAULT_TABLE_OPTIONS = {
|
|
"minSpareRows": 0,
|
|
"startRows": 3,
|
|
"startCols": 3,
|
|
"colHeaders": False,
|
|
"rowHeaders": False,
|
|
"contextMenu": [
|
|
"row_above",
|
|
"row_below",
|
|
"---------",
|
|
"col_left",
|
|
"col_right",
|
|
"---------",
|
|
"remove_row",
|
|
"remove_col",
|
|
"---------",
|
|
"undo",
|
|
"redo",
|
|
],
|
|
"editor": "text",
|
|
"stretchH": "all",
|
|
"height": 108,
|
|
"renderer": "text",
|
|
"autoColumnSize": False,
|
|
}
|
|
|
|
|
|
class TableInput(forms.HiddenInput):
|
|
def __init__(self, table_options=None, attrs=None):
|
|
self.table_options = table_options
|
|
super().__init__(attrs=attrs)
|
|
|
|
@cached_property
|
|
def media(self):
|
|
return forms.Media(
|
|
css={
|
|
"all": [
|
|
versioned_static(
|
|
"table_block/css/vendor/handsontable-6.2.2.full.min.css"
|
|
),
|
|
]
|
|
},
|
|
js=[
|
|
versioned_static(
|
|
"table_block/js/vendor/handsontable-6.2.2.full.min.js"
|
|
),
|
|
versioned_static("table_block/js/table.js"),
|
|
],
|
|
)
|
|
|
|
|
|
class TableInputAdapter(WidgetAdapter):
|
|
js_constructor = "wagtail.widgets.TableInput"
|
|
|
|
def js_args(self, widget):
|
|
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 column as a header": _(
|
|
"Display the first column as a header"
|
|
),
|
|
"Column header": _("Column 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"),
|
|
"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."
|
|
),
|
|
"Table": _("Table"),
|
|
}
|
|
|
|
return [
|
|
widget.table_options,
|
|
strings,
|
|
]
|
|
|
|
|
|
register(TableInputAdapter(), TableInput)
|
|
|
|
|
|
class TableBlock(FieldBlock):
|
|
def __init__(self, required=True, help_text=None, table_options=None, **kwargs):
|
|
"""
|
|
CharField's 'label' and 'initial' parameters are not exposed, as Block
|
|
handles that functionality natively (via 'label' and 'default')
|
|
|
|
CharField's 'max_length' and 'min_length' parameters are not exposed as table
|
|
data needs to have arbitrary length
|
|
"""
|
|
self.table_options = self.get_table_options(table_options=table_options)
|
|
self.field_options = {"required": required, "help_text": help_text}
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
@cached_property
|
|
def field(self):
|
|
return forms.CharField(
|
|
widget=TableInput(table_options=self.table_options), **self.field_options
|
|
)
|
|
|
|
def value_from_form(self, value):
|
|
return json.loads(value)
|
|
|
|
def value_for_form(self, 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 would be a breaking change.
|
|
"""
|
|
if value and 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):
|
|
# pass state to frontend as a JSON-ish dict - do not serialise to a JSON string
|
|
return value
|
|
|
|
def is_html_renderer(self):
|
|
return self.table_options["renderer"] == "html"
|
|
|
|
def get_searchable_content(self, value):
|
|
content = []
|
|
if value:
|
|
for row in value.get("data", []):
|
|
content.extend([v for v in row if v])
|
|
return content
|
|
|
|
def render(self, value, context=None):
|
|
template = getattr(self.meta, "template", None)
|
|
if template and value:
|
|
table_header = (
|
|
value["data"][0]
|
|
if value.get("data", None)
|
|
and len(value["data"]) > 0
|
|
and value.get("first_row_is_table_header", False)
|
|
else None
|
|
)
|
|
first_col_is_header = value.get("first_col_is_header", False)
|
|
|
|
if context is None:
|
|
new_context = {}
|
|
else:
|
|
new_context = dict(context)
|
|
|
|
new_context.update(
|
|
{
|
|
"self": value,
|
|
self.TEMPLATE_VAR: value,
|
|
"table_header": table_header,
|
|
"first_col_is_header": first_col_is_header,
|
|
"html_renderer": self.is_html_renderer(),
|
|
"table_caption": value.get("table_caption"),
|
|
"data": value["data"][1:]
|
|
if table_header
|
|
else value.get("data", []),
|
|
}
|
|
)
|
|
|
|
if value.get("cell"):
|
|
new_context["classnames"] = {}
|
|
new_context["hidden"] = {}
|
|
for meta in value["cell"]:
|
|
if "className" in meta:
|
|
new_context["classnames"][(meta["row"], meta["col"])] = meta[
|
|
"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)
|
|
else:
|
|
return self.render_basic(value or "", context=context)
|
|
|
|
def get_table_options(self, table_options=None):
|
|
"""
|
|
Return a dict of table options using the defaults unless custom options provided
|
|
|
|
table_options can contain any valid handsontable options:
|
|
https://handsontable.com/docs/6.2.2/Options.html
|
|
contextMenu: if value from table_options is True, still use default
|
|
language: if value is not in table_options, attempt to get from environment
|
|
"""
|
|
|
|
collected_table_options = DEFAULT_TABLE_OPTIONS.copy()
|
|
|
|
if table_options is not None:
|
|
if table_options.get("contextMenu", None) is True:
|
|
# explicitly check for True, as value could also be array
|
|
# delete to ensure the above default is kept for contextMenu
|
|
del table_options["contextMenu"]
|
|
collected_table_options.update(table_options)
|
|
|
|
if "language" not in collected_table_options:
|
|
# attempt to gather the current set language of not provided
|
|
language = translation.get_language()
|
|
collected_table_options["language"] = language
|
|
|
|
return collected_table_options
|
|
|
|
class Meta:
|
|
default = None
|
|
template = "table_block/blocks/table.html"
|
|
icon = "table"
|