Browse Source

Typed table block - initial block class and client-side mechanism for adding columns/rows

Matt Westcott 3 years ago
parent
commit
614c23c9a0

+ 160 - 0
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 = $(`
+      <div class="typed-table-block ${h(this.blockDef.meta.classname || '')}">
+        <table>
+          <thead>
+            <tr><th><button type="button" data-append-column>Add columns</button></th></tr>
+          </thead>
+          <tbody>
+          </tbody>
+        </table>
+        <button type="button" data-add-row>Add row</button>
+      </div>
+    `);
+    $(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(`
+        <span>
+          <div class="help">
+            ${this.blockDef.meta.helpIcon}
+            ${this.blockDef.meta.helpText}
+          </div>
+        </span>
+      `);
+    }
+
+    this.addColumnCallback = null;
+    this.addColumnMenu = $('<ul></ul>');
+    this.blockDef.childBlockDefs.forEach(childBlockDef => {
+      const columnTypeButton = $('<button type="button"></button>').text(childBlockDef.meta.label);
+      columnTypeButton.on('click', () => {
+        if (this.addColumnCallback) this.addColumnCallback(childBlockDef);
+        this.hideAddColumnMenu();
+      });
+      const li = $('<li></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);

+ 5 - 0
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 = {};

+ 1 - 0
wagtail/contrib/typed_table_block/.gitignore

@@ -0,0 +1 @@
+static

+ 0 - 0
wagtail/contrib/typed_table_block/__init__.py


+ 79 - 0
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)