From 3fa5e16356c3de0e56eaec08a5afdca0dd82c123 Mon Sep 17 00:00:00 2001 From: vgreb Date: Thu, 20 Aug 2026 21:40:31 +0200 Subject: [PATCH] =?UTF-8?q?refonte=20visuelle=20page=20d'adh=C3=A9sion=20d?= =?UTF-8?q?es=20entreprises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Form/CompanyMemberInvitationType.php | 15 +- .../Association/Form/CompanyMemberType.php | 35 ++- .../Model/CompanyMemberInvitation.php | 6 +- .../Website/Membership/CompanyAction.php | 13 +- .../adhesion_entreprise.html.twig | 275 +++++++++--------- .../Form/CompanyMemberTypeTest.php | 159 ++++++++++ 6 files changed, 356 insertions(+), 147 deletions(-) create mode 100644 tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php diff --git a/sources/AppBundle/Association/Form/CompanyMemberInvitationType.php b/sources/AppBundle/Association/Form/CompanyMemberInvitationType.php index 6ffb74b77..4dff7536d 100644 --- a/sources/AppBundle/Association/Form/CompanyMemberInvitationType.php +++ b/sources/AppBundle/Association/Form/CompanyMemberInvitationType.php @@ -16,8 +16,16 @@ class CompanyMemberInvitationType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('email', EmailType::class) - ->add('manager', CheckboxType::class, ['required' => false, 'label' => 'Lui partager les droits de gestion']) + // empty_data explicite : TextType le fait valoir null quand le champ n'est pas + // requis, ce qui ferait échouer setEmail(string) sur un email laissé vide. + ->add('email', EmailType::class, ['empty_data' => '']) + ->add('manager', CheckboxType::class, [ + 'required' => false, + 'label' => 'Lui partager les droits de gestion', + // disabled fait aussi ignorer la valeur soumise au profit de celle du modèle : + // le verrou résiste à un POST forgé. + 'disabled' => $options['lock_manager'], + ]) ; } @@ -25,6 +33,9 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => CompanyMemberInvitation::class, + 'lock_manager' => false, ]); + + $resolver->setAllowedTypes('lock_manager', 'bool'); } } diff --git a/sources/AppBundle/Association/Form/CompanyMemberType.php b/sources/AppBundle/Association/Form/CompanyMemberType.php index 2286b1b52..a2e0f578f 100644 --- a/sources/AppBundle/Association/Form/CompanyMemberType.php +++ b/sources/AppBundle/Association/Form/CompanyMemberType.php @@ -6,6 +6,7 @@ use AppBundle\Association\CompanyMembership\SubscriptionManagement; use AppBundle\Association\Model\CompanyMember; +use AppBundle\Association\Model\CompanyMemberInvitation; use EWZ\Bundle\RecaptchaBundle\Form\Type\EWZRecaptchaType; use EWZ\Bundle\RecaptchaBundle\Validator\Constraints\IsTrue as RecaptchaIsValid; use Symfony\Component\Form\AbstractType; @@ -16,8 +17,12 @@ use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Context\ExecutionContextInterface; class CompanyMemberType extends AbstractType { @@ -87,9 +92,26 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'entry_type' => CompanyMemberInvitationType::class, 'allow_add' => true, 'required' => false, + // Déclaré ici et non sur le modèle : CompanyMember::$invitations n'a pas + // d'Assert\Valid, la validation ne cascade donc pas vers les invitations. + 'constraints' => [ + new Callback(static function (?array $invitations, ExecutionContextInterface $context): void { + $first = $invitations[0] ?? null; + + if ($first instanceof CompanyMemberInvitation && '' !== trim($first->getEmail())) { + return; + } + + $context + ->buildViolation("L'adresse email du premier membre est obligatoire.") + ->atPath('[0].email') + ->addViolation() + ; + }), + ], ]) ->add('recaptcha', EWZRecaptchaType::class, [ - 'label' => 'Vérification', + 'label' => false, 'mapped' => false, 'constraints' => [ new RecaptchaIsValid(), @@ -97,6 +119,17 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('save', SubmitType::class, ['label' => 'saveMembership']) ; + + // Le premier membre est toujours gestionnaire. `entry_options` s'appliquant à toute + // la collection, on reconstruit l'enfant 0 ; priorité négative pour passer après + // ResizeFormListener, qui crée les enfants sur ce même évènement. + $builder->get('invitations')->addEventListener( + FormEvents::PRE_SET_DATA, + static function (FormEvent $event): void { + $event->getForm()->add('0', CompanyMemberInvitationType::class, ['lock_manager' => true]); + }, + -1, + ); } public function configureOptions(OptionsResolver $resolver): void diff --git a/sources/AppBundle/Association/Model/CompanyMemberInvitation.php b/sources/AppBundle/Association/Model/CompanyMemberInvitation.php index c856c45b4..8dfa96f02 100644 --- a/sources/AppBundle/Association/Model/CompanyMemberInvitation.php +++ b/sources/AppBundle/Association/Model/CompanyMemberInvitation.php @@ -20,8 +20,10 @@ class CompanyMemberInvitation implements NotifyPropertyInterface private int $companyId; + // Valeur par défaut : sans elle, getEmail() sur une invitation fraîchement instanciée + // (CompanyAction pré-remplit la collection) lève une erreur de propriété non initialisée. #[Assert\Email] - private string $email; + private string $email = ''; private string $token; @@ -65,7 +67,7 @@ public function getEmail(): string public function setEmail(string $email): self { - $this->propertyChanged('email', $this->email ?? null, $email); + $this->propertyChanged('email', $this->email, $email); $this->email = $email; return $this; } diff --git a/sources/AppBundle/Controller/Website/Membership/CompanyAction.php b/sources/AppBundle/Controller/Website/Membership/CompanyAction.php index 2205f261e..030700671 100644 --- a/sources/AppBundle/Controller/Website/Membership/CompanyAction.php +++ b/sources/AppBundle/Controller/Website/Membership/CompanyAction.php @@ -31,10 +31,17 @@ public function __construct( public function __invoke(Request $request): Response { + // On pré-remplit autant de lignes que le forfait par défaut en compte, pour que le + // formulaire reste utilisable sans JavaScript. Le script ne sert plus qu'à réagir aux + // changements du select « nombre de membres ». + $defaultMembers = SubscriptionManagement::AFUP_PERSONNE_MORALE_SEUIL; + $data = new CompanyMember(); - $data->setInvitations([ - new CompanyMemberInvitation()->setManager(true), - ]); + $data->setMaxMembers($defaultMembers); + $data->setInvitations(array_map( + static fn(int $index): CompanyMemberInvitation => new CompanyMemberInvitation()->setManager(0 === $index), + range(0, $defaultMembers - 1), + )); $subscribeForm = $this->createForm(CompanyMemberType::class, $data); $subscribeForm->handleRequest($request); diff --git a/templates/site/company_membership/adhesion_entreprise.html.twig b/templates/site/company_membership/adhesion_entreprise.html.twig index 987e11922..282a6beec 100644 --- a/templates/site/company_membership/adhesion_entreprise.html.twig +++ b/templates/site/company_membership/adhesion_entreprise.html.twig @@ -1,167 +1,164 @@ -{% extends 'admin/association/membership/_base.html.twig' %} +{% extends 'layouts/site.html.twig' %} +{% form_theme form 'form_themes/tailwind.html.twig' %} {% block page_title %}Adhésion entreprise à l'AFUP{% endblock %} -{% block submenu %} - {{ render(controller( - 'AppBundle\\Controller\\Website\\SecondaryMenuController::display', - { 'feuille_id': constant('Afup\\Site\\Corporate\\Feuille::ID_FEUILLE_ASSOCIATION') } - )) }} -{% endblock %} - -{% block page_content %} -
-

