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"