Fix inconsistent StreamField ValidationError nesting

Fixes #7086. As per https://github.com/wagtail/wagtail/issues/7086#issuecomment-826945031, ensure that .as_data() is consistently called when telepath-packing ErrorList objects (so that we preserve any embedded ValidationError objects instead of casting them to strings), and introduce an explicit ValidationError class on the client side to make mismatches more obvious (and for future extensibility in case we need to attach more fancy logic to ValidationError).

Also add tests for setError, and fix rendering of StreamBlock non-field errors (selector to clear old errors was incorrect, and jest apparently doesn't support innerText).
pull/7118/head
Matt Westcott 2021-04-26 19:08:04 +01:00
rodzic 53e55d28e6
commit 1061caa5ef
14 zmienionych plików z 385 dodań i 10 usunięć

Wyświetl plik

@ -27,7 +27,7 @@ export class FieldBlock {
// eslint-disable-next-line no-console
console.error(e);
this.setError([
['This widget failed to render, please check the console for details']
{ messages: ['This widget failed to render, please check the console for details'] }
]);
return;
}
@ -78,7 +78,7 @@ export class FieldBlock {
const errorElement = document.createElement('p');
errorElement.classList.add('error-message');
errorElement.innerHTML = errorList.map(error => `<span>${h(error[0])}</span>`).join('');
errorElement.innerHTML = errorList.map(error => `<span>${h(error.messages[0])}</span>`).join('');
this.element.querySelector('.field-content').appendChild(errorElement);
} else {
this.element.classList.remove('error');

Wyświetl plik

@ -41,6 +41,12 @@ class DummyWidgetDefinition {
}
}
class ValidationError {
constructor(messages) {
this.messages = messages;
}
}
describe('telepath: wagtail.blocks.FieldBlock', () => {
let boundBlock;
@ -117,6 +123,14 @@ describe('telepath: wagtail.blocks.FieldBlock', () => {
expect(focus.mock.calls.length).toBe(1);
expect(focus.mock.calls[0][0]).toBe('The widget');
});
test('setError() renders errors', () => {
boundBlock.setError([
new ValidationError(['Field must not contain the letter E']),
new ValidationError(['Field must contain a story about kittens']),
]);
expect(document.body.innerHTML).toMatchSnapshot();
});
});
describe('telepath: wagtail.blocks.FieldBlock with comments enabled', () => {

Wyświetl plik

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { FieldBlockDefinition } from './FieldBlock';
import { ListBlockDefinition } from './ListBlock';
import { ListBlockDefinition, ListBlockValidationError } from './ListBlock';
import $ from 'jquery';
window.$ = $;
@ -37,6 +37,12 @@ class DummyWidgetDefinition {
}
}
class ValidationError {
constructor(messages) {
this.messages = messages;
}
}
describe('telepath: wagtail.blocks.ListBlock', () => {
let boundBlock;
@ -202,4 +208,14 @@ describe('telepath: wagtail.blocks.ListBlock', () => {
expect(document.body.innerHTML).toMatchSnapshot();
});
test('setError passes error messages to children', () => {
boundBlock.setError([
new ListBlockValidationError([
null,
[new ValidationError(['Not as good as the first one'])],
]),
]);
expect(document.body.innerHTML).toMatchSnapshot();
});
});

Wyświetl plik

@ -330,15 +330,15 @@ export class StreamBlock extends BaseSequenceBlock {
// Non block errors
const container = this.container[0];
container.querySelectorAll(':scope > .help-block .help-critical').forEach(element => element.remove());
container.querySelectorAll(':scope > .help-block.help-critical').forEach(element => element.remove());
if (error.nonBlockErrors.length > 0) {
// Add a help block for each error raised
error.nonBlockErrors.forEach(errorText => {
error.nonBlockErrors.forEach(nonBlockError => {
const errorElement = document.createElement('p');
errorElement.classList.add('help-block');
errorElement.classList.add('help-critical');
errorElement.innerText = errorText;
errorElement.innerHTML = h(nonBlockError.messages[0]);
container.insertBefore(errorElement, container.childNodes[0]);
});
}

Wyświetl plik

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { FieldBlockDefinition } from './FieldBlock';
import { StreamBlockDefinition } from './StreamBlock';
import { StreamBlockDefinition, StreamBlockValidationError } from './StreamBlock';
import $ from 'jquery';
window.$ = $;
@ -37,6 +37,12 @@ class DummyWidgetDefinition {
}
}
class ValidationError {
constructor(messages) {
this.messages = messages;
}
}
describe('telepath: wagtail.blocks.StreamBlock', () => {
let boundBlock;
@ -272,6 +278,21 @@ describe('telepath: wagtail.blocks.StreamBlock', () => {
expect(document.body.innerHTML).toMatchSnapshot();
});
test('setError renders error messages', () => {
boundBlock.setError([
new StreamBlockValidationError(
[
/* non-block error */
new ValidationError(['At least three blocks are required']),
],
{
/* block error */
1: [new ValidationError(['Not as good as the first one'])],
}),
]);
expect(document.body.innerHTML).toMatchSnapshot();
});
});
describe('telepath: wagtail.blocks.StreamBlock with labels that need escaping', () => {

Wyświetl plik

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { FieldBlockDefinition } from './FieldBlock';
import { StructBlockDefinition } from './StructBlock';
import { StructBlockDefinition, StructBlockValidationError } from './StructBlock';
import $ from 'jquery';
window.$ = $;
@ -37,6 +37,12 @@ class DummyWidgetDefinition {
}
}
class ValidationError {
constructor(messages) {
this.messages = messages;
}
}
describe('telepath: wagtail.blocks.StructBlock', () => {
let boundBlock;
@ -152,6 +158,15 @@ describe('telepath: wagtail.blocks.StructBlock', () => {
expect(focus.mock.calls.length).toBe(1);
expect(focus.mock.calls[0][0]).toBe('Heading widget');
});
test('setError passes error messages to children', () => {
boundBlock.setError([
new StructBlockValidationError({
size: [new ValidationError(['This is too big'])],
}),
]);
expect(document.body.innerHTML).toMatchSnapshot();
});
});
describe('telepath: wagtail.blocks.StructBlock with formTemplate', () => {

Wyświetl plik

@ -22,6 +22,17 @@ exports[`telepath: wagtail.blocks.FieldBlock it renders correctly 1`] = `
</div>"
`;
exports[`telepath: wagtail.blocks.FieldBlock setError() renders errors 1`] = `
"<div class=\\"field char_field widget-text_input fieldname-test_charblock error\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix\\" id=\\"the-prefix\\">The widget</p>
<span></span>
</div>
<p class=\\"help\\">drink <em>more</em> water</p><p class=\\"error-message\\"><span>Field must not contain the letter E</span><span>Field must contain a story about kittens</span></p></div>
</div>"
`;
exports[`telepath: wagtail.blocks.FieldBlock with comments enabled it renders correctly 1`] = `
"<div class=\\"field char_field widget-text_input fieldname-test_charblock\\">
<div class=\\"field-content\\">

Wyświetl plik

@ -591,3 +591,112 @@ exports[`telepath: wagtail.blocks.ListBlock it renders correctly 1`] = `
</button></div>
</div>"
`;
exports[`telepath: wagtail.blocks.ListBlock setError passes error messages to children 1`] = `
"<span>
<div class=\\"help\\">
<div class=\\"icon-help\\">?</div>
use <strong>a few</strong> of these
</div>
</span><div class=\\"c-sf-container \\">
<input type=\\"hidden\\" name=\\"the-prefix-count\\" data-streamfield-list-count=\\"\\" value=\\"2\\">
<div data-streamfield-list-container=\\"\\"><button type=\\"button\\" title=\\"Add\\" data-streamfield-list-add=\\"\\" class=\\"c-sf-add-button c-sf-add-button--visible\\">
<i aria-hidden=\\"true\\">+</i>
</button><div aria-hidden=\\"false\\" data-contentpath-disabled=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-deleted\\" value=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-order\\" value=\\"0\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-type\\" value=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-id\\" value=\\"\\">
<div>
<div class=\\"c-sf-container__block-container\\">
<div class=\\"c-sf-block\\">
<div data-block-header=\\"\\" class=\\"c-sf-block__header c-sf-block__header--collapsible\\">
<span class=\\"c-sf-block__header__icon\\">
<i class=\\"icon icon-pilcrow\\"></i>
</span>
<h3 data-block-title=\\"\\" class=\\"c-sf-block__header__title\\"></h3>
<div class=\\"c-sf-block__actions\\">
<span class=\\"c-sf-block__type\\"></span>
<button type=\\"button\\" data-move-up-button=\\"\\" class=\\"c-sf-block__actions__single\\" disabled=\\"\\" title=\\"Move up\\">
<i class=\\"icon icon-arrow-up\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-move-down-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Move down\\">
<i class=\\"icon icon-arrow-down\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-duplicate-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Duplicate\\">
<i class=\\"icon icon-duplicate\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-delete-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Delete\\">
<i class=\\"icon icon-bin\\" aria-hidden=\\"true\\"></i>
</button>
</div>
</div>
<div data-block-content=\\"\\" class=\\"c-sf-block__content\\" aria-hidden=\\"false\\">
<div class=\\"c-sf-block__content-inner\\">
<div class=\\"field char_field widget-admin_auto_height_text_input fieldname-\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix-0-value\\" id=\\"the-prefix-0-value\\">The widget</p>
<span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><button type=\\"button\\" title=\\"Add\\" data-streamfield-list-add=\\"\\" class=\\"c-sf-add-button c-sf-add-button--visible\\">
<i aria-hidden=\\"true\\">+</i>
</button><div aria-hidden=\\"false\\" data-contentpath-disabled=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-deleted\\" value=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-order\\" value=\\"1\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-type\\" value=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-id\\" value=\\"\\">
<div>
<div class=\\"c-sf-container__block-container\\">
<div class=\\"c-sf-block\\">
<div data-block-header=\\"\\" class=\\"c-sf-block__header c-sf-block__header--collapsible\\">
<span class=\\"c-sf-block__header__icon\\">
<i class=\\"icon icon-pilcrow\\"></i>
</span>
<h3 data-block-title=\\"\\" class=\\"c-sf-block__header__title\\"></h3>
<div class=\\"c-sf-block__actions\\">
<span class=\\"c-sf-block__type\\"></span>
<button type=\\"button\\" data-move-up-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Move up\\">
<i class=\\"icon icon-arrow-up\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-move-down-button=\\"\\" class=\\"c-sf-block__actions__single\\" disabled=\\"\\" title=\\"Move down\\">
<i class=\\"icon icon-arrow-down\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-duplicate-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Duplicate\\">
<i class=\\"icon icon-duplicate\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-delete-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Delete\\">
<i class=\\"icon icon-bin\\" aria-hidden=\\"true\\"></i>
</button>
</div>
</div>
<div data-block-content=\\"\\" class=\\"c-sf-block__content\\" aria-hidden=\\"false\\">
<div class=\\"c-sf-block__content-inner\\">
<div class=\\"field char_field widget-admin_auto_height_text_input fieldname- error\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix-1-value\\" id=\\"the-prefix-1-value\\">The widget</p>
<span></span>
</div>
<p class=\\"error-message\\"><span>Not as good as the first one</span></p></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><button type=\\"button\\" title=\\"Add\\" data-streamfield-list-add=\\"\\" class=\\"c-sf-add-button c-sf-add-button--visible\\">
<i aria-hidden=\\"true\\">+</i>
</button></div>
</div>"
`;

Wyświetl plik

@ -677,6 +677,129 @@ exports[`telepath: wagtail.blocks.StreamBlock it renders menus on opening 1`] =
</div>"
`;
exports[`telepath: wagtail.blocks.StreamBlock setError renders error messages 1`] = `
"<span>
<div class=\\"help\\">
<div class=\\"icon-help\\">?</div>
use <strong>plenty</strong> of these
</div>
</span><div class=\\"c-sf-container \\"><p class=\\"help-block help-critical\\">At least three blocks are required</p>
<input type=\\"hidden\\" name=\\"the-prefix-count\\" data-streamfield-stream-count=\\"\\" value=\\"2\\">
<div data-streamfield-stream-container=\\"\\"><div>
<button data-streamblock-menu-open=\\"\\" type=\\"button\\" title=\\"Add\\" class=\\"c-sf-add-button c-sf-add-button--visible\\">
<i aria-hidden=\\"true\\">+</i>
</button>
<div data-streamblock-menu-outer=\\"\\" style=\\"display: none;\\" aria-hidden=\\"true\\">
<div data-streamblock-menu-inner=\\"\\" class=\\"c-sf-add-panel\\"></div>
</div>
</div><div aria-hidden=\\"false\\" data-contentpath=\\"1\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-deleted\\" value=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-order\\" value=\\"0\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-type\\" value=\\"test_block_a\\">
<input type=\\"hidden\\" name=\\"the-prefix-0-id\\" value=\\"1\\">
<div>
<div class=\\"c-sf-container__block-container\\">
<div class=\\"c-sf-block\\">
<div data-block-header=\\"\\" class=\\"c-sf-block__header c-sf-block__header--collapsible\\">
<span class=\\"c-sf-block__header__icon\\">
<i class=\\"icon icon-placeholder\\"></i>
</span>
<h3 data-block-title=\\"\\" class=\\"c-sf-block__header__title\\"></h3>
<div class=\\"c-sf-block__actions\\">
<span class=\\"c-sf-block__type\\">Test Block A</span>
<button type=\\"button\\" data-move-up-button=\\"\\" class=\\"c-sf-block__actions__single\\" disabled=\\"\\" title=\\"Move up\\">
<i class=\\"icon icon-arrow-up\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-move-down-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Move down\\">
<i class=\\"icon icon-arrow-down\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-duplicate-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Duplicate\\">
<i class=\\"icon icon-duplicate\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-delete-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Delete\\">
<i class=\\"icon icon-bin\\" aria-hidden=\\"true\\"></i>
</button>
</div>
</div>
<div data-block-content=\\"\\" class=\\"c-sf-block__content\\" aria-hidden=\\"false\\">
<div class=\\"c-sf-block__content-inner\\">
<div class=\\"field char_field widget-text_input fieldname-test_charblock\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix-0-value\\" id=\\"the-prefix-0-value\\">Block A widget</p>
<span></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><div>
<button data-streamblock-menu-open=\\"\\" type=\\"button\\" title=\\"Add\\" class=\\"c-sf-add-button c-sf-add-button--visible\\">
<i aria-hidden=\\"true\\">+</i>
</button>
<div data-streamblock-menu-outer=\\"\\" style=\\"display: none;\\" aria-hidden=\\"true\\">
<div data-streamblock-menu-inner=\\"\\" class=\\"c-sf-add-panel\\"></div>
</div>
</div><div aria-hidden=\\"false\\" data-contentpath=\\"2\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-deleted\\" value=\\"\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-order\\" value=\\"1\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-type\\" value=\\"test_block_b\\">
<input type=\\"hidden\\" name=\\"the-prefix-1-id\\" value=\\"2\\">
<div>
<div class=\\"c-sf-container__block-container\\">
<div class=\\"c-sf-block\\">
<div data-block-header=\\"\\" class=\\"c-sf-block__header c-sf-block__header--collapsible\\">
<span class=\\"c-sf-block__header__icon\\">
<i class=\\"icon icon-pilcrow\\"></i>
</span>
<h3 data-block-title=\\"\\" class=\\"c-sf-block__header__title\\"></h3>
<div class=\\"c-sf-block__actions\\">
<span class=\\"c-sf-block__type\\">Test Block B</span>
<button type=\\"button\\" data-move-up-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Move up\\">
<i class=\\"icon icon-arrow-up\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-move-down-button=\\"\\" class=\\"c-sf-block__actions__single\\" disabled=\\"\\" title=\\"Move down\\">
<i class=\\"icon icon-arrow-down\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-duplicate-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Duplicate\\">
<i class=\\"icon icon-duplicate\\" aria-hidden=\\"true\\"></i>
</button>
<button type=\\"button\\" data-delete-button=\\"\\" class=\\"c-sf-block__actions__single\\" title=\\"Delete\\">
<i class=\\"icon icon-bin\\" aria-hidden=\\"true\\"></i>
</button>
</div>
</div>
<div data-block-content=\\"\\" class=\\"c-sf-block__content\\" aria-hidden=\\"false\\">
<div class=\\"c-sf-block__content-inner\\">
<div class=\\"field char_field widget-admin_auto_height_text_input fieldname-test_textblock error\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix-1-value\\" id=\\"the-prefix-1-value\\">Block B widget</p>
<span></span>
</div>
<p class=\\"error-message\\"><span>Not as good as the first one</span></p></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div><div>
<button data-streamblock-menu-open=\\"\\" type=\\"button\\" title=\\"Add\\" class=\\"c-sf-add-button c-sf-add-button--visible\\">
<i aria-hidden=\\"true\\">+</i>
</button>
<div data-streamblock-menu-outer=\\"\\" style=\\"display: none;\\" aria-hidden=\\"true\\">
<div data-streamblock-menu-inner=\\"\\" class=\\"c-sf-add-panel\\"></div>
</div>
</div></div>
</div>"
`;
exports[`telepath: wagtail.blocks.StreamBlock with labels that need escaping it renders correctly 1`] = `
"<span>
<div class=\\"help\\">

Wyświetl plik

@ -32,6 +32,38 @@ exports[`telepath: wagtail.blocks.StructBlock it renders correctly 1`] = `
</div></div>"
`;
exports[`telepath: wagtail.blocks.StructBlock setError passes error messages to children 1`] = `
"<div class=\\"struct-block\\">
<span>
<div class=\\"help\\">
<div class=\\"icon-help\\">?</div>
use <strong>lots</strong> of these
</div>
</span>
<div class=\\"field required\\" data-contentpath=\\"heading_text\\">
<label class=\\"field__label\\" for=\\"the-prefix-heading_text\\">Heading text</label>
<div class=\\"field char_field widget-text_input fieldname-heading_text\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix-heading_text\\" id=\\"the-prefix-heading_text\\">Heading widget</p>
<span></span>
</div>
</div>
</div>
</div><div class=\\"field \\" data-contentpath=\\"size\\">
<label class=\\"field__label\\" for=\\"the-prefix-size\\">Size</label>
<div class=\\"field choice_field widget-select fieldname-size error\\">
<div class=\\"field-content\\">
<div class=\\"input\\">
<p name=\\"the-prefix-size\\" id=\\"the-prefix-size\\">Size widget</p>
<span></span>
</div>
<p class=\\"error-message\\"><span>This is too big</span></p></div>
</div>
</div></div>"
`;
exports[`telepath: wagtail.blocks.StructBlock with formTemplate it renders correctly 1`] = `
"<div class=\\"custom-form-template\\">
<p>here comes the first field:</p>

Wyświetl plik

@ -235,3 +235,10 @@ class AdminDateTimeInput extends BaseDateTimeWidget {
initChooserFn = window.initDateTimeChooser;
}
window.telepath.register('wagtail.widgets.AdminDateTimeInput', AdminDateTimeInput);
class ValidationError {
constructor(messages) {
this.messages = messages;
}
}
window.telepath.register('wagtail.errors.ValidationError', ValidationError);

Wyświetl plik

@ -38,7 +38,7 @@ class StreamBlockValidationErrorAdapter(Adapter):
js_constructor = 'wagtail.blocks.StreamBlockValidationError'
def js_args(self, error):
return [error.non_block_errors, {
return [error.non_block_errors.as_data(), {
block_id: child_errors.as_data()
for block_id, child_errors in error.block_errors.items()
}]

Wyświetl plik

@ -27,7 +27,15 @@ class StructBlockValidationErrorAdapter(Adapter):
js_constructor = 'wagtail.blocks.StructBlockValidationError'
def js_args(self, error):
return [error.block_errors]
if error.block_errors is None:
return [None]
else:
return [
{
name: error_list.as_data()
for name, error_list in error.block_errors.items()
}
]
@cached_property
def media(self):

Wyświetl plik

@ -5,6 +5,7 @@ and extract field values.
"""
from django import forms
from django.core.exceptions import ValidationError
from django.utils.functional import cached_property
from wagtail.admin.staticfiles import versioned_static
@ -49,3 +50,21 @@ class RadioSelectAdapter(WidgetAdapter):
register(RadioSelectAdapter(), forms.RadioSelect)
class ValidationErrorAdapter(Adapter):
js_constructor = 'wagtail.errors.ValidationError'
def js_args(self, error):
return [
error.messages,
]
@cached_property
def media(self):
return forms.Media(js=[
versioned_static('wagtailadmin/js/telepath/widgets.js'),
])
register(ValidationErrorAdapter(), ValidationError)