- Ce formulaire est réservé aux entreprises. Vous êtes un particulier ou un indépendant ? - Retrouvez le formulaire d'adhésion dédié. +{% block content %} +

+

{{ "Adhésion entreprise à l'AFUP"|trans }}

+

+ {{ 'Ce formulaire est réservé aux entreprises. Vous êtes un particulier ou un indépendant ?'|trans }} + {{ "Retrouvez le formulaire d'adhésion dédié"|trans }}

- {{ form_start(form) }} -
- Société -
+ + {{ form_start(form, {attr: {class: 'self-stretch'}}) }} + + + {{ 'Créer votre compte entreprise'|trans }} + {{ form_errors(form) }} + + {{ form_row(form.companyName) }} - {{ form_row(form.siret) }} +
+ {{ form_row(form.siret) }} + {{ form_row(form.phone) }} +
{{ form_row(form.address) }} - {{ form_row(form.zipcode) }} - {{ form_row(form.city) }} -
-
-
- Contact administratif -
- {{ form_row(form.firstName) }} - {{ form_row(form.lastName) }} +
+ {{ form_row(form.zipcode) }} + {{ form_row(form.city) }} +
+ + + +
+ {{ form_row(form.lastName) }} + {{ form_row(form.firstName) }} +
{{ form_row(form.email) }} - {{ form_row(form.phone) }} -
-
-
- Membres rattachés -
-

