diff --git a/.changes/unreleased/Feature-20231116-150900.yaml b/.changes/unreleased/Feature-20231116-150900.yaml new file mode 100644 index 000000000..9d4c134d6 --- /dev/null +++ b/.changes/unreleased/Feature-20231116-150900.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Allow user to add a phonenumber to their profile which will be included in automatically + generated documents +time: 2023-11-16T15:09:00.369359598+01:00 +custom: + Issue: "173" diff --git a/src/Bundle/ChillMainBundle/Controller/UserProfileController.php b/src/Bundle/ChillMainBundle/Controller/UserProfileController.php new file mode 100644 index 000000000..52aba1a48 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Controller/UserProfileController.php @@ -0,0 +1,65 @@ +getUser(); + $editForm = $this->createPhonenumberEditForm($user); + $editForm->handleRequest($request); + + if ($editForm->isSubmitted() && $editForm->isValid()) { + $phonenumber = $editForm->get('phonenumber')->getData(); + + $user->setPhonenumber($phonenumber); + + $this->getDoctrine()->getManager()->flush(); + $this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!')); + + return $this->redirectToRoute('chill_main_user_profile'); + } + + return $this->render('@ChillMain/User/profile.html.twig', [ + 'user' => $user, + 'form' => $editForm->createView(), + ]); + } + + private function createPhonenumberEditForm(UserInterface $user): FormInterface + { + return $this->createForm( + UserPhonenumberType::class, + $user, + ) + ->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]); + } +} diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 01655fa3d..23dfe6926 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -18,9 +18,11 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; +use libphonenumber\PhoneNumber; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; /** * User. @@ -161,6 +163,15 @@ class User implements UserInterface, \Stringable */ private ?string $usernameCanonical = null; + /** + * The user's mobile phone number. + * + * @ORM\Column(type="phone_number", nullable=true) + * + * @PhonenumberConstraint() + */ + private ?PhoneNumber $phonenumber = null; + /** * User constructor. */ @@ -419,6 +430,11 @@ class User implements UserInterface, \Stringable } } + public function getPhonenumber(): ?PhoneNumber + { + return $this->phonenumber; + } + /** * @throws \RuntimeException if the groupCenter is not in the collection */ @@ -639,4 +655,11 @@ class User implements UserInterface, \Stringable return $this; } + + public function setPhonenumber(?PhoneNumber $phonenumber): self + { + $this->phonenumber = $phonenumber; + + return $this; + } } diff --git a/src/Bundle/ChillMainBundle/Form/UserPhonenumberType.php b/src/Bundle/ChillMainBundle/Form/UserPhonenumberType.php new file mode 100644 index 000000000..579829b84 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/UserPhonenumberType.php @@ -0,0 +1,36 @@ +add('phonenumber', ChillPhoneNumberType::class, [ + 'required' => false, + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Bundle/ChillMainBundle/Form/UserType.php b/src/Bundle/ChillMainBundle/Form/UserType.php index cb5e05c13..9d62fbc6a 100644 --- a/src/Bundle/ChillMainBundle/Form/UserType.php +++ b/src/Bundle/ChillMainBundle/Form/UserType.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Form\Type\ChillDateType; +use Chill\MainBundle\Form\Type\ChillPhoneNumberType; use Chill\MainBundle\Form\Type\PickCivilityType; use Chill\MainBundle\Templating\TranslatableStringHelper; use Doctrine\ORM\EntityRepository; @@ -44,6 +45,9 @@ class UserType extends AbstractType ->add('email', EmailType::class, [ 'required' => true, ]) + ->add('phonenumber', ChillPhoneNumberType::class, [ + 'required' => false, + ]) ->add('label', TextType::class) ->add('civility', PickCivilityType::class, [ 'required' => false, diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig new file mode 100644 index 000000000..360d748a5 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig @@ -0,0 +1,58 @@ +{# +* Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, + / +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see . +#} + + +{% extends "@ChillMain/layout.html.twig" %} + + +{% block title %}{{"My profile"|trans}}{% endblock %} + +{% block content %} +
+

{{ 'user.profile.title'|trans }}

+ + +
+
{{ 'Job'|trans }}
+ {% if user.getUserJob is not null %} +
{{ user.getUserJob.label|localize_translatable_string }}
+ {% else %} +
{{ 'user.profile.no job'|trans }}
+ {% endif %} +
{{ 'Scope'|trans }}
+ {% if user.getMainScope is not null %} +
{{ user.getMainScope.name|localize_translatable_string }}
+ {% else %} +
{{ 'user.profile.no scope'|trans }}
+ {% endif %} +
+
+ {{ form_start(form) }} + {{ form_row(form.phonenumber) }} + +
    +
  • + {{ form_widget(form.submit, { 'attr': { 'class': 'btn btn-save' } } ) }} +
  • +
+ + {{ form_end(form) }} +
+
+ +{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php index 2c28ff2e8..3427face3 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/UserMenuBuilder.php @@ -29,6 +29,14 @@ class UserMenuBuilder implements LocalMenuBuilderInterface $user = $this->security->getUser(); if ($user instanceof User) { + $menu->addChild($this->translator->trans('user.profile.title'), [ + 'route' => 'chill_main_user_profile', + ]) + ->setExtras([ + 'order' => -11_111_111, + 'icon' => 'user', + ]); + if (null !== $user->getCurrentLocation()) { $locationTextMenu = $user->getCurrentLocation()->getName(); } else { diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php index 8a80f1492..55a887001 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Templating\Entity\UserRender; +use libphonenumber\PhoneNumber; use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -34,6 +35,7 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware 'text_without_absent' => '', 'label' => '', 'email' => '', + 'isAbsent' => false, ]; public function __construct(private readonly UserRender $userRender) {} @@ -61,9 +63,13 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware $context, ['docgen:expects' => Civility::class, 'groups' => 'docgen:read'] ); + $phonenumberContext = array_merge( + $context, + ['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read'] + ); if (null === $object && 'docgen' === $format) { - return [...self::NULL_USER, 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)]; + return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)]; } $data = [ @@ -74,6 +80,7 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware 'text_without_absent' => $this->userRender->renderString($object, ['absence' => false]), 'label' => $object->getLabel(), 'email' => (string) $object->getEmail(), + 'phonenumber' => $this->normalizer->normalize($object->getPhonenumber(), $format, $phonenumberContext), 'user_job' => $this->normalizer->normalize($object->getUserJob(), $format, $userJobContext), 'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext), 'main_scope' => $this->normalizer->normalize($object->getMainScope(), $format, $scopeContext), diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/UserProfileControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/UserProfileControllerTest.php new file mode 100644 index 000000000..fd7fd6028 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Controller/UserProfileControllerTest.php @@ -0,0 +1,33 @@ +getClientAuthenticated(); + + $client->request('GET', '/fr/main/user/my-profile'); + $this->assertResponseIsSuccessful('Request GET /main/user/my-profile was successful'); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php new file mode 100644 index 000000000..123f1c3ae --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php @@ -0,0 +1,140 @@ +setUsername('SomeUser') + ->setLabel('SomeUser') + ->setPhonenumber(PhoneNumberUtil::getInstance()->parse('+32475928635')) + ->setEmail('some.user@chill.com'); + + $userNoPhone + ->setUsername('AnotherUser') + ->setLabel('AnotherUser'); + + yield [$user, 'docgen', ['docgen:expects' => User::class], + [ + 'id' => $user->getId(), // id + 'type' => 'user', // type + 'username' => 'SomeUser', // username + 'email' => 'some.user@chill.com', // email + 'text' => 'SomeUser', // text + 'label' => 'SomeUser', // label + 'phonenumber' => ['context' => PhoneNumber::class], // phonenumber + 'main_scope' => ['context' => Scope::class], // scope + 'user_job' => ['context' => UserJob::class], // user job + 'current_location' => ['context' => Location::class], // curent location + 'main_location' => ['context' => Location::class], // main location + 'civility' => ['context' => Civility::class], // civility + 'text_without_absent' => 'SomeUser', + 'isAbsent' => false, + 'main_center' => ['context' => Center::class], + ]]; + + yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class], + [ + 'id' => $user->getId(), // id + 'type' => 'user', // type + 'username' => 'AnotherUser', // username + 'email' => '', // email + 'text' => 'AnotherUser', // text + 'label' => 'AnotherUser', // label + 'phonenumber' => ['context' => PhoneNumber::class], // phonenumber + 'main_scope' => ['context' => Scope::class], // scope + 'user_job' => ['context' => UserJob::class], // user job + 'current_location' => ['context' => Location::class], // curent location + 'main_location' => ['context' => Location::class], // main location + 'civility' => ['context' => Civility::class], // civility + 'text_without_absent' => 'AnotherUser', + 'isAbsent' => false, + 'main_center' => ['context' => Center::class], + ]]; + + yield [null, 'docgen', ['docgen:expects' => User::class], [ + 'id' => '', // id + 'type' => 'user', // type + 'username' => '', // username + 'email' => '', // email + 'text' => '', // text + 'label' => '', // label + 'phonenumber' => ['context' => PhoneNumber::class], // phonenumber + 'main_scope' => ['context' => Scope::class], // scope + 'user_job' => ['context' => UserJob::class], // user job + 'current_location' => ['context' => Location::class], // curent location + 'main_location' => ['context' => Location::class], // main location + 'civility' => ['context' => Civility::class], // civility + 'text_without_absent' => '', + 'isAbsent' => false, + 'main_center' => ['context' => Center::class], + ]]; + } + + /** + * @dataProvider dataProviderUserNormalizer + * + * @throws ExceptionInterface + */ + public function testNormalize(null|User $user, mixed $format, mixed $context, mixed $expected) + { + $userRender = $this->prophesize(UserRender::class); + $userRender->renderString(Argument::type(User::class), Argument::type('array'))->willReturn($user ? $user->getLabel() : ''); + + $normalizer = new UserNormalizer($userRender->reveal()); + $normalizer->setNormalizer(new class () implements NormalizerInterface { + public function normalize($object, string $format = null, array $context = []) + { + return ['context' => $context['docgen:expects'] ?? null]; + } + + public function supportsNormalization($data, string $format = null) + { + return true; + } + }); + + $this->assertEquals($expected, $normalizer->normalize($user, $format, $context)); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20231020075524.php b/src/Bundle/ChillMainBundle/migrations/Version20231020075524.php new file mode 100644 index 000000000..5ae44d9bd --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20231020075524.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE users ADD phonenumber VARCHAR(35) DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN users.phonenumber IS \'(DC2Type:phone_number)\''); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users DROP phonenumber'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 28615bc75..91068275f 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -44,6 +44,13 @@ address_fields: Données liées à l'adresse Datas: Données No title: Aucun titre +user: + profile: + title: Mon profil + Phonenumber successfully updated!: Numéro de téléphone mis à jour! + no job: Pas de métier assigné + no scope: Pas de cercle assigné + inactive: inactif Edit: Modifier diff --git a/src/Bundle/ChillMainBundle/translations/messages.nl.yml b/src/Bundle/ChillMainBundle/translations/messages.nl.yml index 2e4ee5a88..cd6cbc6a4 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.nl.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.nl.yml @@ -39,6 +39,13 @@ Last updated by: Laatste update door on: "op " Last updated on: Laatste update op by_user: "door " +lifecycleUpdate: Updates en creatie gebeurtenissen +address_fields: Gegevens gelinked aan het adres +Datas: Gegevens +No title: Geen titel +User profile: Mijn gebruikersprofiel +Phonenumber successfully updated!: Telefoonnummer bijgewerkt! + Edit: Bewerken Update: Updaten