Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion assets/js/app/ajax-save.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ $(document).ready(function() {
$('#toastTitle').addClass(['toast-header', typeClass]);
$('#toastNotification').append(notification);
$('#toastType').append(toastType);
$('#toastBody').append(toastMessage);
// Set as text, because validation messages can contain the value that was rejected.
$('#toastBody').text(toastMessage);

$(document).ready(function() {
let toastElList = [].slice.call(document.querySelectorAll('.toast'));
Expand All @@ -59,6 +60,27 @@ $(document).ready(function() {
});
});
}

// Shows the validation errors of a rejected save above the form, the way the classic
// (non-ajax) save shows them, see `templates/content/edit.html.twig`.
function showValidationErrors(errors) {
let container = $('<div id="editcontent-validation-errors"></div>');

errors.forEach(function(error) {
let alert = $('<div class="alert alert-danger" role="alert"></div>');
alert.text(error.property ? error.property + ': ' + error.message : error.message);
container.append(alert);
});

clearValidationErrors();
$(form).before(container);
container[0].scrollIntoView({ block: 'nearest' });
}

function clearValidationErrors() {
$('#editcontent-validation-errors').remove();
}

this.href = window.location.pathname;

let duplicate_id = this.href.substring(this.href.lastIndexOf('/') + 1);
Expand Down Expand Up @@ -86,6 +108,7 @@ $(document).ready(function() {
elementButton.prop('disabled', false);
},
success: function(data, textStatus) {
clearValidationErrors();
if (!record_id) {
window.location.replace(data.url);
} else if (window.location.pathname === '/bolt/duplicate/' + duplicate_id) {
Expand All @@ -99,6 +122,21 @@ $(document).ready(function() {
}
},
error: function(jq, status, err) {
let response = jq.responseJSON;

// The save was rejected by the content validator, which returns its
// violations as JSON, so we can show the validator's own messages.
if (jq.status === 422 && response && Array.isArray(response.errors)) {
let messages = response.errors.map(function(error) {
return error.message;
});

showValidationErrors(response.errors);
showToast(response.type, messages.join(' '), response.status, response.notification, dom_element);

return;
}

// eslint-disable-next-line no-console
console.log(status, err);
showToast();
Expand Down
31 changes: 31 additions & 0 deletions src/Controller/Backend/ContentEditController.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

Expand Down Expand Up @@ -153,6 +154,13 @@ public function save(Request $request, ?Content $originalContent = null, ?Conten
if ($enableContentValidator && $contentValidator) {
$constraintViolations = $contentValidator->validate($content);
if (count($constraintViolations) > 0) {
// When "Saving Ajaxy" the editor isn't re-rendered, so the violations that
// renderEditor() puts in the HTML would never reach the browser. Return them
// as JSON instead, for `assets/js/app/ajax-save.js` to display.
if ($request->isXmlHttpRequest()) {
return $this->renderValidationErrors($request, $constraintViolations);
}
Comment thread
mindaugasjackunaspc marked this conversation as resolved.

$this->addFlash('danger', 'content.validation_errors');

return $this->renderEditor($request, $content, $constraintViolations);
Expand Down Expand Up @@ -651,4 +659,27 @@ private function renderEditor(Request $request, Content $content, $errors = null

return $this->render('@bolt/content/edit.html.twig', $twigvars);
}

private function renderValidationErrors(Request $request, ConstraintViolationListInterface $constraintViolations): JsonResponse
{
$locale = $request->getLocale();

$errors = [];
foreach ($constraintViolations as $constraintViolation) {
$errors[] = [
'property' => $constraintViolation->getPropertyPath(),
'message' => (string) $constraintViolation->getMessage(),
];
}

return new JsonResponse(
[
'status' => 'danger',
'type' => $this->translator->trans('warning', [], null, $locale),
'notification' => $this->translator->trans('flash_messages.notification', [], null, $locale),
'errors' => $errors,
],
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
}
142 changes: 142 additions & 0 deletions tests/php/Controller/Backend/ContentEditControllerSaveTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php

declare(strict_types=1);

namespace Bolt\Tests\Controller\Backend;

use Bolt\Configuration\Config;
use Bolt\Controller\Backend\ContentEditController;
use Bolt\Controller\TwigAwareController;
use Bolt\Entity\Content;
use Bolt\Enum\Statuses;
use Bolt\Event\Listener\ContentFillListener;
use Bolt\Repository\ContentRepository;
use Bolt\Repository\MediaRepository;
use Bolt\Repository\RelationRepository;
use Bolt\Repository\TaxonomyRepository;
use Bolt\Utils\ContentHelper;
use Bolt\Validator\ContentValidatorInterface;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Symfony\Component\DependencyInjection\Container;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

class ContentEditControllerSaveTest extends TestCase
{
/**
* A save over ajax that the content validator rejects has to answer with the
* violations as JSON, because `assets/js/app/ajax-save.js` can do nothing with a
* re-rendered editor. This test fails if that response contract changes.
*/
public function testAjaxSaveReturnsTheValidationViolationsAsJson(): void
{
$controller = $this->createController();

$response = $controller->save(
$this->ajaxSaveRequest(),
new Content(),
$this->contentValidatorRejectingWith(
new ConstraintViolation('End datetime is required', null, [], null, 'end_datetime', null),
// Validators built by hand often leave the property path empty.
new ConstraintViolation('The record is not valid', null, [], null, null, null)
)
);

self::assertInstanceOf(JsonResponse::class, $response);
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());

$payload = json_decode((string) $response->getContent(), true);

self::assertSame('danger', $payload['status']);
self::assertSame('warning', $payload['type']);
self::assertSame('flash_messages.notification', $payload['notification']);
self::assertSame([
['property' => 'end_datetime', 'message' => 'End datetime is required'],
['property' => '', 'message' => 'The record is not valid'],
], $payload['errors']);
}

private function createController(): ContentEditController
{
$controller = new ContentEditController(
$this->createMock(TaxonomyRepository::class),
$this->createMock(RelationRepository::class),
$this->createMock(ContentRepository::class),
$this->createMock(MediaRepository::class),
$this->createMock(EntityManagerInterface::class),
$this->createMock(UrlGeneratorInterface::class),
$this->createMock(ContentFillListener::class),
$this->createMock(EventDispatcherInterface::class),
'en',
$this->translatorReturningKeys(),
$this->createMock(ContentHelper::class)
);

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->method('isTokenValid')->willReturn(true);
$controller->setCsrfTokenManager($csrfTokenManager);

$authorizationChecker = $this->createMock(AuthorizationCheckerInterface::class);
$authorizationChecker->method('isGranted')->willReturn(true);

$container = new Container();
$container->set('security.authorization_checker', $authorizationChecker);
$container->set('security.token_storage', $this->createMock(TokenStorageInterface::class));
$controller->setContainer($container);

// Of the collaborators that `setAutowire()` injects, this code path only reads the
// config, so that is the only one set here.
(new ReflectionProperty(TwigAwareController::class, 'config'))->setValue($controller, $this->configWithValidatorEnabled());

return $controller;
}

private function configWithValidatorEnabled(): Config
{
$config = $this->createMock(Config::class);
$config->method('get')->willReturnCallback(
static fn (string $path, $default = null) => $path === 'general/validator_options/enable' ? true : $default
);

return $config;
}

private function translatorReturningKeys(): TranslatorInterface
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnArgument(0);

return $translator;
}

private function contentValidatorRejectingWith(ConstraintViolation ...$violations): ContentValidatorInterface
{
$contentValidator = $this->createMock(ContentValidatorInterface::class);
$contentValidator->method('validate')->willReturn(new ConstraintViolationList($violations));

return $contentValidator;
}

private function ajaxSaveRequest(): Request
{
$request = Request::create('/bolt/edit/1', Request::METHOD_POST, [
'_csrf_token' => 'valid',
'_edit_locale' => 'en',
'status' => Statuses::DRAFT,
]);
$request->headers->set('X-Requested-With', 'XMLHttpRequest');

return $request;
}
}
Loading