diff --git a/client/src/entrypoints/contrib/typed_table_block/typed_table_block.js b/client/src/entrypoints/contrib/typed_table_block/typed_table_block.js
new file mode 100644
index 0000000000..493be22a28
--- /dev/null
+++ b/client/src/entrypoints/contrib/typed_table_block/typed_table_block.js
@@ -0,0 +1,160 @@
+/* global $ */
+
+import { escapeHtml as h } from '../../../utils/text';
+
+
+export class TypedTableBlock {
+ constructor(blockDef, placeholder, prefix, initialState, initialError) {
+ const state = initialState || {};
+ this.blockDef = blockDef;
+ this.type = blockDef.name;
+ this.columns = [];
+
+ const dom = $(`
+
+ `);
+ $(placeholder).replaceWith(dom);
+ this.thead = dom.find('table > thead').get(0);
+ this.tbody = dom.find('table > tbody').get(0);
+ this.appendColumnButton = dom.find('button[data-append-column]');
+ this.addRowButton = dom.find('button[data-add-row]');
+ this.addRowButton.hide();
+
+ if (this.blockDef.meta.helpText) {
+ // help text is left unescaped as per Django conventions
+ dom.append(`
+
+
+ ${this.blockDef.meta.helpIcon}
+ ${this.blockDef.meta.helpText}
+
+
+ `);
+ }
+
+ this.addColumnCallback = null;
+ this.addColumnMenu = $('');
+ this.blockDef.childBlockDefs.forEach(childBlockDef => {
+ const columnTypeButton = $('').text(childBlockDef.meta.label);
+ columnTypeButton.on('click', () => {
+ if (this.addColumnCallback) this.addColumnCallback(childBlockDef);
+ this.hideAddColumnMenu();
+ });
+ const li = $('').append(columnTypeButton);
+ this.addColumnMenu.append(li);
+ });
+ this.addColumnMenuBaseElement = null; // the element the add-column menu is attached to
+
+ this.appendColumnButton.on('click', () => {
+ this.toggleAddColumnMenu(this.appendColumnButton, (chosenBlockDef) => {
+ this.insertColumn(this.columns.length, chosenBlockDef);
+ });
+ });
+
+ this.addRowButton.on('click', () => {
+ this.addRow();
+ });
+ }
+
+ showAddColumnMenu(baseElement, callback) {
+ this.addColumnMenuBaseElement = baseElement;
+ baseElement.after(this.addColumnMenu);
+ this.addColumnMenu.show();
+ this.addColumnCallback = callback;
+ }
+ hideAddColumnMenu() {
+ this.addColumnMenu.hide();
+ this.addColumnMenuBaseElement = null;
+ }
+ toggleAddColumnMenu(baseElement, callback) {
+ if (this.addColumnMenuBaseElement === baseElement) {
+ this.hideAddColumnMenu();
+ } else {
+ this.showAddColumnMenu(baseElement, callback);
+ }
+ }
+ insertColumn(index, blockDef) {
+ const column = {
+ blockDef,
+ };
+ this.columns.splice(index, 0, column);
+ Array.from(this.thead.children).forEach(tr => {
+ const cells = tr.children;
+ const newCell = document.createElement('th');
+ tr.insertBefore(newCell, cells[index]);
+ });
+ Array.from(this.tbody.children).forEach(tr => {
+ const cells = tr.children;
+ const newCell = document.createElement('td');
+ tr.insertBefore(newCell, cells[index]);
+ this.initCell(newCell, blockDef);
+ });
+ /* after first column is added, enable adding rows */
+ this.addRowButton.show();
+ this.appendColumnButton.text('+');
+ /* if no rows exist, add an initial one */
+ if (this.tbody.children.length === 0) {
+ this.addRow();
+ }
+ }
+ addRow() {
+ const newRow = document.createElement('tr');
+ this.tbody.appendChild(newRow);
+ this.columns.forEach(column => {
+ const newCell = document.createElement('td');
+ newRow.appendChild(newCell);
+ this.initCell(newCell, column.blockDef);
+ });
+ }
+ initCell(cell, blockDef) {
+ const placeholder = document.createElement('div');
+ cell.appendChild(placeholder);
+ blockDef.render(placeholder, 'asdf', null, null);
+ }
+
+ setState(state) {
+ }
+
+ setError(errorList) {
+ if (errorList.length !== 1) {
+ return;
+ }
+ const error = errorList[0];
+ }
+
+ getState() {
+ }
+
+ getValue() {
+ }
+
+ getTextLabel(opts) {
+ // no usable label found
+ return null;
+ }
+
+ focus(opts) {
+ }
+}
+
+export class TypedTableBlockDefinition {
+ constructor(name, childBlockDefs, meta) {
+ this.name = name;
+ this.childBlockDefs = childBlockDefs;
+ this.meta = meta;
+ }
+
+ render(placeholder, prefix, initialState, initialError) {
+ return new TypedTableBlock(this, placeholder, prefix, initialState, initialError);
+ }
+}
+window.telepath.register('wagtail.contrib.typed_table_block.blocks.TypedTableBlock', TypedTableBlockDefinition);
diff --git a/client/webpack.config.js b/client/webpack.config.js
index d8edbd7503..0871f91b9f 100644
--- a/client/webpack.config.js
+++ b/client/webpack.config.js
@@ -9,6 +9,8 @@ const getOutputPath = (app, filename) => {
appLabel = 'wagtaildocs';
} else if (app === 'contrib/table_block') {
appLabel = 'table_block';
+ } else if (app === 'contrib/typed_table_block') {
+ appLabel = 'typed_table_block';
}
return path.join('wagtail', app, 'static', appLabel, 'js', filename);
@@ -70,6 +72,9 @@ module.exports = function exports() {
'contrib/table_block': [
'table',
],
+ 'contrib/typed_table_block': [
+ 'typed_table_block',
+ ],
};
const entry = {};
diff --git a/wagtail/contrib/typed_table_block/.gitignore b/wagtail/contrib/typed_table_block/.gitignore
new file mode 100644
index 0000000000..7b4d4ba2e6
--- /dev/null
+++ b/wagtail/contrib/typed_table_block/.gitignore
@@ -0,0 +1 @@
+static
diff --git a/wagtail/contrib/typed_table_block/__init__.py b/wagtail/contrib/typed_table_block/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/wagtail/contrib/typed_table_block/blocks.py b/wagtail/contrib/typed_table_block/blocks.py
new file mode 100644
index 0000000000..c6d2b4226e
--- /dev/null
+++ b/wagtail/contrib/typed_table_block/blocks.py
@@ -0,0 +1,79 @@
+from django import forms
+from django.utils.functional import cached_property
+
+from wagtail.admin.staticfiles import versioned_static
+from wagtail.core.blocks.base import Block, DeclarativeSubBlocksMetaclass, get_help_icon
+from wagtail.core.telepath import Adapter, register
+
+
+class BaseTypedTableBlock(Block):
+ def __init__(self, local_blocks=None, **kwargs):
+ self._constructor_kwargs = kwargs
+
+ super().__init__(**kwargs)
+
+ # create a local (shallow) copy of base_blocks so that it can be supplemented by local_blocks
+ self.child_blocks = self.base_blocks.copy()
+ if local_blocks:
+ for name, block in local_blocks:
+ block.set_name(name)
+ self.child_blocks[name] = block
+
+ def deconstruct(self):
+ """
+ Always deconstruct TypedTableBlock instances as if they were plain TypedTableBlock with all
+ of the field definitions passed to the constructor - even if in reality this is a subclass
+ with the fields defined declaratively, or some combination of the two.
+
+ This ensures that the field definitions get frozen into migrations, rather than leaving a
+ reference to a custom subclass in the user's models.py that may or may not stick around.
+ """
+ path = 'wagtail.contrib.typed_table_block.blocks.TypedTableBlock'
+ args = [list(self.child_blocks.items())]
+ kwargs = self._constructor_kwargs
+ return (path, args, kwargs)
+
+ def check(self, **kwargs):
+ errors = super().check(**kwargs)
+ for name, child_block in self.child_blocks.items():
+ errors.extend(child_block.check(**kwargs))
+ errors.extend(child_block._check_name(**kwargs))
+
+ return errors
+
+ class Meta:
+ default = None
+ icon = "table"
+
+
+class TypedTableBlock(BaseTypedTableBlock, metaclass=DeclarativeSubBlocksMetaclass):
+ pass
+
+
+class TypedTableBlockAdapter(Adapter):
+ js_constructor = 'wagtail.contrib.typed_table_block.blocks.TypedTableBlock'
+
+ def js_args(self, block):
+ meta = {
+ 'label': block.label, 'required': block.required, 'icon': block.meta.icon,
+ }
+
+ help_text = getattr(block.meta, 'help_text', None)
+ if help_text:
+ meta['helpText'] = help_text
+ meta['helpIcon'] = get_help_icon()
+
+ return [
+ block.name,
+ block.child_blocks.values(),
+ meta,
+ ]
+
+ @cached_property
+ def media(self):
+ return forms.Media(js=[
+ versioned_static('typed_table_block/js/typed_table_block.js'),
+ ])
+
+
+register(TypedTableBlockAdapter(), TypedTableBlock)