mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 17:28:23 +00:00 
			
		
		
		
	Compare commits
	
		
			15 Commits
		
	
	
		
			405-aside-
			...
			#361-impro
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| caaed3e759 | |||
| 6380fdd9a4 | |||
| fcd5080e6f | |||
| 086d418aa3 | |||
| 4e61821e5b | |||
| e3aeab315f | |||
| 8ec1063ef8 | |||
| aad9c984b1 | |||
| 34b3e290e1 | |||
| 0987b575ab | |||
| d960578c5f | |||
| 176048bce6 | |||
| be210a6dd6 | |||
| 4323773595 | |||
| 6d432ca2cb | 
| @@ -1,6 +0,0 @@ | ||||
| kind: Fixed | ||||
| body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted | ||||
| time: 2025-10-03T22:40:44.685474863+02:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: No schema change | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Fixed | ||||
| body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists' | ||||
| time: 2025-10-06T12:13:15.45905994+02:00 | ||||
| custom: | ||||
|     Issue: "434" | ||||
|     SchemaChange: No schema change | ||||
| @@ -1,2 +0,0 @@ | ||||
| chill_aside_activity: | ||||
|     show_concerned_persons_count: hidden | ||||
| @@ -17,3 +17,9 @@ when@dev: | ||||
|         defaults: | ||||
|             template: '@ChillMain/Dev/dev.assets.test2.html.twig' | ||||
|  | ||||
|  | ||||
|     sass_address_picker: | ||||
|         path: /_dev/address-picker | ||||
|         controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController | ||||
|         defaults: | ||||
|             template: '@ChillMain/Dev/dev.address-picker.html.twig' | ||||
|   | ||||
| @@ -45,6 +45,7 @@ | ||||
|     "webpack-cli": "^5.0.1" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fragaria/address-formatter": "^6.6.1", | ||||
|     "@fullcalendar/core": "^6.1.4", | ||||
|     "@fullcalendar/daygrid": "^6.1.4", | ||||
|     "@fullcalendar/interaction": "^6.1.4", | ||||
| @@ -66,10 +67,12 @@ | ||||
|     "mime": "^4.0.0", | ||||
|     "pdfjs-dist": "^4.3.136", | ||||
|     "vis-network": "^9.1.0", | ||||
|     "vue": "^3.5.6", | ||||
|     "vue": "^3.5.x", | ||||
|     "vue-i18n": "^9.1.6", | ||||
|     "vue-multiselect": "3.0.0-alpha.2", | ||||
|     "vue-toast-notification": "^3.1.2", | ||||
|     "vue-tsc": "^3.1.0", | ||||
|     "vue-use-leaflet": "^0.1.7", | ||||
|     "vuex": "^4.0.0" | ||||
|   }, | ||||
|   "browserslist": [ | ||||
|   | ||||
| @@ -25,7 +25,6 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte | ||||
|         $config = $this->processConfiguration($configuration, $configs); | ||||
|  | ||||
|         $container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']); | ||||
|         $container->setParameter('chill_aside_activity.show_concerned_persons_count', 'visible' === $config['show_concerned_persons_count']); | ||||
|  | ||||
|         $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); | ||||
|         $loader->load('services.yaml'); | ||||
| @@ -39,24 +38,6 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte | ||||
|     { | ||||
|         $this->prependRoute($container); | ||||
|         $this->prependCruds($container); | ||||
|         $this->prependTwigConfig($container); | ||||
|     } | ||||
|  | ||||
|     protected function prependTwigConfig(ContainerBuilder $container) | ||||
|     { | ||||
|         // Get the configuration for this bundle | ||||
|         $chillAsideActivityConfig = $container->getExtensionConfig($this->getAlias()); | ||||
|         $config = $this->processConfiguration($this->getConfiguration($chillAsideActivityConfig, $container), $chillAsideActivityConfig); | ||||
|  | ||||
|         // Add configuration to twig globals | ||||
|         $twigConfig = [ | ||||
|             'globals' => [ | ||||
|                 'chill_aside_activity_config' => [ | ||||
|                     'show_concerned_persons_count' => 'visible' === $config['show_concerned_persons_count'], | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|         $container->prependExtensionConfig('twig', $twigConfig); | ||||
|     } | ||||
|  | ||||
|     protected function prependCruds(ContainerBuilder $container) | ||||
|   | ||||
| @@ -141,12 +141,6 @@ class Configuration implements ConfigurationInterface | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->enumNode('show_concerned_persons_count') | ||||
|             ->values(['hidden', 'visible']) | ||||
|             ->defaultValue('hidden') | ||||
|             ->info('Show the concerned persons count field in aside activity forms and views') | ||||
|             ->end() | ||||
|             ->end(); | ||||
|  | ||||
|         return $treeBuilder; | ||||
|   | ||||
| @@ -62,10 +62,6 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface | ||||
|     #[ORM\ManyToOne(targetEntity: User::class)] | ||||
|     private User $updatedBy; | ||||
|  | ||||
|     #[Assert\GreaterThanOrEqual(0)] | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true)] | ||||
|     private ?int $concernedPersonsCount = 0; | ||||
|  | ||||
|     public function getAgent(): ?User | ||||
|     { | ||||
|         return $this->agent; | ||||
| @@ -190,16 +186,4 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
|  | ||||
|     public function getConcernedPersonsCount(): ?int | ||||
|     { | ||||
|         return $this->concernedPersonsCount; | ||||
|     } | ||||
|  | ||||
|     public function setConcernedPersonsCount(?int $concernedPersonsCount): self | ||||
|     { | ||||
|         $this->concernedPersonsCount = $concernedPersonsCount; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,86 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\AsideActivityBundle\Export\Aggregator; | ||||
|  | ||||
| use Chill\AsideActivityBundle\Export\Declarations; | ||||
| use Chill\MainBundle\Export\AggregatorInterface; | ||||
| use Doctrine\ORM\QueryBuilder; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| class ByConcernedPersonsCountAggregator implements AggregatorInterface | ||||
| { | ||||
|     public function addRole(): ?string | ||||
|     { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void | ||||
|     { | ||||
|         $qb->addSelect('aside.concernedPersonsCount AS by_concerned_persons_count_aggregator') | ||||
|             ->addGroupBy('by_concerned_persons_count_aggregator'); | ||||
|     } | ||||
|  | ||||
|     public function applyOn(): string | ||||
|     { | ||||
|         return Declarations::ASIDE_ACTIVITY_TYPE; | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder): void | ||||
|     { | ||||
|         // No form needed | ||||
|     } | ||||
|  | ||||
|     public function getNormalizationVersion(): int | ||||
|     { | ||||
|         return 1; | ||||
|     } | ||||
|  | ||||
|     public function normalizeFormData(array $formData): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function denormalizeFormData(array $formData, int $fromVersion): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, $data): callable | ||||
|     { | ||||
|         return function ($value): string { | ||||
|             if ('_header' === $value) { | ||||
|                 return 'export.aggregator.Concerned persons count'; | ||||
|             } | ||||
|  | ||||
|             if (null === $value) { | ||||
|                 return 'export.aggregator.No concerned persons count specified'; | ||||
|             } | ||||
|  | ||||
|             return (string) $value; | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data): array | ||||
|     { | ||||
|         return ['by_concerned_persons_count_aggregator']; | ||||
|     } | ||||
|  | ||||
|     public function getTitle(): string | ||||
|     { | ||||
|         return 'export.aggregator.Group by concerned persons count'; | ||||
|     } | ||||
| } | ||||
| @@ -1,116 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\AsideActivityBundle\Export\Export; | ||||
|  | ||||
| use Chill\AsideActivityBundle\Export\Declarations; | ||||
| use Chill\AsideActivityBundle\Repository\AsideActivityRepository; | ||||
| use Chill\AsideActivityBundle\Security\AsideActivityVoter; | ||||
| use Chill\MainBundle\Export\ExportInterface; | ||||
| use Chill\MainBundle\Export\FormatterInterface; | ||||
| use Chill\MainBundle\Export\GroupedExportInterface; | ||||
| use Doctrine\ORM\Query; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| class SumConcernedPersonsCountAsideActivity implements ExportInterface, GroupedExportInterface | ||||
| { | ||||
|     public function __construct(private readonly AsideActivityRepository $repository) {} | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder) {} | ||||
|  | ||||
|     public function getNormalizationVersion(): int | ||||
|     { | ||||
|         return 1; | ||||
|     } | ||||
|  | ||||
|     public function normalizeFormData(array $formData): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function denormalizeFormData(array $formData, int $fromVersion): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     public function getAllowedFormattersTypes(): array | ||||
|     { | ||||
|         return [FormatterInterface::TYPE_TABULAR]; | ||||
|     } | ||||
|  | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'export.Sum concerned persons count for aside activities'; | ||||
|     } | ||||
|  | ||||
|     public function getGroup(): string | ||||
|     { | ||||
|         return 'export.Exports of aside activities'; | ||||
|     } | ||||
|  | ||||
|     public function getLabels($key, array $values, $data) | ||||
|     { | ||||
|         if ('export_sum_concerned_persons_count' !== $key) { | ||||
|             throw new \LogicException("the key {$key} is not used by this export"); | ||||
|         } | ||||
|  | ||||
|         $labels = array_combine($values, $values); | ||||
|         $labels['_header'] = $this->getTitle(); | ||||
|  | ||||
|         return static fn ($value) => $labels[$value]; | ||||
|     } | ||||
|  | ||||
|     public function getQueryKeys($data): array | ||||
|     { | ||||
|         return ['export_sum_concerned_persons_count']; | ||||
|     } | ||||
|  | ||||
|     public function getResult($query, $data, \Chill\MainBundle\Export\ExportGenerationContext $context): array | ||||
|     { | ||||
|         return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); | ||||
|     } | ||||
|  | ||||
|     public function getTitle(): string | ||||
|     { | ||||
|         return 'export.Sum concerned persons count for aside activities'; | ||||
|     } | ||||
|  | ||||
|     public function getType(): string | ||||
|     { | ||||
|         return Declarations::ASIDE_ACTIVITY_TYPE; | ||||
|     } | ||||
|  | ||||
|     public function initiateQuery(array $requiredModifiers, array $acl, array $data, \Chill\MainBundle\Export\ExportGenerationContext $context): \Doctrine\ORM\QueryBuilder | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('aside'); | ||||
|  | ||||
|         $qb->select('SUM(COALESCE(aside.concernedPersonsCount, 0)) as export_sum_concerned_persons_count'); | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     public function requiredRole(): string | ||||
|     { | ||||
|         return AsideActivityVoter::STATS; | ||||
|     } | ||||
|  | ||||
|     public function supportsModifiers(): array | ||||
|     { | ||||
|         return [ | ||||
|             Declarations::ASIDE_ACTIVITY_TYPE, | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -21,7 +21,6 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; | ||||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\IntegerType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\Form\FormEvent; | ||||
| use Symfony\Component\Form\FormEvents; | ||||
| @@ -30,13 +29,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| final class AsideActivityFormType extends AbstractType | ||||
| { | ||||
|     private readonly array $timeChoices; | ||||
|     private readonly bool $showConcernedPersonsCount; | ||||
|  | ||||
|     public function __construct( | ||||
|         ParameterBagInterface $parameterBag, | ||||
|     ) { | ||||
|         $this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration'); | ||||
|         $this->showConcernedPersonsCount = $parameterBag->get('chill_aside_activity.show_concerned_persons_count'); | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
| @@ -79,16 +76,6 @@ final class AsideActivityFormType extends AbstractType | ||||
|             ->add('location', PickUserLocationType::class) | ||||
|         ; | ||||
|  | ||||
|         if ($this->showConcernedPersonsCount) { | ||||
|             $builder->add('concernedPersonsCount', IntegerType::class, [ | ||||
|                 'label' => 'Concerned persons count', | ||||
|                 'required' => false, | ||||
|                 'attr' => [ | ||||
|                     'min' => 0, | ||||
|                 ], | ||||
|             ]); | ||||
|         } | ||||
|  | ||||
|         foreach (['duration'] as $fieldName) { | ||||
|             $builder->get($fieldName) | ||||
|                 ->addModelTransformer($durationTimeTransformer); | ||||
|   | ||||
| @@ -42,11 +42,6 @@ | ||||
|                                 {%- if entity.location.name is defined -%} | ||||
|                                     <div><i class="fa fa-fw fa-map-marker"></i>{{ entity.location.name }}</div> | ||||
|                                 {%- endif -%} | ||||
|  | ||||
|                                 {%- if entity.concernedPersonsCount > 0 -%} | ||||
|                                     <div><i class="fa fa-fw fa-user"></i>{{ entity.concernedPersonsCount }}</div> | ||||
|                                 {%- endif -%} | ||||
|  | ||||
| 							</div> | ||||
| 							<div class="item-col" style="justify-content: flex-end;"> | ||||
| 								<div class="box"> | ||||
|   | ||||
| @@ -38,11 +38,6 @@ | ||||
| 				<dt class="inline">{{ 'Duration'|trans }}</dt> | ||||
| 				<dd>{{ entity.duration|date('H:i') }}</dd> | ||||
|  | ||||
|                 {% if chill_aside_activity_config.show_concerned_persons_count == 'visible' %} | ||||
|                     <dt class="inline">{{ 'Concerned persons count'|trans }}</dt> | ||||
|                     <dd>{{ entity.concernedPersonsCount }}</dd> | ||||
|                 {% endif %} | ||||
|  | ||||
| 				<dt class="inline">{{ 'Remark'|trans }}</dt> | ||||
| 				{%- if entity.note is empty -%} | ||||
| 					<dd> | ||||
|   | ||||
| @@ -1,49 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\AsideActivityBundle\Tests\Export\Aggregator; | ||||
|  | ||||
| use Chill\AsideActivityBundle\Entity\AsideActivity; | ||||
| use Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator; | ||||
| use Chill\MainBundle\Test\Export\AbstractAggregatorTest; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class ByConcernedPersonsCountAggregatorTest extends AbstractAggregatorTest | ||||
| { | ||||
|     public function getAggregator() | ||||
|     { | ||||
|         return new ByConcernedPersonsCountAggregator(); | ||||
|     } | ||||
|  | ||||
|     public static function getFormData(): array | ||||
|     { | ||||
|         return [ | ||||
|             [], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public static function getQueryBuilders(): iterable | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $em = self::getContainer()->get(EntityManagerInterface::class); | ||||
|  | ||||
|         return [ | ||||
|             $em->createQueryBuilder() | ||||
|                 ->select('count(aside.id)') | ||||
|                 ->from(AsideActivity::class, 'aside'), | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -1,50 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\AsideActivityBundle\Tests\Export\Export; | ||||
|  | ||||
| use Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity; | ||||
| use Chill\AsideActivityBundle\Repository\AsideActivityRepository; | ||||
| use Chill\MainBundle\Test\Export\AbstractExportTest; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| final class SumConcernedPersonsCountAsideActivityTest extends AbstractExportTest | ||||
| { | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|     } | ||||
|  | ||||
|     public function getExport() | ||||
|     { | ||||
|         $repository = self::getContainer()->get(AsideActivityRepository::class); | ||||
|  | ||||
|         yield new SumConcernedPersonsCountAsideActivity($repository); | ||||
|     } | ||||
|  | ||||
|     public static function getFormData(): array | ||||
|     { | ||||
|         return [ | ||||
|             [], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public static function getModifiersCombination(): array | ||||
|     { | ||||
|         return [ | ||||
|             ['aside_activity'], | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
| @@ -20,10 +20,6 @@ services: | ||||
|       tags: | ||||
|           - { name: chill.export, alias: 'avg_aside_activity_duration' } | ||||
|  | ||||
|   Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity: | ||||
|       tags: | ||||
|           - { name: chill.export, alias: 'sum_aside_activity_concerned_persons_count' } | ||||
|  | ||||
|   ## Filters | ||||
|   chill.aside_activity.export.date_filter: | ||||
|     class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter | ||||
| @@ -74,7 +70,3 @@ services: | ||||
|   Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator: | ||||
|       tags: | ||||
|           - { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' } | ||||
|  | ||||
|   Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator: | ||||
|       tags: | ||||
|           - { name: chill.export_aggregator, alias: 'aside_activity_concerned_persons_count_aggregator' } | ||||
|   | ||||
| @@ -1,33 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\AsideActivity; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20251006113048 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Add concernedPersonsCount property to AsideActivity entity'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_asideactivity.asideactivity ADD concernedPersonsCount INT DEFAULT 0'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount'); | ||||
|     } | ||||
| } | ||||
| @@ -27,7 +27,6 @@ Emergency: Urgent | ||||
| by: "Par " | ||||
| location: Lieu | ||||
| Asideactivity location: Localisation de l'activité | ||||
| Concerned persons count: Nombre d'usager concernés | ||||
|  | ||||
| # Crud | ||||
| crud: | ||||
| @@ -191,7 +190,6 @@ export: | ||||
|     Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères | ||||
|     Average aside activities duration: Durée moyenne des activités annexes | ||||
|     Sum aside activities duration: Durée des activités annexes | ||||
|     Sum concerned persons count for aside activities: Nombre d'usager concernés par les activités annexes | ||||
|     filter: | ||||
|         Filter by aside activity date: Filtrer les activités annexes par date | ||||
|         Filter by aside activity type: Filtrer les activités annexes par type d'activité | ||||
| @@ -212,8 +210,6 @@ export: | ||||
|         'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%" | ||||
|     aggregator: | ||||
|         Group by aside activity type: Grouper les activités annexes par type d'activité | ||||
|         Group by concerned persons count: Grouper les activités annexes par nombre d'usagers conernés | ||||
|         Concerned persons count: Nombre d'usagers concernés | ||||
|         Aside activity type: Type d'activité annexe | ||||
|         by_user_job: | ||||
|             Aggregate by user job: Grouper les activités annexes par métier des utilisateurs | ||||
|   | ||||
| @@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController | ||||
|  | ||||
|         return new JsonResponse( | ||||
|             $this->serializer->serialize( | ||||
|                 new Collection(array_values($items->toArray()), $paginator), | ||||
|                 new Collection($items, $paginator), | ||||
|                 'json', | ||||
|                 [AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]] | ||||
|             ), | ||||
|   | ||||
| @@ -3,9 +3,9 @@ import { | ||||
|     StoredObject, | ||||
|     StoredObjectPointInTime, | ||||
|     StoredObjectVersionWithPointInTime, | ||||
| } from "ChillDocStoreAssets/types"; | ||||
| } from "./../../../types"; | ||||
| import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; | ||||
| import { ISOToDatetime } from "ChillMainAssets/chill/js/date"; | ||||
| import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date"; | ||||
| import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue"; | ||||
| import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue"; | ||||
| import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue"; | ||||
|   | ||||
| @@ -40,10 +40,6 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase | ||||
|             $storedObject->registerVersion(); | ||||
|         } | ||||
|  | ||||
|         // remove one version in the history | ||||
|         $v5 = $storedObject->getVersions()->get(5); | ||||
|         $storedObject->removeVersion($v5); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject) | ||||
|             ->willReturn(true) | ||||
| @@ -57,7 +53,6 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase | ||||
|         self::assertEquals($response->getStatusCode(), 200); | ||||
|         self::assertIsArray($body); | ||||
|         self::assertArrayHasKey('results', $body); | ||||
|         self::assertIsList($body['results']); | ||||
|         self::assertCount(10, $body['results']); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,50 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| final readonly class AddressReferenceAggregatedApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private Security $security, | ||||
|         private AddressReferenceRepositoryInterface $addressReferenceRepository, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route(path: '/api/1.0/main/address-reference/aggregated/search')] | ||||
|     public function search(Request $request): JsonResponse | ||||
|     { | ||||
|         if (!$this->security->isGranted('IS_AUTHENTICATED')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         if (!$request->query->has('q')) { | ||||
|             throw new BadRequestHttpException('Parameter "q" is required.'); | ||||
|         } | ||||
|  | ||||
|         $q = trim($request->query->get('q')); | ||||
|  | ||||
|         if ('' === $q) { | ||||
|             throw new BadRequestHttpException('Parameter "q" is required and cannot be empty.'); | ||||
|         } | ||||
|  | ||||
|         $result = $this->addressReferenceRepository->findAggregatedBySearchString($q); | ||||
|  | ||||
|         return new JsonResponse(iterator_to_array($result)); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepositoryInterface; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| final readonly class PostalCodeForAddressReferenceApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private PostalCodeForAddressReferenceRepositoryInterface $postalCodeForAddressReferenceRepository, | ||||
|         private Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/api/1.0/main/address-reference/postal-code/search')] | ||||
|     public function findPostalCodeBySearch(Request $request): JsonResponse | ||||
|     { | ||||
|  | ||||
|         if (!$this->security->isGranted('IS_AUTHENTICATED')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $search = $request->query->get('q'); | ||||
|  | ||||
|         if (null === $search || '' === trim($search)) { | ||||
|             throw new BadRequestHttpException('No search query provided'); | ||||
|         } | ||||
|  | ||||
|         $postalCodes = iterator_to_array($this->postalCodeForAddressReferenceRepository->findPostalCode($search)); | ||||
|  | ||||
|         return new JsonResponse($postalCodes, json: false); | ||||
|     } | ||||
| } | ||||
| @@ -14,13 +14,14 @@ namespace Chill\MainBundle\Repository; | ||||
| use Chill\MainBundle\Entity\AddressReference; | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Chill\MainBundle\Search\SearchApiQuery; | ||||
| use Doctrine\DBAL\Types\Types; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\NativeQuery; | ||||
| use Doctrine\ORM\Query\ResultSetMapping; | ||||
| use Doctrine\ORM\Query\ResultSetMappingBuilder; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| final readonly class AddressReferenceRepository implements ObjectRepository | ||||
| final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface | ||||
| { | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
| @@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $connection = $this->entityManager->getConnection(); | ||||
|         $qb = $connection->createQueryBuilder(); | ||||
|  | ||||
|         $qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions') | ||||
|             ->from('view_chill_main_address_reference', 'var') | ||||
|             ->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id') | ||||
|             ->groupBy('cmpc.id', 'var.street') | ||||
|             ->setFirstResult($firstResult) | ||||
|             ->setMaxResults($maxResults); | ||||
|  | ||||
|         $paramId = 0; | ||||
|  | ||||
|         foreach ($terms as $term) { | ||||
|             $qb->andWhere('var.address like UNACCENT(LOWER(?))'); | ||||
|             $qb->setParameter(++$paramId, "%{$term}%"); | ||||
|         } | ||||
|  | ||||
|         if (null !== $postalCode) { | ||||
|             $qb->andWhere('var.postcode_id = ?'); | ||||
|             $qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode); | ||||
|         } | ||||
|  | ||||
|         $result = $qb->executeQuery(); | ||||
|  | ||||
|         foreach ($result->iterateAssociative() as $row) { | ||||
|             yield [...$row, 'positions' => json_decode($row['positions'], true, 512, JSON_THROW_ON_ERROR)]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return iterable<AddressReference> | ||||
|      */ | ||||
|     public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $rsm = new ResultSetMappingBuilder($this->entityManager); | ||||
|         $rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar'); | ||||
|         $baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN | ||||
|             view_chill_main_address_reference var ON var.address_id = ar.id'; | ||||
|         $nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode); | ||||
|  | ||||
|         $orderBy = []; | ||||
|         $pertinence = []; | ||||
|         foreach ($terms as $k => $term) { | ||||
|             $pertinence[] = | ||||
|                 "(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int"; | ||||
|             $pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int"; | ||||
|             $nql->setParameter('order'.$k, $term); | ||||
|         } | ||||
|         $orderBy[] = implode(' + ', $pertinence).' ASC'; | ||||
|         $orderBy[] = implode('row_number ASC', $orderBy); | ||||
|  | ||||
|         $nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy)); | ||||
|  | ||||
|         return $nql->toIterable(); | ||||
|     } | ||||
|  | ||||
|     public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         $rsm = new ResultSetMappingBuilder($this->entityManager); | ||||
|         $rsm->addScalarResult('c', 'c', Types::INTEGER); | ||||
|         $nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode); | ||||
|  | ||||
|         return $nql->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     private function buildTermsFromSearchString(string $search): array | ||||
|     { | ||||
|         return array_filter( | ||||
|             array_map( | ||||
|                 static fn (string $term) => trim($term), | ||||
|                 explode(' ', $search) | ||||
|             ), | ||||
|             static fn (string $term) => '' !== $term | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery | ||||
|     { | ||||
|         $nql = $this->entityManager->createNativeQuery('', $rsm); | ||||
|  | ||||
|         $sql = $select.' WHERE '; | ||||
|  | ||||
|         $wheres = []; | ||||
|         foreach ($terms as $k => $term) { | ||||
|             $wheres[] = "var.address like :w{$k}"; | ||||
|             $nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING); | ||||
|         } | ||||
|  | ||||
|         if (null !== $postalCode) { | ||||
|             $wheres[] = 'var.postcode_id = :postalCode'; | ||||
|             $nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode); | ||||
|         } | ||||
|  | ||||
|         $nql->setSQL($sql.implode(' AND ', $wheres)); | ||||
|  | ||||
|         return $nql; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param mixed|null $limit | ||||
|      * @param mixed|null $offset | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| interface AddressReferenceRepositoryInterface extends ObjectRepository | ||||
| { | ||||
|     public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable; | ||||
| } | ||||
| @@ -0,0 +1,69 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Repository; | ||||
|  | ||||
| use Doctrine\DBAL\Connection; | ||||
|  | ||||
| final readonly class PostalCodeForAddressReferenceRepository implements PostalCodeForAddressReferenceRepositoryInterface | ||||
| { | ||||
|     public function __construct(private Connection $connection) {} | ||||
|  | ||||
|     public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $qb = $this->connection->createQueryBuilder(); | ||||
|  | ||||
|         $qb->from('chill_main_postal_code', 'cmpc') | ||||
|             ->join('cmpc', 'view_chill_main_address_reference', 'vcmar', 'vcmar.postcode_id = cmpc.id') | ||||
|             ->join('vcmar', 'country', 'country', condition: 'cmpc.country_id = country.id') | ||||
|             ->setFirstResult($firstResult) | ||||
|             ->setMaxResults($maxResults) | ||||
|         ; | ||||
|  | ||||
|         $qb->select( | ||||
|             'DISTINCT ON (cmpc.code, cmpc.label) cmpc.id AS postcode_id', | ||||
|             'cmpc.code AS code', | ||||
|             'cmpc.label AS label', | ||||
|             'country.id AS country_id', | ||||
|             'country.countrycode AS country_code', | ||||
|             'country.name AS country_name' | ||||
|         ); | ||||
|  | ||||
|         $paramId = 0; | ||||
|  | ||||
|         foreach ($terms as $term) { | ||||
|             $qb->andWhere('vcmar.address like ?'); | ||||
|             $qb->setParameter(++$paramId, "%{$term}%"); | ||||
|         } | ||||
|  | ||||
|         $result = $qb->executeQuery(); | ||||
|  | ||||
|         foreach ($result->iterateAssociative() as $row) { | ||||
|             yield [...$row, 'country_name' => json_decode($row['country_name'], true, 512, JSON_THROW_ON_ERROR)]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private function buildTermsFromSearchString(string $search): array | ||||
|     { | ||||
|         return array_filter( | ||||
|             array_map( | ||||
|                 static fn (string $term) => trim($term), | ||||
|                 explode(' ', $search) | ||||
|             ), | ||||
|             static fn (string $term) => '' !== $term | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Repository; | ||||
|  | ||||
| /** | ||||
|  * Search for postal code using optimized materialized view. | ||||
|  */ | ||||
| interface PostalCodeForAddressReferenceRepositoryInterface | ||||
| { | ||||
|     public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable; | ||||
| } | ||||
| @@ -75,6 +75,7 @@ export interface Postcode { | ||||
|     name: string; | ||||
|     code: string; | ||||
|     center: Point; | ||||
|     country: Country; | ||||
| } | ||||
|  | ||||
| export interface Point { | ||||
| @@ -90,6 +91,28 @@ export interface Country { | ||||
|  | ||||
| export type AddressRefStatus = "match" | "to_review" | "reviewed"; | ||||
|  | ||||
| /** | ||||
|  * An interface to create an address | ||||
|  */ | ||||
| export interface AddressCreation { | ||||
|     confidential: boolean; | ||||
|     isNoAddress: boolean; | ||||
|     street: string; | ||||
|     streetNumber: string; | ||||
|     postcode: Postcode; | ||||
|     point: Point; // [number, number]; // [longitude, latitude] | ||||
|     addressReference: AddressReference; | ||||
|     validFrom: DateTime|null; | ||||
|     floor: string; | ||||
|     corridor: string; | ||||
|     steps: string; | ||||
|     flat: string; | ||||
|     buildingName: string; | ||||
|     distribution: string; | ||||
|     extra: string; | ||||
| } | ||||
|  | ||||
|  | ||||
| export interface Address { | ||||
|     type: "address"; | ||||
|     address_id: number; | ||||
| @@ -108,7 +131,7 @@ export interface Address { | ||||
|     confidential: boolean; | ||||
|     lines: string[]; | ||||
|     addressReference: AddressReference | null; | ||||
|     validFrom: DateTime; | ||||
|     validFrom: DateTime  | null; // TODO there is no null for validFrom | ||||
|     validTo: DateTime | null; | ||||
|     point: Point | null; | ||||
|     refStatus: AddressRefStatus; | ||||
|   | ||||
| @@ -0,0 +1,33 @@ | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import AddressPicker from "ChillMainAssets/vuejs/AddressPicker/AddressPicker.vue"; | ||||
| import {Ref, ref} from "vue"; | ||||
|  | ||||
| const showModal: Ref<boolean> = ref(false); | ||||
|  | ||||
| const modalDialogClasses = {"modal-dialog": true, "modal-dialog-scrollable": true, "modal-xl": true}; | ||||
|  | ||||
| const clickButton = () => { | ||||
|     showModal.value = true; | ||||
| } | ||||
|  | ||||
| const closeModal = () => { | ||||
|     showModal.value = false; | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <modal v-if="showModal" :hide-footer="false" :modal-dialog-class="modalDialogClasses" @close="closeModal"> | ||||
|         <template v-slot:header>TODO</template> | ||||
|         <template v-slot:body> | ||||
|             <AddressPicker></AddressPicker> | ||||
|         </template> | ||||
|     </modal> | ||||
|     <button class="btn btn-submit" type="button" @click="clickButton">SEARCH ADDRESS</button> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,170 @@ | ||||
| <script setup lang="ts"> | ||||
| import {Address, AddressReference} from "ChillMainAssets/types"; | ||||
| import SearchBar from "ChillMainAssets/vuejs/AddressPicker/Component/SearchBar.vue"; | ||||
| import { | ||||
|     AddressAggregated, | ||||
|     AssociatedPostalCode, fetchAddressReference, | ||||
|     getAddressesAggregated, | ||||
|     getPostalCodes, | ||||
| } from "ChillMainAssets/vuejs/AddressPicker/driver/local-search"; | ||||
| import {computed, Ref, ref} from "vue"; | ||||
| import AddressAggregatedList from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedList.vue"; | ||||
| import AddressDetailsForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressDetailsForm.vue"; | ||||
| import AddressForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressForm.vue"; | ||||
| import {trans, SAVE} from "translator"; | ||||
|  | ||||
| interface AddressPickerProps { | ||||
|     suggestions?: Address[]; | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<AddressPickerProps>(), { | ||||
|     suggestions: () => [], | ||||
| }); | ||||
|  | ||||
| const addresses: Ref<AddressAggregated[]> = ref([]); | ||||
| const postalCodes: Ref<AssociatedPostalCode[]> = ref([]); | ||||
| const searchTokens: Ref<string[]> = ref([]); | ||||
| const addressReference: Ref<AddressReference|null> = ref(null); | ||||
|  | ||||
| let abortControllerSearchAddress: null | AbortController = null; | ||||
| let abortControllerSearchPostalCode: null | AbortController = null; | ||||
|  | ||||
| const searchResultsClasses = computed(() => ({ | ||||
|     "mid-size": addressReference !== null, | ||||
| })); | ||||
|  | ||||
| const floor = ref<string>(""); | ||||
| const corridor = ref<string>(""); | ||||
| const steps = ref<string>(""); | ||||
| const flat = ref<string>(""); | ||||
| const buildingName = ref<string>(""); | ||||
| const extra = ref<string>(""); | ||||
| const distribution = ref<string>(""); | ||||
|  | ||||
|  | ||||
| const onSearch = async function (search: string): Promise<void> { | ||||
|     performSearchForAddress(search); | ||||
|     performSearchForPostalCode(search); | ||||
|     searchTokens.value = [search]; | ||||
| }; | ||||
|  | ||||
| const onPickPosition = async (id: string) => { | ||||
|     console.log('Pick Position', id); | ||||
|     addressReference.value = await fetchAddressReference(id); | ||||
| } | ||||
|  | ||||
| const performSearchForAddress = async (search: string): Promise<void> => { | ||||
|     if (null !== abortControllerSearchAddress) { | ||||
|         abortControllerSearchAddress.abort(); | ||||
|     } | ||||
|  | ||||
|     if ("" === search) { | ||||
|         addresses.value = []; | ||||
|         abortControllerSearchAddress = null; | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     abortControllerSearchAddress = new AbortController(); | ||||
|  | ||||
|     console.log("onSearch", search); | ||||
|     try { | ||||
|         addresses.value = await getAddressesAggregated( | ||||
|             search, | ||||
|             abortControllerSearchAddress, | ||||
|         ); | ||||
|         abortControllerSearchAddress = null; | ||||
|  | ||||
|         // check if there is only one result | ||||
|         if (addresses.value.length === 1 && Object.keys(addresses.value[0].positions).length === 1) { | ||||
|             onPickPosition(Object.keys(addresses.value[0].positions)[0]); | ||||
|         } | ||||
|     } catch (e: unknown) { | ||||
|         if (e instanceof DOMException && e.name === "AbortError") { | ||||
|             console.log("search aborted for:", search); | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         throw e; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| const performSearchForPostalCode = async (search: string): Promise<void> => { | ||||
|     if (null !== abortControllerSearchPostalCode) { | ||||
|         abortControllerSearchPostalCode.abort(); | ||||
|     } | ||||
|  | ||||
|     if ("" === search) { | ||||
|         addresses.value = []; | ||||
|         abortControllerSearchPostalCode = null; | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     abortControllerSearchPostalCode = new AbortController(); | ||||
|  | ||||
|     console.log("onSearch", search); | ||||
|     try { | ||||
|         postalCodes.value = await getPostalCodes( | ||||
|             search, | ||||
|             abortControllerSearchPostalCode, | ||||
|         ); | ||||
|         abortControllerSearchPostalCode = null; | ||||
|     } catch (e: unknown) { | ||||
|         if (e instanceof DOMException && e.name === "AbortError") { | ||||
|             console.log("search postal code aborted for:", search); | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         throw e; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| const save = async(): Promise<void> => { | ||||
|     console.log("save"); | ||||
|     console.log("content", floor, corridor, steps, flat, buildingName, extra, distribution); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <search-bar @search="onSearch"></search-bar> | ||||
|     <div class="address-pick-content"> | ||||
|         <div class="search-results" :class="searchResultsClasses"> | ||||
|             <address-aggregated-list :addresses="addresses" :search-tokens="searchTokens" @pick-position="(id) => onPickPosition(id)"></address-aggregated-list> | ||||
|         </div> | ||||
|         <div v-if="addressReference !== null" class="address-details-form"> | ||||
|             <address-details-form :address="addressReference" | ||||
|                                   v-model:floor="floor" | ||||
|                                   v-model:corridor="corridor" | ||||
|                                   v-model:steps="steps" | ||||
|                                   v-model:flat="flat" | ||||
|                                   v-model:building-name="buildingName" | ||||
|                                   v-model:extra="extra" | ||||
|                                   v-model:distribution="distribution" | ||||
|             /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <ul class="record_actions"> | ||||
|                 <li><button class="btn btn-save">{{ trans(SAVE) }}</button></li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| .address-pick-content { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     gap: 1rem; | ||||
|  | ||||
|     .search-results { | ||||
|         &.mid-size { | ||||
|             width: 50%; | ||||
|         } | ||||
|     } | ||||
|     .address-details-form { | ||||
|         width: 50%; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -0,0 +1,25 @@ | ||||
| <script setup lang="ts"> | ||||
| import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search"; | ||||
| import AddressAggregatedListItem from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedListItem.vue"; | ||||
|  | ||||
| interface AddressAggregatedListProps { | ||||
|     addresses: AddressAggregated[]; | ||||
|     searchTokens: string[]; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressAggregatedListProps>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     pickPosition: [id: string] | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <template v-for="a in props.addresses" :key="a.row_number"> | ||||
|         <address-aggregated-list-item :address="a" :search-tokens="props.searchTokens" @pick-position="(id) => emit('pickPosition', id)"></address-aggregated-list-item> | ||||
|     </template> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,82 @@ | ||||
| <script setup lang="ts"> | ||||
| import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search"; | ||||
| import {computed, ref} from "vue"; | ||||
|  | ||||
| interface AddressAggregatedListItemProps { | ||||
|     address: AddressAggregated; | ||||
|     searchTokens: string[]; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressAggregatedListItemProps>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     pickPosition: [id: string] | ||||
| }>(); | ||||
|  | ||||
| const showAllPositions = ref<boolean>(false); | ||||
| const positionsToShow = computed((): Record<string, string> => { | ||||
|     const obj: Record<string, any> = {}; | ||||
|     let count = 0; | ||||
|     for (const [id, position] of Object.entries(props.address.positions)) { | ||||
|        obj[id] = position; | ||||
|        count++; | ||||
|        if (count >= 10 && !showAllPositions.value) { | ||||
|            break; | ||||
|        } | ||||
|     } | ||||
|  | ||||
|     return obj; | ||||
| }) | ||||
| const needToShowMorePosition = computed(() => { | ||||
|     return Object.keys(props.address.positions).length > 10; | ||||
| }) | ||||
|  | ||||
| const onClickButton = (id: string) => { | ||||
|     console.log('onClickButton', id); | ||||
|     emit('pickPosition', id); | ||||
| } | ||||
| const displayAllPositions = () => { | ||||
|     showAllPositions.value = true; | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div> | ||||
|         <div class="street"> | ||||
|             <span>{{ props.address.street }}</span> | ||||
|         </div> | ||||
|         <div class="postcode"> | ||||
|             <span>{{ props.address.code }}</span> <span>{{ address.label }}</span> | ||||
|         </div> | ||||
|         <div class="positions"> | ||||
|             <ul> | ||||
|                 <li v-for="(position, id) in positionsToShow" :key="id"  > | ||||
|                     <button type="button" @click="onClickButton(id)"  > | ||||
|                         {{ position }} | ||||
|                     </button> | ||||
|                 </li> | ||||
|                 <li v-if="needToShowMorePosition"> | ||||
|                     <button @click="displayAllPositions">show all</button> | ||||
|                 </li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| .street { | ||||
|     font-variant: small-caps; | ||||
|     font-weight: bold; | ||||
| } | ||||
| .postcode { | ||||
|     font-variant: small-caps; | ||||
| } | ||||
| .positions ul { | ||||
|     list-style-type: none; | ||||
|  | ||||
|     li { | ||||
|         display: inline-block; | ||||
|         margin-right: 2px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -0,0 +1,44 @@ | ||||
| <script setup lang="ts"> | ||||
| import {AddressReference} from "ChillMainAssets/types"; | ||||
| import {computed, ref} from "vue"; | ||||
| import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper"; | ||||
| import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue"; | ||||
| import AddressForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressForm.vue"; | ||||
|  | ||||
| export interface AddressDetailsFormProps { | ||||
|     address: AddressReference; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressDetailsFormProps>(); | ||||
|  | ||||
| const floor = ref<string>(""); | ||||
| const corridor = ref<string>(""); | ||||
| const steps = ref<string>(""); | ||||
| const flat = ref<string>(""); | ||||
| const buildingName = ref<string>(""); | ||||
| const extra = ref<string>(""); | ||||
| const distribution = ref<string>(""); | ||||
|  | ||||
| const address = computed(() => addressReferenceToAddress(props.address)); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div> | ||||
|         <address-form | ||||
|             @update:floor="val => (floor = val)" | ||||
|             @update:corridor="val => (corridor = val)" | ||||
|             @update:steps="val => (steps = val)" | ||||
|             @update:flat="val => (flat = val)" | ||||
|             @update:building-name="val => (buildingName = val)" | ||||
|             @update:extra="val => (extra = val)" | ||||
|             @update:distribution="val => (distribution = val)" | ||||
|         ></address-form> | ||||
|     </div> | ||||
|     <div> | ||||
|         <address-details-content :address="address"></address-details-content> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,112 @@ | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|     ADDRESS_STREET, | ||||
|     ADDRESS_STREET_NUMBER, | ||||
|     ADDRESS_FLOOR, | ||||
|     ADDRESS_CORRIDOR, | ||||
|     ADDRESS_STEPS, | ||||
|     ADDRESS_FLAT, | ||||
|     ADDRESS_BUILDING_NAME, | ||||
|     ADDRESS_DISTRIBUTION, | ||||
|     ADDRESS_EXTRA, | ||||
|     ADDRESS_FILL_AN_ADDRESS, | ||||
|     trans, | ||||
| } from "translator"; | ||||
| import {ref} from "vue"; | ||||
|  | ||||
| const isNoAddress = ref(false); | ||||
|  | ||||
| const floor = defineModel("floor"); | ||||
| const corridor = defineModel("corridor"); | ||||
| const steps = defineModel("steps"); | ||||
| const flat = defineModel("flat"); | ||||
| const buildingName = defineModel("buildingName"); | ||||
| const extra = defineModel("extra"); | ||||
| const distribution = defineModel("distribution"); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="floor" | ||||
|             :placeholder="trans(ADDRESS_FLOOR)" | ||||
|             v-model="floor" | ||||
|         /> | ||||
|         <label for="floor">{{ trans(ADDRESS_FLOOR) }}</label> | ||||
|     </div> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="corridor" | ||||
|             :placeholder="trans(ADDRESS_CORRIDOR)" | ||||
|             v-model="corridor" | ||||
|         /> | ||||
|         <label for="corridor">{{ trans(ADDRESS_CORRIDOR) }}</label> | ||||
|     </div> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="steps" | ||||
|             :placeholder="trans(ADDRESS_STEPS)" | ||||
|             v-model="steps" | ||||
|         /> | ||||
|         <label for="steps">{{ trans(ADDRESS_STEPS) }}</label> | ||||
|     </div> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="flat" | ||||
|             :placeholder="trans(ADDRESS_FLAT)" | ||||
|             v-model="flat" | ||||
|         /> | ||||
|         <label for="flat">{{ trans(ADDRESS_FLAT) }}</label> | ||||
|     </div> | ||||
|     <div :class="isNoAddress ? 'col-lg-12' : 'col-lg-6'"> | ||||
|         <div class="form-floating my-1" v-if="!isNoAddress"> | ||||
|             <input | ||||
|                 class="form-control" | ||||
|                 type="text" | ||||
|                 name="buildingName" | ||||
|                 maxlength="255" | ||||
|                 :placeholder="trans(ADDRESS_BUILDING_NAME)" | ||||
|                 v-model="buildingName" | ||||
|             /> | ||||
|             <label for="buildingName">{{ | ||||
|                     trans(ADDRESS_BUILDING_NAME) | ||||
|                 }}</label> | ||||
|         </div> | ||||
|         <div class="form-floating my-1"> | ||||
|             <input | ||||
|                 class="form-control" | ||||
|                 type="text" | ||||
|                 name="extra" | ||||
|                 maxlength="255" | ||||
|                 :placeholder="trans(ADDRESS_EXTRA)" | ||||
|                 v-model="extra" | ||||
|             /> | ||||
|             <label for="extra">{{ trans(ADDRESS_EXTRA) }}</label> | ||||
|         </div> | ||||
|         <div class="form-floating my-1" v-if="!isNoAddress"> | ||||
|             <input | ||||
|                 class="form-control" | ||||
|                 type="text" | ||||
|                 name="distribution" | ||||
|                 maxlength="255" | ||||
|                 :placeholder="trans(ADDRESS_DISTRIBUTION)" | ||||
|                 v-model="distribution" | ||||
|             /> | ||||
|             <label for="distribution">{{ | ||||
|                     trans(ADDRESS_DISTRIBUTION) | ||||
|                 }}</label> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,39 @@ | ||||
| <script setup lang="ts"> | ||||
| import { ADDRESS_PICKER_SEARCH_FOR_ADDRESSES, trans } from 'translator'; | ||||
| const emits = defineEmits<{ | ||||
|     search: [search: string]; | ||||
| }>(); | ||||
|  | ||||
| let searchTimer = 0; | ||||
| let searchString: string; | ||||
|  | ||||
| const onInput = function (event: InputEvent) { | ||||
|     const target = event.target as HTMLInputElement; | ||||
|     const value = target.value; | ||||
|     searchString = value; | ||||
|  | ||||
|     if (0 === searchTimer) { | ||||
|         window.clearTimeout(searchTimer); | ||||
|         searchTimer = 0; | ||||
|     } | ||||
|  | ||||
|     searchTimer = window.setTimeout(() => { | ||||
|         if (value === searchString) { | ||||
|             emits("search", value); | ||||
|         } | ||||
|     }, 500); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div class="input-group mb-3"> | ||||
|         <span class="input-group-text"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16"> | ||||
|               <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/> | ||||
|             </svg> | ||||
|         </span> | ||||
|         <input type="search" class="form-control" @input="onInput" :placeholder="trans(ADDRESS_PICKER_SEARCH_FOR_ADDRESSES)" /> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"></style> | ||||
| @@ -0,0 +1,69 @@ | ||||
| import {AddressReference, TranslatableString} from "ChillMainAssets/types"; | ||||
|  | ||||
| export interface AddressAggregated { | ||||
|     row_number: number; | ||||
|     street: string; | ||||
|     postcode_id: number; | ||||
|     code: string; | ||||
|     label: string; | ||||
|     positions: Record<string, string>; | ||||
| } | ||||
|  | ||||
| export interface AssociatedPostalCode { | ||||
|     postcode_id: number; | ||||
|     code: string; | ||||
|     label: string; | ||||
|     country_id: number; | ||||
|     country_code: string; | ||||
|     country_name: TranslatableString; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @throws {DOMException} when fetch is aborted, the property name is always equals to 'AbortError' | ||||
|  */ | ||||
| export const getAddressesAggregated = async ( | ||||
|     search: string, | ||||
|     abortController: AbortController, | ||||
| ): Promise<AddressAggregated[]> => { | ||||
|     const params = new URLSearchParams({ q: search.trim() }); | ||||
|     let response = null; | ||||
|  | ||||
|     response = await fetch( | ||||
|         `/api/1.0/main/address-reference/aggregated/search?${params}`, | ||||
|         { signal: abortController.signal }, | ||||
|     ); | ||||
|  | ||||
|     if (response.ok) { | ||||
|         return await response.json(); | ||||
|     } | ||||
|  | ||||
|     throw new Error(response.statusText); | ||||
| }; | ||||
|  | ||||
| export const getPostalCodes = async ( | ||||
|     search: string, | ||||
|     abortController: AbortController, | ||||
| ): Promise<AssociatedPostalCode[]> => { | ||||
|     const params = new URLSearchParams({ q: search.trim() }); | ||||
|     let response = null; | ||||
|  | ||||
|     response = await fetch( | ||||
|         `/api/1.0/main/address-reference/postal-code/search?${params}`, | ||||
|         { signal: abortController.signal }, | ||||
|     ); | ||||
|  | ||||
|     if (response.ok) { | ||||
|         return await response.json(); | ||||
|     } | ||||
|  | ||||
|     throw new Error(response.statusText); | ||||
| }; | ||||
|  | ||||
| export const fetchAddressReference = async (id: string): Promise<AddressReference> => { | ||||
|     const response = await fetch(`/api/1.0/main/address-reference/${id}.json`); | ||||
|     if (response.ok) { | ||||
|         return await response.json(); | ||||
|     } | ||||
|  | ||||
|     throw new Error(response.statusText); | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| import {Address, AddressCreation, AddressReference} from "ChillMainAssets/types"; | ||||
|  | ||||
| export const addressReferenceToAddress = (reference: AddressReference): AddressCreation => { | ||||
|     return { | ||||
|         street: reference.street, | ||||
|         streetNumber: reference.streetNumber, | ||||
|         postcode: reference.postcode, | ||||
|         floor: "", | ||||
|         corridor: "", | ||||
|         steps: "", | ||||
|         flat: "", | ||||
|         buildingName: "", | ||||
|         distribution: "", | ||||
|         extra: "", | ||||
|         confidential: false, | ||||
|         addressReference: reference, | ||||
|         point: reference.point, | ||||
|         isNoAddress: false, | ||||
|         validFrom: null, | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| import { createApp } from "vue"; | ||||
| import AddressButton from "ChillMainAssets/vuejs/AddressPicker/AddressButton.vue"; | ||||
|  | ||||
| document.addEventListener("DOMContentLoaded", async () => { | ||||
|     document | ||||
|         .querySelectorAll<HTMLDivElement>("div[data-address-picker]") | ||||
|         .forEach((elem): void => { | ||||
|             const app = createApp(AddressButton); | ||||
|  | ||||
|             app.mount(elem); | ||||
|         }); | ||||
| }); | ||||
| @@ -4,24 +4,27 @@ | ||||
|         :show-button-details="false" | ||||
|     ></address-render-box> | ||||
|     <address-details-ref-matching | ||||
|         v-if="isAddress(props.address)" | ||||
|         :address="props.address" | ||||
|         @update-address="onUpdateAddress" | ||||
|     ></address-details-ref-matching> | ||||
|     <address-details-map :address="props.address"></address-details-map> | ||||
|     <address-details-geographical-layers | ||||
|         v-if="isAddress(props.address)" | ||||
|         :address="props.address" | ||||
|     ></address-details-geographical-layers> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { Address } from "../../../types"; | ||||
| import {Address, AddressCreation} from "../../../types"; | ||||
| import AddressDetailsMap from "./Parts/AddressDetailsMap.vue"; | ||||
| import AddressRenderBox from "../Entity/AddressRenderBox.vue"; | ||||
| import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue"; | ||||
| import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue"; | ||||
| import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper"; | ||||
|  | ||||
| interface AddressModalContentProps { | ||||
|     address: Address; | ||||
|     address: Address|AddressCreation; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressModalContentProps>(); | ||||
|   | ||||
| @@ -12,90 +12,91 @@ | ||||
|         Voir sur | ||||
|         <a :href="makeUrlGoogleMap(props.address)" target="_blank" | ||||
|             >Google Maps</a | ||||
|         > | ||||
|         <a :href="makeUrlOsm(props.address)" target="_blank">OSM</a> | ||||
|         > <a | ||||
|           :href="makeUrlOsm(props.address)" target="_blank" | ||||
|         >OSM</a> | ||||
|     </p> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, ref } from "vue"; | ||||
| import {computed, onMounted, ref} from "vue"; | ||||
| import "leaflet/dist/leaflet.css"; | ||||
| import markerIconPng from "leaflet/dist/images/marker-icon.png"; | ||||
| import L, { LatLngExpression, LatLngTuple } from "leaflet"; | ||||
| import { Address, Point } from "../../../../types"; | ||||
| import {Address, AddressCreation, Point} from "../../../../types"; | ||||
| import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper"; | ||||
| import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet"; | ||||
|  | ||||
| const lonLatForLeaflet = (point: Point): LatLngTuple => { | ||||
|     return [point.coordinates[1], point.coordinates[0]]; | ||||
| }; | ||||
|  | ||||
| export interface MapProps { | ||||
|     address: Address; | ||||
|     address: Address|AddressCreation; | ||||
| } | ||||
|  | ||||
| const props = defineProps<MapProps>(); | ||||
|  | ||||
| const map_div = ref<HTMLDivElement | null>(null); | ||||
| let map: L.Map | null = null; | ||||
| let marker: L.Marker | null = null; | ||||
|  | ||||
| onMounted(() => { | ||||
|     if (map_div.value === null) { | ||||
|         // there is no map div when the address does not have any Point | ||||
|         return; | ||||
| const markerIcon = L.icon({ | ||||
|     iconUrl: markerIconPng, | ||||
|     iconAnchor: [12, 41], | ||||
| }); | ||||
| const latLngMarker = computed((): LatLngExpression => { | ||||
|     if (props.address === null || props.address.point === null) { | ||||
|         return [0, 0, 0]; | ||||
|     } | ||||
|  | ||||
|     if (props.address.point !== null) { | ||||
|         map = L.map(map_div.value); | ||||
|         map.setView(lonLatForLeaflet(props.address.point), 18); | ||||
|  | ||||
|         L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | ||||
|             attribution: | ||||
|                 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', | ||||
|         }).addTo(map); | ||||
|  | ||||
|         const markerIcon = L.icon({ | ||||
|             iconUrl: markerIconPng, | ||||
|             iconAnchor: [12, 41], | ||||
|         }); | ||||
|  | ||||
|         marker = L.marker(lonLatForLeaflet(props.address.point), { | ||||
|             icon: markerIcon, | ||||
|         }); | ||||
|         marker.addTo(map); | ||||
|     } | ||||
|     return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0] | ||||
| }); | ||||
|  | ||||
| const makeUrlGoogleMap = (address: Address): string => { | ||||
| const map_div = ref<HTMLDivElement | null>(null); | ||||
| const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker}); | ||||
| const tileLayer = useLeafletTileLayer( | ||||
|     "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | ||||
|         attribution: | ||||
|             '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', | ||||
|     } | ||||
| ); | ||||
| useLeafletDisplayLayer(map, tileLayer); | ||||
|  | ||||
|  | ||||
| const marker = useLeafletMarker(latLngMarker, {icon: markerIcon}); | ||||
| useLeafletDisplayLayer(map, marker); | ||||
|  | ||||
|  | ||||
| const makeUrlGoogleMap = (address: Address|AddressCreation): string => { | ||||
|     const params = new URLSearchParams(); | ||||
|     params.append("api", "1"); | ||||
|     if (address.point !== null && address.addressReference !== null) { | ||||
|     const point = getAddressPoint(address); | ||||
|     if (point !== null && address.addressReference !== null) { | ||||
|         params.append( | ||||
|             "query", | ||||
|             `${address.point.coordinates[1]} ${address.point.coordinates[0]}`, | ||||
|             `${point.coordinates[1]} ${point.coordinates[0]}`, | ||||
|         ); | ||||
|     } else { | ||||
|         params.append("query", address.lines.join(", ")); | ||||
|         params.append("query", buildAddressLines(address).join(", ")); | ||||
|     } | ||||
|  | ||||
|     return `https://www.google.com/maps/search/?${params.toString()}`; | ||||
| }; | ||||
|  | ||||
| const makeUrlOsm = (address: Address): string => { | ||||
|     if (address.point !== null && address.addressReference !== null) { | ||||
| const makeUrlOsm = (address: Address|AddressCreation): string => { | ||||
|     const point = getAddressPoint(address); | ||||
|     if (point !== null && address.addressReference !== null) { | ||||
|         const params = new URLSearchParams(); | ||||
|         params.append("mlat", `${address.point.coordinates[1]}`); | ||||
|         params.append("mlon", `${address.point.coordinates[0]}`); | ||||
|         params.append("mlat", `${point.coordinates[1]}`); | ||||
|         params.append("mlon", `${point.coordinates[0]}`); | ||||
|         const hashParams = new URLSearchParams(); | ||||
|         hashParams.append( | ||||
|             "map", | ||||
|             `18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`, | ||||
|             `18/${point.coordinates[1]}/${point.coordinates[0]}`, | ||||
|         ); | ||||
|  | ||||
|         return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`; | ||||
|     } | ||||
|  | ||||
|     const params = new URLSearchParams(); | ||||
|     params.append("query", address.lines.join(", ")); | ||||
|     params.append("query", buildAddressLines(address).join(", ")); | ||||
|  | ||||
|     return `https://www.openstreetmap.org/search?${params.toString()}`; | ||||
| }; | ||||
|   | ||||
| @@ -0,0 +1,46 @@ | ||||
| import {Address, AddressCreation, Point} from "ChillMainAssets/types"; | ||||
| import addressFormatter, {Input} from "@fragaria/address-formatter"; | ||||
|  | ||||
| /** | ||||
|  * Checks if the given object is of type Address by verifying the existence | ||||
|  * of the `lines` property and confirming that it is an array of strings. | ||||
|  * | ||||
|  * @param {AddressCreation | Address} obj - The object to check. | ||||
|  * @return {boolean} Returns true if the object is of type Address, otherwise false. | ||||
|  */ | ||||
| export function isAddress(obj: AddressCreation | Address): obj is Address { | ||||
|     return (obj as any).lines !== undefined && Array.isArray((obj as any).lines); | ||||
| } | ||||
|  | ||||
| function buildAddressFormatterObject(address: AddressCreation): Input { | ||||
|     return { | ||||
|         city: address.postcode.name, | ||||
|         postcode: address.postcode.code, | ||||
|         countryCode: address.postcode.country.code, | ||||
|         street: address.street, | ||||
|         houseNumber: address.streetNumber, | ||||
|     }; | ||||
| } | ||||
|  | ||||
|  | ||||
| export const buildAddressLines = (address: AddressCreation|Address): string[] => { | ||||
|     if (isAddress(address)) { | ||||
|         return address.lines; | ||||
|     } | ||||
|  | ||||
|     const lines = addressFormatter.format(buildAddressFormatterObject(address), {output: 'array', countryCode: address.addressReference.postcode.country.code }); | ||||
|     console.log('lines:', lines); | ||||
|     return lines; | ||||
| } | ||||
|  | ||||
| export const buildAddressText = (address: AddressCreation|Address): string => { | ||||
|     return buildAddressLines(address).join(' - '); | ||||
| } | ||||
|  | ||||
| export const getAddressPoint = (address: AddressCreation|Address): Point|null => { | ||||
|     if (isAddress(address)) { | ||||
|         return address.point; | ||||
|     } | ||||
|  | ||||
|     return address.addressReference?.point; | ||||
| } | ||||
| @@ -4,14 +4,14 @@ | ||||
|             <div v-if="isConfidential"> | ||||
|                 <confidential :position-btn-far="true"> | ||||
|                     <template #confidential-content> | ||||
|                         <div v-if="isMultiline === true"> | ||||
|                         <div v-if="isMultiline"> | ||||
|                             <p | ||||
|                                 v-for="(l, i) in address.lines" | ||||
|                                 v-for="(l, i) in buildAddressLines(address)" | ||||
|                                 :key="`line-${i}`" | ||||
|                             > | ||||
|                                 {{ l }} | ||||
|                             </p> | ||||
|                             <p v-if="showButtonDetails"> | ||||
|                             <p v-if="showButtonDetails && isAddress(address) "> | ||||
|                                 <address-details-button | ||||
|                                     :address_id="address.address_id" | ||||
|                                     :address_ref_status="address.refStatus" | ||||
| @@ -19,8 +19,8 @@ | ||||
|                             </p> | ||||
|                         </div> | ||||
|                         <div v-else> | ||||
|                             <p v-if="'' !== address.text" class="street"> | ||||
|                                 {{ address.text }} | ||||
|                             <p v-if="'' !== buildAddressText(address)" class="street"> | ||||
|                                 {{ buildAddressText(address) }} | ||||
|                             </p> | ||||
|                             <p | ||||
|                                 v-if="null !== address.postcode" | ||||
| @@ -29,8 +29,8 @@ | ||||
|                                 {{ address.postcode.code }} | ||||
|                                 {{ address.postcode.name }} | ||||
|                             </p> | ||||
|                             <p v-if="null !== address.country" class="country"> | ||||
|                                 {{ localizeString(address.country.name) }} | ||||
|                             <p v-if="null !== address.postcode" class="country"> | ||||
|                                 {{ localizeString(address.postcode.country.name) }} | ||||
|                             </p> | ||||
|                         </div> | ||||
|                     </template> | ||||
| @@ -38,11 +38,11 @@ | ||||
|             </div> | ||||
|  | ||||
|             <div v-if="!isConfidential"> | ||||
|                 <div v-if="isMultiline === true"> | ||||
|                     <p v-for="(l, i) in address.lines" :key="`line-${i}`"> | ||||
|                 <div v-if="isMultiline"> | ||||
|                     <p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`"> | ||||
|                         {{ l }} | ||||
|                     </p> | ||||
|                     <p v-if="showButtonDetails"> | ||||
|                     <p v-if="showButtonDetails && isAddress(address) "> | ||||
|                         <address-details-button | ||||
|                             :address_id="address.address_id" | ||||
|                             :address_ref_status="address.refStatus" | ||||
| @@ -50,9 +50,9 @@ | ||||
|                     </p> | ||||
|                 </div> | ||||
|                 <div v-else> | ||||
|                     <p v-if="address.text" class="street"> | ||||
|                         {{ address.text }} | ||||
|                         <template v-if="showButtonDetails"> | ||||
|                     <p v-if="'' !== buildAddressText(address)" class="street"> | ||||
|                         {{ buildAddressText(address)}} | ||||
|                         <template v-if="showButtonDetails && isAddress(address) "> | ||||
|                             <address-details-button | ||||
|                                 :address_id="address.address_id" | ||||
|                                 :address_ref_status="address.refStatus" | ||||
| @@ -63,68 +63,49 @@ | ||||
|             </div> | ||||
|         </component> | ||||
|  | ||||
|         <div v-if="useDatePane === true" class="address-more"> | ||||
|         <div v-if="useDatePane" class="address-more"> | ||||
|             <div v-if="address.validFrom"> | ||||
|                 <span class="validFrom"> | ||||
|                     <b>{{ trans(ADDRESS_VALID_FROM) }}</b | ||||
|                     >: {{ $d(address.validFrom.date) }} | ||||
|                     >: {{ address.validFrom?.datetime8601 }} | ||||
|                 </span> | ||||
|             </div> | ||||
|             <div v-if="address.validTo"> | ||||
|             <div v-if="isAddress(address) && address.validTo !== null"> | ||||
|                 <span class="validTo"> | ||||
|                     <b>{{ trans(ADDRESS_VALID_TO) }}</b | ||||
|                     >: {{ $d(address.validTo.date) }} | ||||
|                     >: {{ address.validTo?.datetime8601 }} | ||||
|                 </span> | ||||
|             </div> | ||||
|         </div> | ||||
|     </component> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| <script setup lang="ts"> | ||||
| import { computed } from "vue"; | ||||
| import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue"; | ||||
| import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue"; | ||||
| import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator"; | ||||
| import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; | ||||
| import {Address, AddressCreation} from "ChillMainAssets/types"; | ||||
| import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper"; | ||||
|  | ||||
| export default { | ||||
|     name: "AddressRenderBox", | ||||
|     methods: { localizeString }, | ||||
|     components: { | ||||
|         Confidential, | ||||
|         AddressDetailsButton, | ||||
|     }, | ||||
|     props: { | ||||
|         address: { | ||||
|             type: Object, | ||||
|         }, | ||||
|         isMultiline: { | ||||
|             default: true, | ||||
|             type: Boolean, | ||||
|         }, | ||||
|         useDatePane: { | ||||
|             default: false, | ||||
|             type: Boolean, | ||||
|         }, | ||||
|         showButtonDetails: { | ||||
|             default: true, | ||||
|             type: Boolean, | ||||
|         }, | ||||
|     }, | ||||
|     setup() { | ||||
|         return { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO }; | ||||
|     }, | ||||
|     computed: { | ||||
|         component() { | ||||
|             return this.isMultiline === true ? "div" : "span"; | ||||
|         }, | ||||
|         multiline() { | ||||
|             return this.isMultiline === true ? "multiline" : ""; | ||||
|         }, | ||||
|         isConfidential() { | ||||
|             return this.address.confidential; | ||||
|         }, | ||||
|     }, | ||||
| }; | ||||
| const props = withDefaults( | ||||
|     defineProps<{ | ||||
|         address: Address|AddressCreation; | ||||
|         isMultiline?: boolean; | ||||
|         useDatePane?: boolean; | ||||
|         showButtonDetails?: boolean; | ||||
|     }>(), | ||||
|     { | ||||
|         isMultiline: true, | ||||
|         useDatePane: false, | ||||
|         showButtonDetails: true, | ||||
|     } | ||||
| ); | ||||
|  | ||||
| const component = computed(() => (props.isMultiline ? "div" : "span")); | ||||
| const multiline = computed(() => (props.isMultiline ? "multiline" : "")); | ||||
| const isConfidential = computed(() => Boolean(props.address?.confidential)); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -0,0 +1,15 @@ | ||||
| {% extends '@ChillMain/layout.html.twig' %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ encore_entry_link_tags('address_picker') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ encore_entry_script_tags('address_picker') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
|     <div data-address-picker="data-address-picker"></div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -21,6 +21,8 @@ | ||||
|     {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} | ||||
|     {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} | ||||
|  | ||||
|     {{ form_row(form.addressesEmails) }} | ||||
|  | ||||
|     {% include handler.template(notification) with handler.templateData(notification) %} | ||||
|  | ||||
|     <div class="mb-3 row"> | ||||
|   | ||||
| @@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw | ||||
|                     'name' => $address->getPostCode()->getName(), | ||||
|                     'code' => $address->getPostCode()->getCode(), | ||||
|                     'center' => $address->getPostcode()->getCenter(), | ||||
|                     'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]), | ||||
|                 ], | ||||
|                 'country' => [ | ||||
|                     'id' => $address->getPostCode()->getCountry()->getId(), | ||||
|   | ||||
| @@ -0,0 +1,118 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Controller\AddressReferenceAggregatedApiController; | ||||
| use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @covers \Chill\MainBundle\Controller\AddressReferenceAggregatedApiController | ||||
|  */ | ||||
| final class AddressReferenceAggregatedApiControllerTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testAccessDeniedWhenNotAuthenticated(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(false); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         $request = new Request(query: ['q' => 'anything']); | ||||
|  | ||||
|         $this->expectException(AccessDeniedHttpException::class); | ||||
|  | ||||
|         $controller->search($request); | ||||
|     } | ||||
|  | ||||
|     public function testBadRequestWhenQueryIsMissing(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(true); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         $request = new Request(); | ||||
|  | ||||
|         $this->expectException(BadRequestHttpException::class); | ||||
|         $this->expectExceptionMessage('Parameter "q" is required.'); | ||||
|  | ||||
|         $controller->search($request); | ||||
|     } | ||||
|  | ||||
|     public function testBadRequestWhenQueryIsEmpty(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(true); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         $request = new Request(query: ['q' => '   ']); | ||||
|  | ||||
|         $this->expectException(BadRequestHttpException::class); | ||||
|         $this->expectExceptionMessage('Parameter "q" is required and cannot be empty.'); | ||||
|  | ||||
|         $controller->search($request); | ||||
|     } | ||||
|  | ||||
|     public function testSuccessfulSearchReturnsJsonAndCallsRepositoryWithTrimmedQuery(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(true); | ||||
|  | ||||
|         $expectedQuery = 'foo'; | ||||
|         $iterableResult = new \ArrayIterator([ | ||||
|             [ | ||||
|                 'street' => 'Main Street', | ||||
|                 'postcode_id' => 123, | ||||
|                 'code' => '1000', | ||||
|                 'label' => 'Brussels', | ||||
|                 'positions' => ['1' => '12', '2' => '14'], | ||||
|                 'row_number' => 1, | ||||
|             ], | ||||
|         ]); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|         $repo->findAggregatedBySearchString($expectedQuery)->willReturn($iterableResult)->shouldBeCalledOnce(); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         // Provide spaces around to ensure trimming is applied | ||||
|         $request = new Request(query: ['q' => "  {$expectedQuery}  "]); | ||||
|  | ||||
|         $response = $controller->search($request); | ||||
|  | ||||
|         self::assertSame(200, $response->getStatusCode()); | ||||
|         $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); | ||||
|         self::assertIsArray($data); | ||||
|         self::assertCount(1, $data); | ||||
|         self::assertSame('Main Street', $data[0]['street']); | ||||
|         self::assertSame(123, $data[0]['postcode_id']); | ||||
|         self::assertSame('1000', $data[0]['code']); | ||||
|         self::assertSame('Brussels', $data[0]['label']); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,88 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\AddressReference; | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Chill\MainBundle\Repository\AddressReferenceRepository; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class AddressReferenceRepositoryTest extends KernelTestCase | ||||
| { | ||||
|     private static AddressReferenceRepository $repository; | ||||
|  | ||||
|     public static function setUpBeforeClass(): void | ||||
|     { | ||||
|         static::bootKernel(); | ||||
|         static::$repository = static::getContainer()->get(AddressReferenceRepository::class); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateSearchString | ||||
|      */ | ||||
|     public function testFindBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void | ||||
|     { | ||||
|         $actual = static::$repository->findBySearchString($search, $postalCode); | ||||
|  | ||||
|         self::assertIsIterable($actual, $text); | ||||
|  | ||||
|         if (null !== $expected) { | ||||
|             self::assertEquals($expected, iterator_to_array($actual)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateSearchString | ||||
|      */ | ||||
|     public function testCountBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void | ||||
|     { | ||||
|         $actual = static::$repository->countBySearchString($search, $postalCode); | ||||
|  | ||||
|         self::assertIsInt($actual, $text); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateSearchString | ||||
|      */ | ||||
|     public function testFindAggreggateBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void | ||||
|     { | ||||
|         $actual = static::$repository->findAggregatedBySearchString($search, $postalCode); | ||||
|  | ||||
|         self::assertIsIterable($actual, $text); | ||||
|  | ||||
|         if (null !== $expected) { | ||||
|             self::assertEquals($expected, iterator_to_array($actual)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static function generateSearchString(): iterable | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $em = static::getContainer()->get(EntityManagerInterface::class); | ||||
|         /** @var AddressReference $ar */ | ||||
|         $ar = $em->createQuery('SELECT ar FROM '.AddressReference::class.' ar') | ||||
|             ->setMaxResults(1) | ||||
|             ->getSingleResult(); | ||||
|  | ||||
|         yield ['', null, 'search with empty string', []]; | ||||
|         yield ['   ', null, 'search with spaces only', []]; | ||||
|         yield ['rue des    moulins', null, 'search contains an empty string']; | ||||
|         yield ['rue des moulins', $ar->getPostcode()->getId(), 'search with postal code as an id']; | ||||
|         yield ['rue des moulins', $ar->getPostcode(), 'search with postal code instance']; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,61 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepository; | ||||
| use Doctrine\DBAL\Connection; | ||||
| use PHPUnit\Framework\Attributes\DataProvider; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| final class PostalCodeForAddressReferenceRepositoryTest extends KernelTestCase | ||||
| { | ||||
|     private Connection $connection; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $this->connection = self::getContainer()->get(Connection::class); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return iterable<string[]> | ||||
|      */ | ||||
|     public static function provideSearches(): iterable | ||||
|     { | ||||
|         yield ['']; | ||||
|         yield ['   ']; | ||||
|         yield ['hugo']; | ||||
|         yield ['   hugo']; | ||||
|         yield ['hugo   ']; | ||||
|         yield ['rue victor hugo']; | ||||
|         yield ['rue   victor hugo']; | ||||
|     } | ||||
|  | ||||
|     #[DataProvider('provideSearches')] | ||||
|     public function testFindPostalCodeDoesNotErrorAndIsIterable(string $search): void | ||||
|     { | ||||
|         $repository = new PostalCodeForAddressReferenceRepository($this->connection); | ||||
|  | ||||
|         $result = $repository->findPostalCode($search); | ||||
|  | ||||
|         self::assertIsIterable($result); | ||||
|  | ||||
|         // Ensure it can be converted to an array (and iterate without error) | ||||
|         $rows = \is_array($result) ? $result : iterator_to_array($result, false); | ||||
|         self::assertIsArray($rows); | ||||
|     } | ||||
| } | ||||
| @@ -595,6 +595,44 @@ paths: | ||||
|                 401: | ||||
|                     description: "Unauthorized" | ||||
|  | ||||
|     /1.0/main/address-reference/aggregated/search: | ||||
|         get: | ||||
|             tags: | ||||
|                 - address | ||||
|             summary: Search for address reference aggregated | ||||
|             parameters: | ||||
|                 - name: q | ||||
|                   in: query | ||||
|                   required: true | ||||
|                   description: The search pattern | ||||
|                   schema: | ||||
|                       type: string | ||||
|             responses: | ||||
|                 200: | ||||
|                     description: "ok" | ||||
|                 401: | ||||
|                     description: "Unauthorized" | ||||
|                 400: | ||||
|                     description: "Bad Request" | ||||
|     /1.0/main/address-reference/postal-code/search: | ||||
|         get: | ||||
|             tags: | ||||
|                 - address | ||||
|             summary: Search for postal code that can contains the search query | ||||
|             parameters: | ||||
|                 - name: q | ||||
|                   in: query | ||||
|                   required: true | ||||
|                   description: The search pattern | ||||
|                   schema: | ||||
|                       type: string | ||||
|             responses: | ||||
|                 200: | ||||
|                     description: "ok" | ||||
|                 401: | ||||
|                     description: "Unauthorized" | ||||
|                 400: | ||||
|                     description: "Bad Request" | ||||
|     /1.0/main/postal-code/search.json: | ||||
|         get: | ||||
|             tags: | ||||
|   | ||||
| @@ -120,6 +120,11 @@ module.exports = function (encore, entries) { | ||||
|     "vue_onthefly", | ||||
|     __dirname + "/Resources/public/vuejs/OnTheFly/index.js", | ||||
|   ); | ||||
|   encore.addEntry( | ||||
|     'address_picker', | ||||
|     __dirname + "/Resources/public/vuejs/AddressPicker/index.ts", | ||||
|   ) | ||||
|  | ||||
|   encore.addEntry( | ||||
|     "page_workflow_waiting_post_process", | ||||
|     __dirname + "/Resources/public/vuejs/WaitPostProcessWorkflow/index.ts" | ||||
|   | ||||
| @@ -0,0 +1,52 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\Migrations\Main; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20250214154310 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Create view for searching address reference'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql(<<<'SQL' | ||||
|             create materialized view public.view_chill_main_address_reference as | ||||
|             SELECT row_number() OVER ()       AS row_number, | ||||
|                    cmar.street                AS street, | ||||
|                    cmar.streetnumber          AS streetnumber, | ||||
|                    cmar.id                    AS address_id, | ||||
|                    lower(unaccent( | ||||
|                            (((((cmar.street || ' '::text) || cmar.streetnumber) || ' '::text) || cmpc.code::text) || ' '::text) || | ||||
|                            cmpc.label::text)) AS address, | ||||
|                    cmpc.id                    AS postcode_id | ||||
|             FROM chill_main_address_reference cmar | ||||
|                      JOIN chill_main_postal_code cmpc ON cmar.postcode_id = cmpc.id | ||||
|             WHERE cmar.deletedat IS NULL | ||||
|             ORDER BY ((cmpc.code::text || ' '::text) || cmpc.label::text), cmar.street, (lpad(cmar.streetnumber, 10, '0'::text)); | ||||
|             SQL); | ||||
|  | ||||
|         $this->addSql(<<<'SQL' | ||||
|             create index if not exists view_chill_internal_address_reference_trgm | ||||
|                 on view_chill_main_address_reference using gist (postcode_id, address public.gist_trgm_ops); | ||||
|         SQL); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('DROP MATERIALIZED VIEW view_chill_main_address_reference'); | ||||
|     } | ||||
| } | ||||
| @@ -181,6 +181,11 @@ address more: | ||||
|     buildingName: résidence | ||||
|     extra: "" | ||||
|     distribution: cedex | ||||
|  | ||||
| address_picker: | ||||
|     # placeholder | ||||
|     Search for addresses: Chercher des adresses | ||||
|  | ||||
| Create a new address: Créer une nouvelle adresse | ||||
| Create an address: Créer une adresse | ||||
| Update address: Modifier l'adresse | ||||
|   | ||||
		Reference in New Issue
	
	Block a user