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)