Chaque membre va recevoir une invitation lui permettant de créer son compte, rattaché à votre compte entreprise.

+ + + +

{{ 'Chaque membre va recevoir une invitation lui permettant de créer son compte, rattaché à votre compte entreprise.'|trans }}

{{ form_row(form.maxMembers) }} -
+
{% for invitation in form.invitations %} -
- {{ form_row(invitation.email) }} +
+ {{ form_row(invitation.email, loop.first ? {required: true} : {}) }} {{ form_row(invitation.manager) }}
{% endfor %}
-
+ -
+ + {{ form_row(form.recaptcha) }} + -
-
- {{ form_row(form.recaptcha) }} +
+

Si vous rencontrez le moindre problème, n'hésitez pas à contacter l'AFUP par email: bonjour (at) afup.org.

+ {{ form_widget(form.save, {attr: { "class": "flex flex-row justify-center items-center py-3 px-8 gap-2 bg-ruby-500 rounded-lg font-sans font-semibold text-base text-white text-center"}}) }}
-
- -
- {{ form_widget(form.save, {attr: { "class": "button button--call-to-action"}}) }} -
- {{ form_widget(form) }} + {{ form_end(form) }} +
+

{{ 'Questions fréquentes'|trans }}

+ +
+ +

{{ "Pourquoi adhérer à l'AFUP ?"|trans }}

+

+ {{ "Adhérer à l'AFUP pour une entreprise du monde PHP, c'est soutenir la communauté du langage sur lequel reposent ses outils, assurant une dynamique positive à l'écosystème. C'est également la possibilité pour vos devs de bénéficier des mailing lists réservées aux membres et faire partie d'un réseau de professionnels."|trans }} +

+
+ + +

{{ "Comment régler l'adhésion ?"|trans }}

+

+ {{ "L'adhésion peut être réglée par carte bancaire, virement ou chèque. Seule l'adhésion par carte bancaire permet une adhésion immédiate. Dans le cas des autres modes de paiement, l'adhésion est activée lors de la réception du paiement."|trans }} +

+
+ + +

{{ 'Comment gérer les membres rattachés à mon compte ?'|trans }}

+

+ {{ 'Vous devez toujours avoir au moins un membre ayant les droits de gestion sur votre compte. Ce dernier pourra gérer les membres rattachés, effectuer le règlement des adhésions et collecter les factures.'|trans }} +

+
+ + +

{{ 'Comment est faite la reconduction ?'|trans }}

+

+ {{ "L'adhésion à l'AFUP est réalisée pour un an à partir de l'enregistrement de votre cotisation. Un mois avant l'échéance, nous vous avertirons que votre adhésion arrive à expiration. Vous pourrez alors choisir de régler à nouveau votre adhésion ou la laisser expirer. 15 jours après expiration de votre adhésion, si celle-ci n'a pas été renouvelée, les comptes de vos membres seront désactivés, ils perdront alors tous les avantages de votre adhésion."|trans }} +

+
+
+
+
- - -

Si vous rencontrez le moindre problème, n'hésitez pas à contacter l'AFUP par email: bonjour (at) afup.org.

-

Questions fréquentes

