23 StreamField blocks API
Thibaud Colas edytuje tę stronę 2021-03-03 17:45:20 +00:00

Warning: This is a design document created during the design and development of the StreamField API for Wagtail 1.0, and may not be fully consistent with the final implementation as released. You are advised to cross-reference with the code: https://github.com/wagtail/wagtail/blob/main/wagtail/core/blocks/base.py

Shopping list: a worked example of the blocks API

A core aim for the StreamField development is to allow content blocks to be nested to any depth. To achieve this, each block will implement a common API that defines how that block is inserted into the edit page, along with any Javascript or other resources that it depends on. Basic block types such as text fields and image choosers will implement this; this then opens up the possibility of creating 'structural' block types such as StructBlock (which embeds a collection of sub-blocks and presents them as a single block), ListBlock (which allows a sub-block to repeated any number of times) and StreamBlock (which provides the main mechanic of StreamField by embedding a collection of sub-blocks that can be repeated and mixed freely).

As long as these structural types accept any kind of sub-block that conforms to the blocks API, and expose the blocks API themselves, it's possible to nest these structures to any depth.

This document illustrates the blocks API by showing how to implement 'shopping list' as a block type, from first principles - this is a dynamically extendable list of products (each of which is a text box labelled "Product"). In practice, a Wagtail site implementer will never need to go to this trouble to define a type like this - they would just define it as a special case of ListBlock. Rather, this document introduces the concepts that will be required to implement ListBlock itself (along with the other structural types). It will not cover recursive inclusion of blocks (here our Product text boxes will just be plain <input> fields, not blocks themselves) or how this example can be generalised to any kind of list without the need to write new Javascript on a case-by-case basis - I believe this API provides everything we need to accomplish that, though.

So let's get started...

Block definition objects

A 'block definition' object roughly corresponds to a 'widget' in Django's forms framework: it's responsible for the process of translating a value into a fragment of HTML form markup that can be used to edit that value; and turning the submitted data of such a form back into a value. (A 'value' here can be a Python data type as complex as you like, such as a Django model - but for a shopping list, this will be a plain list such as ['peas', 'carrots', 'toothpaste']). Block definition objects will generally be created as part of a content panel definition:

    ShoppingListPage.content_panels = [
        StreamField('content', block_types=[
            ('shopping_list', ShoppingListBlock(classname='polkadot'))
        ])
    ]

