Allow StructBlocks to be collapsible

pull/13215/head
Sage Abdullah 2025-07-08 17:32:51 +01:00 zatwierdzone przez Thibaud Colas
rodzic 885d0a8e47
commit b548bf88bc
8 zmienionych plików z 1448 dodań i 10 usunięć

Wyświetl plik

@ -9,6 +9,7 @@ interface PanelProps {
};
blockTypeIcon: string;
blockTypeLabel: string;
collapsed?: boolean;
}
/**
@ -30,11 +31,12 @@ export class CollapsiblePanel {
blockDef,
blockTypeIcon,
blockTypeLabel,
collapsed,
} = this.props;
// Keep in sync with wagtailadmin/shared/panel.html
template.innerHTML = /* html */ `
<section class="w-panel w-panel--nested" id="${panelId}" aria-labelledby="${headingId}" data-panel>
<section class="w-panel w-panel--nested${collapsed ? ' collapsed' : ''}" id="${panelId}" aria-labelledby="${headingId}" data-panel>
<div class="w-panel__header">
<a class="w-panel__anchor w-panel__anchor--prefix" href="#${panelId}" aria-labelledby="${headingId}" data-panel-anchor>
<svg class="icon icon-link w-panel__icon" aria-hidden="true">

Wyświetl plik

@ -6,6 +6,8 @@ import {
addErrorMessages,
removeErrorMessages,
} from '../../../includes/streamFieldErrors';
import { CollapsiblePanel } from './CollapsiblePanel';
import { initCollapsiblePanel } from '../../../includes/panels';
export class StructBlock {
constructor(blockDef, placeholder, prefix, initialState, initialError) {
@ -34,12 +36,32 @@ export class StructBlock {
});
this.container = dom;
} else {
const dom = $(`
let container = '';
// null or undefined means not collapsible
const collapsible = blockDef.meta.collapsed != null;
if (collapsible) {
container = new CollapsiblePanel({
panelId: prefix + '-section',
headingId: prefix + '-heading',
contentId: prefix + '-content',
blockTypeIcon: h(blockDef.meta.icon),
blockTypeLabel: h(blockDef.meta.label),
collapsed: blockDef.meta.collapsed,
}).render().outerHTML;
}
let dom = $(`
<div class="${h(this.blockDef.meta.classname || '')}">
</div>
`);
dom.append(container);
$(placeholder).replaceWith(dom);
if (collapsible) {
initCollapsiblePanel(dom.find('[data-panel-toggle]')[0]);
dom = dom.find(`#${prefix}-content`);
}
if (this.blockDef.meta.helpText) {
// help text is left unescaped as per Django conventions
dom.append(`
@ -52,13 +74,26 @@ export class StructBlock {
}
this.blockDef.childBlockDefs.forEach((childBlockDef) => {
const childDom = $(`
<div data-contentpath="${childBlockDef.name}">
<label class="w-field__label">${h(childBlockDef.meta.label)}${
const isCollapsibleStructBlock =
// Cannot use `instanceof StructBlockDefinition` here as it is defined
// later in this file. Compare our own blockDef constructor instead.
childBlockDef instanceof this.blockDef.constructor &&
childBlockDef.meta.collapsed != null;
// Collapsible struct blocks have their own header, so only add the label
// if this is not a collapsible struct block.
let label = '';
if (!isCollapsibleStructBlock) {
label = `<label class="w-field__label">${h(childBlockDef.meta.label)}${
childBlockDef.meta.required
? '<span class="w-required-mark">*</span>'
: ''
}</label>
}</label>`;
}
const childDom = $(`
<div data-contentpath="${childBlockDef.name}">
${label}
<div data-streamfield-block></div>
</div>
`);

Wyświetl plik

@ -184,6 +184,370 @@ describe('telepath: wagtail.blocks.StructBlock', () => {
});
});
describe('telepath: wagtail.blocks.StructBlock with collapsible panel', () => {
let boundBlock;
const setup = (collapsed = true) => {
// Define a test block
const blockDef = new StructBlockDefinition(
'settings_block',
[
new FieldBlockDefinition(
'accent_color',
new DummyWidgetDefinition('Accent color widget'),
{
label: 'Accent color',
required: false,
icon: 'placeholder',
classname: 'w-field w-field--char_field w-field--text_input',
},
),
new FieldBlockDefinition(
'font_size',
new DummyWidgetDefinition('Font size widget'),
{
label: 'Font size',
required: false,
icon: 'placeholder',
classname: 'w-field w-field--choice_field w-field--select',
},
),
],
{
label: 'Settings block',
required: false,
icon: 'title',
classname: 'struct-block',
helpText: 'configure how the block is <strong>displayed</strong>',
helpIcon: '<svg></svg>',
collapsed,
},
);
// Render it
document.body.innerHTML = '<div id="placeholder"></div>';
boundBlock = blockDef.render($('#placeholder'), 'the-prefix', {
accent_color: 'Test accent color',
font_size: '16px',
});
};
beforeEach(() => {
// Create mocks for callbacks
constructor = jest.fn();
setState = jest.fn();
getState = jest.fn();
getValue = jest.fn();
focus = jest.fn();
setup();
});
test('it renders correctly with initially collapsed state', () => {
expect(document.body.innerHTML).toMatchSnapshot();
// Check that the panel can be expanded by clicking the toggle button
const button = document.querySelector('[data-panel-toggle]');
expect(button).toBeTruthy();
button.click();
// Check that the panel is now expanded
expect(document.body.innerHTML).toMatchSnapshot();
});
test('it renders correctly with initially expanded state', () => {
// Setup with initially expanded state (collapsed = False)
setup(false);
expect(document.body.innerHTML).toMatchSnapshot();
// Check that the panel can be expanded by clicking the toggle button
const button = document.querySelector('[data-panel-toggle]');
expect(button).toBeTruthy();
button.click();
// Check that the panel is now expanded
expect(document.body.innerHTML).toMatchSnapshot();
});
test('Widget constructors are called with correct parameters', () => {
expect(constructor.mock.calls.length).toBe(2);
expect(constructor.mock.calls[0][0]).toBe('Accent color widget');
expect(constructor.mock.calls[0][1]).toEqual({
name: 'the-prefix-accent_color',
id: 'the-prefix-accent_color',
initialState: 'Test accent color',
});
expect(constructor.mock.calls[1][0]).toBe('Font size widget');
expect(constructor.mock.calls[1][1]).toEqual({
name: 'the-prefix-font_size',
id: 'the-prefix-font_size',
initialState: '16px',
});
});
test('getValue() calls getValue() on all widgets', () => {
const value = boundBlock.getValue();
expect(getValue.mock.calls.length).toBe(2);
expect(value).toEqual({
accent_color: 'value: Accent color widget - the-prefix-accent_color',
font_size: 'value: Font size widget - the-prefix-font_size',
});
});
test('getState() calls getState() on all widgets', () => {
const state = boundBlock.getState();
expect(getState.mock.calls.length).toBe(2);
expect(state).toEqual({
accent_color: 'state: Accent color widget - the-prefix-accent_color',
font_size: 'state: Font size widget - the-prefix-font_size',
});
});
test('setState() calls setState() on all widgets', () => {
boundBlock.setState({
accent_color: 'Changed accent color',
font_size: '456',
});
expect(setState.mock.calls.length).toBe(2);
expect(setState.mock.calls[0][0]).toBe('Accent color widget');
expect(setState.mock.calls[0][1]).toBe('Changed accent color');
expect(setState.mock.calls[1][0]).toBe('Font size widget');
expect(setState.mock.calls[1][1]).toBe('456');
});
test('focus() calls focus() on first widget', () => {
boundBlock.focus();
expect(focus.mock.calls.length).toBe(1);
expect(focus.mock.calls[0][0]).toBe('Accent color widget');
});
test('getTextLabel() returns text label of first widget', () => {
expect(boundBlock.getTextLabel()).toBe('label: the-prefix-accent_color');
});
test('setError passes error messages to children', () => {
boundBlock.setError({
blockErrors: {
font_size: { messages: ['This is too big.'] },
},
});
expect(document.body.innerHTML).toMatchSnapshot();
});
test('setError shows non-block errors', () => {
boundBlock.setError({
messages: ['This is just generally wrong.'],
});
expect(document.body.innerHTML).toMatchSnapshot();
});
});
describe('telepath: wagtail.blocks.StructBlock with nested collapsible panel', () => {
let boundBlock;
beforeEach(() => {
// Create mocks for callbacks
constructor = jest.fn();
setState = jest.fn();
getState = jest.fn();
getValue = jest.fn();
focus = jest.fn();
// Define a test block
const settingsBlockDef = new StructBlockDefinition(
'settings',
[
new FieldBlockDefinition(
'accent_color',
new DummyWidgetDefinition('Accent color widget'),
{
label: 'Accent color',
required: false,
icon: 'placeholder',
classname: 'w-field w-field--char_field w-field--text_input',
},
),
new FieldBlockDefinition(
'font_size',
new DummyWidgetDefinition('Font size widget'),
{
label: 'Font size',
required: false,
icon: 'placeholder',
classname: 'w-field w-field--choice_field w-field--select',
},
),
],
{
label: 'Settings block',
required: false,
icon: 'title',
classname: 'struct-block',
helpText: 'configure how the block is <strong>displayed</strong>',
helpIcon: '<svg></svg>',
collapsed: true, // Initially collapsed
},
);
const blockDef = new StructBlockDefinition(
'heading_block',
[
new FieldBlockDefinition(
'heading_text',
new DummyWidgetDefinition('Heading widget'),
{
label: 'Heading text',
required: true,
icon: 'placeholder',
classname: 'w-field w-field--char_field w-field--text_input',
},
),
new FieldBlockDefinition(
'size',
new DummyWidgetDefinition('Size widget'),
{
label: 'Size',
required: false,
icon: 'placeholder',
classname: 'w-field w-field--choice_field w-field--select',
},
),
settingsBlockDef,
],
{
label: 'Heading block',
required: false,
icon: 'title',
classname: 'struct-block',
helpText: 'use <strong>lots</strong> of these',
helpIcon: '<svg></svg>',
},
);
// Render it
document.body.innerHTML = '<div id="placeholder"></div>';
boundBlock = blockDef.render($('#placeholder'), 'the-prefix', {
heading_text: 'Test heading text',
size: '123',
settings: {
accent_color: 'Test accent color',
font_size: '16px',
},
});
});
test('it renders correctly with initially collapsed state', () => {
expect(document.body.innerHTML).toMatchSnapshot();
// Check that the panel can be expanded by clicking the toggle button
const button = document.querySelector('[data-panel-toggle]');
expect(button).toBeTruthy();
button.click();
// Check that the panel is now expanded
expect(document.body.innerHTML).toMatchSnapshot();
});
test('it renders correctly with initially expanded state', () => {
expect(document.body.innerHTML).toMatchSnapshot();
// Check that the panel can be expanded by clicking the toggle button
const button = document.querySelector('[data-panel-toggle]');
expect(button).toBeTruthy();
button.click();
// Check that the panel is now expanded
expect(document.body.innerHTML).toMatchSnapshot();
});
test('Widget constructors are called with correct parameters', () => {
expect(constructor.mock.calls.length).toBe(4);
expect(constructor.mock.calls[0][0]).toBe('Heading widget');
expect(constructor.mock.calls[0][1]).toEqual({
name: 'the-prefix-heading_text',
id: 'the-prefix-heading_text',
initialState: 'Test heading text',
});
expect(constructor.mock.calls[1][0]).toBe('Size widget');
expect(constructor.mock.calls[1][1]).toEqual({
name: 'the-prefix-size',
id: 'the-prefix-size',
initialState: '123',
});
});
test('getValue() calls getValue() on all widgets', () => {
const value = boundBlock.getValue();
expect(getValue.mock.calls.length).toBe(4);
expect(value).toEqual({
heading_text: 'value: Heading widget - the-prefix-heading_text',
size: 'value: Size widget - the-prefix-size',
settings: {
accent_color:
'value: Accent color widget - the-prefix-settings-accent_color',
font_size: 'value: Font size widget - the-prefix-settings-font_size',
},
});
});
test('getState() calls getState() on all widgets', () => {
const state = boundBlock.getState();
expect(getState.mock.calls.length).toBe(4);
expect(state).toEqual({
heading_text: 'state: Heading widget - the-prefix-heading_text',
size: 'state: Size widget - the-prefix-size',
settings: {
accent_color:
'state: Accent color widget - the-prefix-settings-accent_color',
font_size: 'state: Font size widget - the-prefix-settings-font_size',
},
});
});
test('setState() calls setState() on all widgets', () => {
boundBlock.setState({
heading_text: 'Changed heading text',
size: '456',
});
expect(setState.mock.calls.length).toBe(2);
expect(setState.mock.calls[0][0]).toBe('Heading widget');
expect(setState.mock.calls[0][1]).toBe('Changed heading text');
expect(setState.mock.calls[1][0]).toBe('Size widget');
expect(setState.mock.calls[1][1]).toBe('456');
});
test('focus() calls focus() on first widget', () => {
boundBlock.focus();
expect(focus.mock.calls.length).toBe(1);
expect(focus.mock.calls[0][0]).toBe('Heading widget');
});
test('getTextLabel() returns text label of first widget', () => {
expect(boundBlock.getTextLabel()).toBe('label: the-prefix-heading_text');
});
test('setError passes error messages to children', () => {
boundBlock.setError({
blockErrors: {
size: { messages: ['This is too big.'] },
},
});
expect(document.body.innerHTML).toMatchSnapshot();
});
test('setError shows non-block errors', () => {
boundBlock.setError({
messages: ['This is just generally wrong.'],
});
expect(document.body.innerHTML).toMatchSnapshot();
});
});
describe('telepath: wagtail.blocks.StructBlock with formTemplate', () => {
let boundBlock;
let blockDefWithBadLabelFormat;

Wyświetl plik

@ -26,18 +26,35 @@ You can then provide custom CSS for this block, targeted at the specified classn
Wagtail's editor styling has some built-in styling for the `struct-block` class and other related elements. If you specify a value for `form_classname`, it will overwrite the classes that are already applied to `StructBlock`, so you must remember to specify the `struct-block` as well.
```
In addition, the `StructBlock`'s `Meta` class also accepts a `collapsed` attribute. When set to `None` (the default), the block is not collapsible. When set to `True` or `False`, the block is wrapped in a collapsible panel and initially displayed in a collapsed or expanded state in the editing interface, respectively. This can be useful for blocks with many sub-blocks, or blocks that are not expected to be edited frequently.
```python
class PersonBlock(blocks.StructBlock):
first_name = blocks.CharBlock()
surname = blocks.CharBlock()
photo = ImageChooserBlock(required=False)
biography = blocks.RichTextBlock()
class Meta:
icon = 'user'
collapsed = True # This block will be initially collapsed
```
For more extensive customizations that require changes to the HTML markup as well, you can override the `form_template` attribute in `Meta` to specify your own template path. The following variables are available on this template:
**`children`**
**`children`**\
An `OrderedDict` of `BoundBlock`s for all of the child blocks making up this `StructBlock`.
**`help_text`**
**`help_text`**\
The help text for this block, if specified.
**`classname`**
**`classname`**\
The class name passed as `form_classname` (defaults to `struct-block`).
**`block_definition`**
**`collapsed`**\
The initial collapsible state of the block (defaults to `None`). Note that the collapsible panel wrapper is not automatically applied to the block's form template. You must write your own wrapper if you want the block to be collapsible.
**`block_definition`**\
The `StructBlock` instance that defines this block.
**`prefix`**

Wyświetl plik

@ -494,6 +494,7 @@ All block definitions have the following methods and properties that can be over
:param form_classname: An HTML ``class`` attribute to set on the root element of this block as displayed in the editing interface. Defaults to ``struct-block``; note that the admin interface has CSS styles defined on this class, so it is advised to include ``struct-block`` in this value when overriding. See :ref:`custom_editing_interfaces_for_structblock`.
:param form_template: Path to a Django template to use to render this block's form. See :ref:`custom_editing_interfaces_for_structblock`.
:param collapsed: When ``None`` (the default), the block is not collapsible. When ``True`` or ``False``, the block is collapsible and initially displayed in a collapsed or expanded state in the editing interface, respectively. This can be useful for blocks with many sub-blocks, or blocks that are not expected to be edited frequently. See :ref:`custom_editing_interfaces_for_structblock`.
:param value_class: A subclass of ``wagtail.blocks.StructValue`` to use as the type of returned values for this block. See :ref:`custom_value_class_for_structblock`.
:param search_index: If false (default true), the content of this block will not be indexed for searching.
:param label_format:

Wyświetl plik

@ -378,6 +378,7 @@ class BaseStructBlock(Block):
),
"help_text": getattr(self.meta, "help_text", None),
"classname": self.meta.form_classname,
"collapsed": self.meta.collapsed,
"block_definition": self,
"prefix": prefix,
}
@ -392,6 +393,7 @@ class BaseStructBlock(Block):
form_template = None
value_class = StructValue
label_format = None
collapsed = None
# No icon specified here, because that depends on the purpose that the
# block is being used for. Feel encouraged to specify an icon in your
# descendant block type
@ -414,6 +416,7 @@ class StructBlockAdapter(Adapter):
"blockDefId": block.definition_prefix,
"isPreviewable": block.is_previewable,
"classname": block.meta.form_classname,
"collapsed": block.meta.collapsed,
}
help_text = getattr(block.meta, "help_text", None)

Wyświetl plik

@ -2090,6 +2090,7 @@ class TestStructBlock(SimpleTestCase):
"blockDefId": block.definition_prefix,
"isPreviewable": block.is_previewable,
"classname": "struct-block",
"collapsed": None,
},
)
@ -2122,6 +2123,7 @@ class TestStructBlock(SimpleTestCase):
"blockDefId": block.definition_prefix,
"isPreviewable": block.is_previewable,
"classname": "struct-block",
"collapsed": None,
"formTemplate": "<div>Hello</div>",
},
)
@ -2149,6 +2151,7 @@ class TestStructBlock(SimpleTestCase):
"blockDefId": block.definition_prefix,
"isPreviewable": block.is_previewable,
"classname": "struct-block",
"collapsed": None,
"formTemplate": "<div>Hello</div>",
},
)
@ -2185,6 +2188,7 @@ class TestStructBlock(SimpleTestCase):
"blockDefId": block.definition_prefix,
"isPreviewable": block.is_previewable,
"classname": "struct-block",
"collapsed": None,
"helpIcon": (
'<svg class="icon icon-help default" aria-hidden="true">'
'<use href="#icon-help"></use></svg>'
@ -2213,6 +2217,7 @@ class TestStructBlock(SimpleTestCase):
"blockDefId": block.definition_prefix,
"isPreviewable": block.is_previewable,
"classname": "struct-block",
"collapsed": None,
"helpIcon": (
'<svg class="icon icon-help default" aria-hidden="true">'
'<use href="#icon-help"></use></svg>'
@ -2221,6 +2226,21 @@ class TestStructBlock(SimpleTestCase):
},
)
def test_adapt_with_collapsed(self):
class LinkBlock(blocks.StructBlock):
title = blocks.CharBlock()
link = blocks.URLBlock()
cases = [None, False, True]
for case in cases:
with self.subTest(collapsed=case):
block = LinkBlock(collapsed=case)
block.set_name("test_structblock")
js_args = StructBlockAdapter().js_args(block)
self.assertIs(js_args[2]["collapsed"], case)
def test_searchable_content(self):
class LinkBlock(blocks.StructBlock):
title = blocks.CharBlock()