From 759371e6436ca462aa2edcd86b1effd779aa2b36 Mon Sep 17 00:00:00 2001 From: Mathieu De Keyzer Date: Sat, 6 Apr 2024 19:37:11 +0200 Subject: [PATCH] refactor: validate link form + anchor links (#863) Co-authored-by: David mattei --- .../assets/js/core/events/formFailEvent.js | 21 +++++++ .../core/helpers/ckeditor5-link/src/linkui.js | 3 +- .../assets/js/core/helpers/linkModal.js | 14 +++-- .../assets/js/core/plugins/form.js | 5 ++ .../Controller/Wysiwyg/ModalController.php | 41 ++++++++++++- .../src/Entity/Form/LoadLinkModalEntity.php | 59 +++++++++++++++++++ .../src/Form/Form/LoadLinkModalType.php | 30 +++++++++- .../Resources/translations/emsco-forms.en.yml | 2 + .../Resources/translations/validators.en.yml | 15 +++++ 9 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 EMS/admin-ui-bundle/assets/js/core/events/formFailEvent.js diff --git a/EMS/admin-ui-bundle/assets/js/core/events/formFailEvent.js b/EMS/admin-ui-bundle/assets/js/core/events/formFailEvent.js new file mode 100644 index 000000000..ef1cb1777 --- /dev/null +++ b/EMS/admin-ui-bundle/assets/js/core/events/formFailEvent.js @@ -0,0 +1,21 @@ +export const EMS_FORM_FAIL_EVENT_EVENT = 'emsFormFailEvent' +export class FormFailEvent { + constructor (form, response) { + this._form = form + + this._event = new CustomEvent(EMS_FORM_FAIL_EVENT_EVENT, { + detail: { + form, + response + } + }) + } + + dispatch () { + if (undefined === this._form) { + return + } + this._form.dispatchEvent(this._event) + } +} +export default FormFailEvent diff --git a/EMS/admin-ui-bundle/assets/js/core/helpers/ckeditor5-link/src/linkui.js b/EMS/admin-ui-bundle/assets/js/core/helpers/ckeditor5-link/src/linkui.js index 971092b25..46a2d84cb 100644 --- a/EMS/admin-ui-bundle/assets/js/core/helpers/ckeditor5-link/src/linkui.js +++ b/EMS/admin-ui-bundle/assets/js/core/helpers/ckeditor5-link/src/linkui.js @@ -210,8 +210,7 @@ export default class LinkUI extends Plugin { const linkCommand = editor.commands.get('link') const value = linkCommand.value || '' const target = undefined !== linkCommand.target ? linkCommand.target : null - - this.formModal.show(value, target) + this.formModal.show(value, target, editor.getData()) } /** diff --git a/EMS/admin-ui-bundle/assets/js/core/helpers/linkModal.js b/EMS/admin-ui-bundle/assets/js/core/helpers/linkModal.js index 80d49dc53..00604c6bf 100644 --- a/EMS/admin-ui-bundle/assets/js/core/helpers/linkModal.js +++ b/EMS/admin-ui-bundle/assets/js/core/helpers/linkModal.js @@ -3,6 +3,7 @@ import AddedDomEvent from '../events/addedDomEvent' import SelectLinkEvent from '../events/selectLinkEvent' import Link from './link' import { EMS_FORM_RESPONSE_EVENT_EVENT } from '../events/formResponseEvent' +import { EMS_FORM_FAIL_EVENT_EVENT } from '../events/formFailEvent' export default class LinkModal { constructor () { @@ -13,9 +14,9 @@ export default class LinkModal { }) } - show (value, target = null) { + show (value, target = null, content = null) { this.modal.show() - this._loadModal(value, target) + this._loadModal(value, target, content) } setLoading (showLoading) { @@ -39,10 +40,10 @@ export default class LinkModal { return this.modal._isShown } - _loadModal (value, target) { + _loadModal (value, target, content) { const self = this const link = new Link(value) - ajaxRequest.post(this.linkModal.dataset.modalInitUrl, { url: link.href, target }) + ajaxRequest.post(this.linkModal.dataset.modalInitUrl, { url: link.href, target, content }) .success(response => self._treatResponse(response)) } @@ -56,6 +57,7 @@ export default class LinkModal { const forms = body.querySelectorAll('form') for (let i = 0; i < forms.length; ++i) { forms[i].addEventListener(EMS_FORM_RESPONSE_EVENT_EVENT, (event) => self._onResponse(event)) + forms[i].addEventListener(EMS_FORM_FAIL_EVENT_EVENT, (event) => self._onFail(event)) } } @@ -64,4 +66,8 @@ export default class LinkModal { const selectEvent = new SelectLinkEvent(event.detail.response.url, event.detail.response.target) selectEvent.dispatch() } + + _onFail (event) { + this._treatResponse(event.detail.response) + } } diff --git a/EMS/admin-ui-bundle/assets/js/core/plugins/form.js b/EMS/admin-ui-bundle/assets/js/core/plugins/form.js index c5c852aa3..8641d6d61 100644 --- a/EMS/admin-ui-bundle/assets/js/core/plugins/form.js +++ b/EMS/admin-ui-bundle/assets/js/core/plugins/form.js @@ -4,6 +4,7 @@ import ChangeEvent from '../events/changeEvent' import DynamicForm from '../helpers/dynamic-form' import { EMS_CTRL_SAVE_EVENT } from '../events/ctrlSaveEvent' import { FormResponseEvent } from '../events/formResponseEvent' +import { FormFailEvent } from '../events/formFailEvent' import '../../../css/core/components/form.scss' class Form { @@ -43,6 +44,10 @@ class Form { const event = new FormResponseEvent(form.get(0), response) event.dispatch() }) + .fail(function (response) { + const event = new FormFailEvent(form.get(0), response) + event.dispatch() + }) } button.on('click', ajaxSave) diff --git a/EMS/core-bundle/src/Controller/Wysiwyg/ModalController.php b/EMS/core-bundle/src/Controller/Wysiwyg/ModalController.php index 7d7a6dc70..0fb28f514 100644 --- a/EMS/core-bundle/src/Controller/Wysiwyg/ModalController.php +++ b/EMS/core-bundle/src/Controller/Wysiwyg/ModalController.php @@ -6,9 +6,14 @@ use EMS\CoreBundle\Entity\Form\LoadLinkModalEntity; use EMS\CoreBundle\Form\Form\LoadLinkModalType; use EMS\CoreBundle\Service\Revision\RevisionService; +use EMS\Helpers\Html\HtmlHelper; +use EMS\Helpers\Standard\Json; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DomCrawler\Crawler; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Validator\Constraints\Callback; +use Symfony\Component\Validator\Context\ExecutionContextInterface; use Twig\Environment; class ModalController extends AbstractController @@ -25,18 +30,45 @@ public function loadLinkModal(Request $request): JsonResponse { $url = (string) $request->request->get('url', ''); $target = (string) $request->request->get('target', ''); + $content = (string) $request->request->get('content', ''); + $targets = []; + if (HtmlHelper::isHtml($content)) { + $crawler = new Crawler($content); + foreach ($crawler->filter('[id]') as $tag) { + if (null === $tag->attributes) { + continue; + } + $node = $tag->attributes->getNamedItem('id'); + if (null === $node) { + continue; + } + $id = $node->nodeValue; + $targets[$id] = "#$id"; + } + } + $anchorTargets = $request->query->get('anchorTargets'); + if (empty($targets) && \is_string($anchorTargets)) { + $targets = Json::decode($anchorTargets); + } + $loadLinkModalEntity = new LoadLinkModalEntity($url, $target); $form = $this->createForm(LoadLinkModalType::class, $loadLinkModalEntity, [ LoadLinkModalType::WITH_TARGET_BLANK_FIELD => $loadLinkModalEntity->hasTargetBlank(), + LoadLinkModalType::ANCHOR_TARGETS => $targets, + 'constraints' => [ + new Callback($this->validate(...)), + ], ]); + $form->handleRequest($request); $response = [ 'body' => $this->twig->render("@$this->templateNamespace/modal/link.html.twig", [ 'form' => $form->createView(), ]), ]; - $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { + if ($form->isSubmitted() && !$form->isValid()) { + $response['success'] = false; + } elseif ($form->isSubmitted() && $form->isValid()) { $data = $form->getData(); if (!$data instanceof LoadLinkModalEntity) { throw new \RuntimeException('Unexpected not LoadLinkModalEntity submitted data'); @@ -60,4 +92,9 @@ public function emsLinkInfo(Request $request): JsonResponse 'label' => $this->revisionService->display($link), ]); } + + public function validate(LoadLinkModalEntity $loadLinkModalEntity, ExecutionContextInterface $context): void + { + $loadLinkModalEntity->validate($context); + } } diff --git a/EMS/core-bundle/src/Entity/Form/LoadLinkModalEntity.php b/EMS/core-bundle/src/Entity/Form/LoadLinkModalEntity.php index 5707e6379..db3e9c06e 100644 --- a/EMS/core-bundle/src/Entity/Form/LoadLinkModalEntity.php +++ b/EMS/core-bundle/src/Entity/Form/LoadLinkModalEntity.php @@ -8,6 +8,7 @@ use EMS\CommonBundle\Helper\EmsFields; use EMS\CoreBundle\Form\Form\LoadLinkModalType; use EMS\Helpers\Standard\Type; +use Symfony\Component\Validator\Context\ExecutionContextInterface; final class LoadLinkModalEntity { @@ -21,6 +22,7 @@ final class LoadLinkModalEntity private ?string $body = null; /** @var array{sha1: string, filename: string|null, mimetype: string|null}|null */ private ?array $file = null; + private ?string $anchor = null; public function __construct(private readonly string $url, string $target) { @@ -38,6 +40,9 @@ public function __construct(private readonly string $url, string $target) $this->subject = Type::string($query['subject'] ?? ''); $this->body = Type::string($query['body'] ?? ''); $this->linkType = LoadLinkModalType::LINK_TYPE_MAILTO; + } elseif (\str_starts_with($this->url, '#')) { + $this->anchor = $this->url; + $this->linkType = LoadLinkModalType::LINK_TYPE_ANCHOR; } else { $this->href = $this->url; $this->linkType = LoadLinkModalType::LINK_TYPE_URL; @@ -150,6 +155,10 @@ public function generateUrl(): ?string return "ems://asset:$hash?name=$name&type=$type"; } + + return null; + case LoadLinkModalType::LINK_TYPE_ANCHOR: + return $this->anchor; } throw new \RuntimeException(\sprintf('Unsupported %s link type', $this->linkType)); } @@ -169,4 +178,54 @@ public function setFile(?array $file): void { $this->file = $file; } + + public function getAnchor(): ?string + { + return $this->anchor; + } + + public function setAnchor(?string $anchor): void + { + $this->anchor = $anchor; + } + + public function validate(ExecutionContextInterface $context): void + { + switch ($this->getLinkType()) { + case LoadLinkModalType::LINK_TYPE_INTERNAL: + if ('' === ($this->dataLink ?? '')) { + $context->buildViolation('modal.link.data_link.mandatory')->atPath(LoadLinkModalType::FIELD_DATA_LINK)->addViolation(); + } + + return; + case LoadLinkModalType::LINK_TYPE_URL: + if ('' === ($this->url ?? '')) { + $context->buildViolation('modal.link.url.mandatory')->atPath(LoadLinkModalType::FIELD_HREF)->addViolation(); + } + + return; + case LoadLinkModalType::LINK_TYPE_FILE: + if (null === ($this->file[EmsFields::CONTENT_FILE_HASH_FIELD] ?? null)) { + $context->buildViolation('modal.link.file.mandatory')->atPath(LoadLinkModalType::FIELD_FILE)->addViolation(); + } + + return; + case LoadLinkModalType::LINK_TYPE_MAILTO: + if ('' === ($this->mailto ?? '')) { + $context->buildViolation('modal.link.mailto.mandatory')->atPath(LoadLinkModalType::FIELD_MAILTO)->addViolation(); + } + + return; + case LoadLinkModalType::LINK_TYPE_ANCHOR: + if ('' === ($this->anchor ?? '')) { + $context->buildViolation('modal.link.anchor.mandatory')->atPath(LoadLinkModalType::FIELD_ANCHOR)->addViolation(); + } elseif (!\str_starts_with($this->anchor ?? '', '#')) { + $context->buildViolation('modal.link.anchor.format')->atPath(LoadLinkModalType::FIELD_ANCHOR)->addViolation(); + } + + return; + default: + $context->buildViolation('modal.link.link_type.unknown')->atPath(LoadLinkModalType::FIELD_LINK_TYPE)->addViolation(); + } + } } diff --git a/EMS/core-bundle/src/Form/Form/LoadLinkModalType.php b/EMS/core-bundle/src/Form/Form/LoadLinkModalType.php index eda5c1ee2..062ff9739 100644 --- a/EMS/core-bundle/src/Form/Form/LoadLinkModalType.php +++ b/EMS/core-bundle/src/Form/Form/LoadLinkModalType.php @@ -17,6 +17,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Validator\Constraints\Email; class LoadLinkModalType extends AbstractType { @@ -24,6 +25,7 @@ class LoadLinkModalType extends AbstractType public const LINK_TYPE_INTERNAL = 'internal'; public const LINK_TYPE_FILE = 'file'; public const LINK_TYPE_MAILTO = 'mailto'; + public const LINK_TYPE_ANCHOR = 'anchor'; public const FIELD_LINK_TYPE = 'linkType'; public const FIELD_HREF = 'href'; public const FIELD_DATA_LINK = 'dataLink'; @@ -31,9 +33,11 @@ class LoadLinkModalType extends AbstractType public const FIELD_SUBJECT = 'subject'; public const FIELD_BODY = 'body'; public const FIELD_FILE = 'file'; + public const FIELD_ANCHOR = 'anchor'; public const FIELD_TARGET_BLANK = 'targetBlank'; public const FIELD_SUBMIT = 'submit'; public const WITH_TARGET_BLANK_FIELD = 'with_target_blank_field'; + public const ANCHOR_TARGETS = 'anchor_targets'; public function __construct(private readonly RouterInterface $router) { @@ -55,6 +59,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'link_modal.link_type.internal' => self::LINK_TYPE_INTERNAL, 'link_modal.link_type.file' => self::LINK_TYPE_FILE, 'link_modal.link_type.mailto' => self::LINK_TYPE_MAILTO, + 'link_modal.link_type.anchor' => self::LINK_TYPE_ANCHOR, ], ]) ->add(self::FIELD_HREF, TextType::class, [ @@ -96,6 +101,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'value' => self::LINK_TYPE_MAILTO, ]]), ], + 'constraints' => [ + new Email(), + ], ]) ->add(self::FIELD_SUBJECT, TextType::class, [ 'label' => 'link_modal.field.subject', @@ -136,6 +144,23 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'value' => self::LINK_TYPE_FILE, ]]), ], + ]) + ->add(self::FIELD_ANCHOR, ChoiceType::class, [ + 'label' => 'link_modal.field.anchor', + 'attr' => ['data-tags' => true, 'class' => 'select2'], + 'choices' => $options[self::ANCHOR_TARGETS], + 'multiple' => false, + 'choice_translation_domain' => false, + 'required' => false, + 'row_attr' => [ + 'data-show-hide' => 'show', + 'data-all-any' => 'any', + 'data-rules' => Json::encode([[ + 'field' => \sprintf('[%s]', self::FIELD_LINK_TYPE), + 'condition' => 'is', + 'value' => self::LINK_TYPE_ANCHOR, + ]]), + ], ]); if (true === ($options[self::WITH_TARGET_BLANK_FIELD] ?? false)) { @@ -158,7 +183,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'link_modal.field.submit', 'attr' => [ 'class' => 'btn btn-primary', - 'data-ajax-save-url' => $this->router->generate(Routes::WYSIWYG_MODAL_LOAD_LINK), + 'data-ajax-save-url' => $this->router->generate(Routes::WYSIWYG_MODAL_LOAD_LINK, [ + 'anchorTargets' => Json::encode($options[self::ANCHOR_TARGETS]), + ]), ], ]); } @@ -168,6 +195,7 @@ public function configureOptions(OptionsResolver $resolver): void $resolver ->setDefaults([ self::WITH_TARGET_BLANK_FIELD => false, + self::ANCHOR_TARGETS => [], 'translation_domain' => EMSCoreBundle::TRANS_FORM_DOMAIN, 'attr' => [ 'class' => 'dynamic-form', diff --git a/EMS/core-bundle/src/Resources/translations/emsco-forms.en.yml b/EMS/core-bundle/src/Resources/translations/emsco-forms.en.yml index 2410895b5..a37a2b2cd 100644 --- a/EMS/core-bundle/src/Resources/translations/emsco-forms.en.yml +++ b/EMS/core-bundle/src/Resources/translations/emsco-forms.en.yml @@ -1,5 +1,6 @@ link_modal: field: + anchor: 'Anchor' file: 'File' link_type: 'Link type' data_link: 'Internal document' @@ -10,6 +11,7 @@ link_modal: submit: 'Set link' target_blank: 'Open links in a new tab (_blank)' link_type: + anchor: 'Anchor' url: 'URL' internal: 'Internal Link' file: 'File' diff --git a/EMS/core-bundle/src/Resources/translations/validators.en.yml b/EMS/core-bundle/src/Resources/translations/validators.en.yml index 97fed9fb9..49cdf409c 100644 --- a/EMS/core-bundle/src/Resources/translations/validators.en.yml +++ b/EMS/core-bundle/src/Resources/translations/validators.en.yml @@ -1,3 +1,18 @@ +modal: + link: + anchor: + mandatory: 'A target anchor is required' + format: 'An anchor must starts by a #' + mailto: + mandatory: 'A target email address is required' + url: + mandatory: 'A target URL is required' + data_link: + mandatory: 'A target document is required' + file: + mandatory: 'A target file is required' + link_type: + unknown: 'Type of link unknown' revision: raw_data: version_from_required: 'This Field is not valid! Empty field'