(This is a page consisting of a StreamField where the only block type that's available to be inserted is a shopping list.)

The constructor can take whatever parameters it likes, but the base Block class handles the parameters 'default' and 'label' as standard. Block definition objects should be considered immutable once they're created, with one exception: the parent block may assign it an internal name ('shopping_list' in this example) for its own purposes by calling set_name(name) on the block. This is not essential, and blocks must still work correctly without a name; for example, in the construction ListBlock(ShoppingListBlock(classname='polkadot')) (a list of shopping lists), the ShoppingListBlock is not given a name. As far as the block itself is concerned, the name is typically only used as a fallback label for when an explicit 'label' parameter has not been supplied. (Again, the base Block class takes care of this.)

The block API

Subclasses of Block must implement the following methods:

render_form(self, value, prefix='', errors=None)

Return the HTML for an instance of this block with the content given by 'value'. All element IDs and names must be prefixed by the given prefix. (The calling code will ensure that this prefix is unique for each individual block instance that appears in the form, in the case of repeatable blocks.)

A short aside about ID prefixing:

To avoid running into naming collisions when dealing with repeated or nested blocks, any 'id' or 'name' attributes on HTML elements generated by a block must be contained within a specific namespace that we pass to the block. When we say that IDs must be prefixed by "foo", we mean that the IDs must be either:

  • "foo" itself, or
  • "foo" followed by a '-' followed by zero or more further characters (subject to the usual validity rules for HTML identifiers).

The hyphen is important: a block which is given the prefix "foo" cannot use "foot" as an identifier.

In the case of complex blocks that have child blocks nested inside them, the parent block is responsible for allocating a subset of its namespace to each child, and ensuring that this doesn't create any name collisions. (In particular, it should avoid mixing its own internal identifiers with user-defined child ones: for example, if a block had a 'count' field and an arbitrary set of named child blocks, it cannot use the names "myprefix-count" and "myprefix-{childname}", in case there happens to be a child named "count".)

For example, render_form(['peas', 'carrots', 'toothpaste'], 'matts-shopping-list') would return something like:

<input type="text" id="matts-shopping-list-count" name="matts-shopping-list-count" value="3">
<ul id="matts-shopping-list-ul" class="polkadot">
    <li>
        <label for="matts-shopping-list-item-0">Product:</label>
        <input type="text" id="matts-shopping-list-item-0" name="matts-shopping-list-item-0" value="peas">
    </li>
    <li>
        <label for="matts-shopping-list-item-1">Product:</label>
        <input type="text" id="matts-shopping-list-item-1" name="matts-shopping-list-item-1" value="carrots">
    </li>
    <li>
        <label for="matts-shopping-list-item-2">Product:</label>
        <input type="text" id="matts-shopping-list-item-2" name="matts-shopping-list-item-2" value="toothpaste">
    </li>
    <li>
        <input type="button" id="matts-shopping-list-addnew" value="Add new">
    </li>
</ul>

Any associated Javascript will typically not be rendered at this point (although for simple scripts that don't involve nesting blocks, an inline <script> tag will work).

If an errors parameter is passed to render_form, the block is responsible for displaying the error messages in an appropriate format. This parameter will always be an ErrorList consisting of ValidationError instances that were previously thrown by the block's clean method (see below). (In practice, the ErrorList will always be of length 1, since we currently have no way of throwing multiple errors per block.)

value_from_datadict(self, data, files, prefix)

Extract data from the 'data' and 'files' dictionaries (which will usually be the request.POST and request.FILES properties of a POST request, from a form that included the HTML as output by render_form) and return it as a value (of whatever type this block is designed to handle - a list, in this case).

default

Each block must define a default value; this will be used as the initial value of any new 'empty' blocks that are created. For ShoppingListBlock, this will be an empty list:

class ShoppingListBlock(Block):
    default = []

Any default parameter passed as part of the block definition will override this, e.g. ShoppingListBlock(default=['eggs', 'milk']).


The base Block class also implements the following methods / properties, which subclasses may wish to override:

media

A Django media object specifying any external static JS/CSS files required by this block definition. The actual low-level Javascript implementation (such as making the 'add new' button spawn a new "Product" text field when clicked) would usually be written here - in a file called 'shopping_list.js', say.

html_declarations(self)

Returns a (possibly empty) string of HTML. This HTML will be included on the edit page - ONCE per block definition, regardless of how many occurrences of the block there are on the page - and it will appear in a non-specific place on the page. Any element IDs within this block of HTML must be prefixed by the block definition's definition prefix; this is a unique string that is assigned to the block definition on creation, by the base Block class, and is accessible as self.definition_prefix. (Note that this is distinct from any prefixes that may be passed to render_form; those ones have to be unique for each time the block is repeated in the HTML, whereas definition_prefix is constant throughout the lifetime of the block definition object.)

Typically this will be used to define snippets of HTML within <script type="text/x-template"></script> blocks, for our Javascript code to work with. For example, our shopping list block type might define this snippet to be dynamically inserted when you click the 'add new' button:

<script type="text/x-template" id="{{ self.definition_prefix }}-shoppinglistitem">
    <label for="__PREFIX__">Product:</label> <input type="text" id="__PREFIX__" name="__PREFIX__" value="">
</script>

js_initializer(self)

Returns a Javascript expression string, or None if this block does not require any Javascript behaviour. This expression will be evaluated once per page, and evaluates to an initializer function. This initializer function takes an element prefix (e.g. 'matts-shopping-list') and applies Javascript behaviours to the elements with that prefix (as previously rendered by self.render). In the simplest cases, where the behaviour can be defined entirely up-front in shopping_list.js, the expression string returned by js_initializer can simply be a function name:

# shopping_list_block.py
class ShoppingListBlock(Block):
    def js_initializer(self):
        return "doAwesomeAjaxyStuff"

# shopping_list.js
function doAwesomeAjaxyStuff(prefix) {
    $('#' + prefix + '-button).click(function() { alert('hello world!'); });
}

However, this isn't sufficient for our shopping list block, because the Javascript code needs to know the definition_prefix used by our <script type="text/x-template"></script> block, which is assigned on page load and not known in advance. Instead, our js_initializer will be a function call that returns the initializer function:

# shopping_list_block.py
class ShoppingListBlock(Block):
    def js_initializer(self):
        return "ShoppingList('%s')" % self.definition_prefix

# shopping_list.js
function ShoppingList(definitionPrefix) {
    var newItemHtml = $('#' + definitionPrefix + '-shoppinglistitem').html();

    var initShoppingList = function(elementPrefix) {
        var itemCountField = $('#' + elementPrefix + '-count');
        var itemCount = parseInt(itemCountField.val(), 10);

        /* Attach autocomplete to each existing field */
        for (var i = 0; i < itemCount; i++) {
            $('#' + htmlPrefix + '-item-' + i).autocomplete();
        }

        /* Make the 'add new' button work */
        $('#' + htmlPrefix + '-addnew').click(function() {
            $('#' + htmlPrefix + '-ul').append(newItemHtml);
            /* not shown here: fiddling the identifiers in newItemHtml to assign a new unique ID */
            itemCount++;
            itemCountField.val(itemCount);
        });
    };
    return initShoppingList;
}

In this way, any parameters that need to be passed from the Python code to the Javascript can be passed within js_initializer. For example, if the block was declared as:

ShoppingListPage.content_panels = [
    StreamField('shopping_lists', [ShoppingListBlock(autocomplete=True)])
]

then this can be passed to the Javascript at this point too:

ShoppingList(
    "{{ self.definition_prefix }}",
    {'autocomplete': {% if self.block_options.autocomplete %}true{% else %}false{% endif %}}
)

clean(self, value)

Validates 'value' and returns a cleaned version of it, or throw a ValidationError if validation fails. This ValidationError will ultimately be passed back to render_form when the form is re-rendered, so the 'params' attribute of the ValidationError can be used to pass additional information required for re-rendering the form.

The default implementation of clean simply returns 'value' unchanged.

get_prep_value(self, value)

Return a version of 'value' converted to JSON-serialisable data (i.e. numeric, string, list and dict values only). Our shopping list data type (a list of strings) is JSON-serialisable already, so we can just use the default implementation which returns 'value' unchanged.

to_python(self, value)

The reverse of get_prep_value; convert the JSON-serialisable data back to the block's 'native' value type. Again, ShoppingListBlock doesn't have to do anything here.

render(self, value)

Return a text rendering of 'value', suitable for display on templates. This may include HTML markup, in which case the render method is responsible for returning the result as a SafeString (having performed the necessary escaping to prevent HTML injection).

id_for_label(self, prefix)

Return the ID to use as the for attribute of <label> elements that refer to this block, when the given prefix is in use.

check(self, **kwargs)

Hook for the Django system checks framework - returns a list of django.core.checks.Error objects indicating validity errors in the block.