- -
+ }; + + var addInvitation = function() { + var emailList = document.querySelector('#company_member_invitations'); + + // grab the prototype template + var newWidget = emailList.getAttribute('data-prototype'); + // replace the "__name__" used in the id and name of the prototype + // with a number that's unique to your emails + // end name attribute looks like name="contact[emails][2]" + newWidget = newWidget.replace(/__name__label__/g, 'Membre #' + (emailCount+1)); + newWidget = newWidget.replace(/__name__/g, emailCount); + emailCount++; + + // create a new row and add it to the list, matching the server-rendered row markup + var newRow = document.createElement('div'); + newRow.className = 'grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-3 py-3 first:pt-0 last:pb-0 sm:[&>*:last-child]:self-end'; + + // the prototype is a compound form widget, wrapped in its own container div; + // unwrap it so the email/checkbox fields become direct children of the grid row, + // matching the server-rendered rows above + var prototypeContainer = document.createElement('div'); + prototypeContainer.innerHTML = newWidget; + newRow.append(...prototypeContainer.firstElementChild.childNodes); + + emailList.appendChild(newRow); + }; + + var removeInvitation = function() + { + var emailList = document.querySelector('#company_member_invitations'); + emailList.removeChild(emailList.lastChild); + } + document.querySelector('#company_member_maxMembers').onchange(); + + // L'état du premier membre (email requis, case gestionnaire cochée et verrouillée) + // est géré côté serveur, cf. CompanyMemberType. + {% endblock %} diff --git a/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php b/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php new file mode 100644 index 000000000..1f9bc2df4 --- /dev/null +++ b/tests/unit/AppBundle/Association/Form/CompanyMemberTypeTest.php @@ -0,0 +1,159 @@ +enableAttributeMapping()->getValidator()), + ]; + } + + #[Test] + public function laCaseGestionnaireDuPremierMembreEstVerrouillee(): void + { + $form = $this->buildForm(); + + $first = $form->get('invitations')->get('0')->get('manager'); + + self::assertTrue($first->isDisabled(), 'la case du premier membre doit être verrouillée'); + self::assertTrue($first->getData(), 'le premier membre est gestionnaire par défaut'); + } + + /** + * `disabled` ne fait pas que griser la case : Symfony ignore la valeur soumise. Un POST + * forgé ne peut donc pas retirer les droits de gestion au premier membre. + */ + #[Test] + public function unPostForgeNePeutPasDeverrouillerLePremierGestionnaire(): void + { + $data = $this->newCompanyMember(); + $form = $this->buildForm($data); + + $form->submit($this->payload(['manager' => '0']), false); + + self::assertTrue($this->invitation($data, 0)->getManager()); + } + + #[Test] + public function lEmailDuPremierMembreEstObligatoire(): void + { + $form = $this->buildForm(); + $form->submit($this->payload(['email' => '']), false); + + self::assertFalse($form->isValid()); + self::assertCount(1, $form->get('invitations')->get('0')->get('email')->getErrors()); + } + + /** + * Une ligne laissée vide ne doit ni planter le mapping — setEmail() n'accepte pas null — + * ni créer une invitation fantôme. + */ + #[Test] + public function uneLigneVideEstSimplementIgnoree(): void + { + $data = $this->newCompanyMember(); + $form = $this->buildForm($data); + + $form->submit([ + 'invitations' => [ + ['email' => 'premier@example.com'], + ['email' => ''], + ], + ], false); + + self::assertCount(1, $this->invitations($data)); + self::assertSame('premier@example.com', $this->invitation($data, 0)->getEmail()); + } + + #[Test] + public function unMembreSupplementaireRenseigneEstBienRattache(): void + { + $data = $this->newCompanyMember(); + $form = $this->buildForm($data); + + $form->submit([ + 'invitations' => [ + ['email' => 'premier@example.com'], + ['email' => 'second@example.com'], + ], + ], false); + + self::assertCount(2, $this->invitations($data)); + self::assertSame('second@example.com', $this->invitation($data, 1)->getEmail()); + self::assertFalse($this->invitation($data, 1)->getManager(), 'seul le premier est gestionnaire'); + } + + private function invitation(CompanyMember $member, int $index): CompanyMemberInvitation + { + $invitations = $member->getInvitations() ?? []; + self::assertArrayHasKey($index, $invitations); + + return $invitations[$index]; + } + + /** + * @return list + */ + private function invitations(CompanyMember $member): array + { + return array_values($member->getInvitations() ?? []); + } + + /** + * @param array $overrides + * @return array + */ + private function payload(array $overrides = []): array + { + return ['invitations' => [array_merge(['email' => 'premier@example.com'], $overrides)]]; + } + + private function newCompanyMember(): CompanyMember + { + $member = new CompanyMember(); + $member->setInvitations([new CompanyMemberInvitation()->setManager(true)]); + + return $member; + } + + /** + * @return FormInterface + */ + private function buildForm(?CompanyMember $data = null): FormInterface + { + return $this->factory->create(CompanyMemberType::class, $data ?? $this->newCompanyMember()); + } +}