Compare commits

..

10 Commits

Author SHA1 Message Date
ac6336d197 Rename concernedUsersCount to concernedPersonsCount + add missing translations 2025-10-06 14:34:33 +02:00
a46b301e44 Add export and aggregator for number of persons concerned 2025-10-06 14:29:25 +02:00
05f0443011 Translation added and templates adjusted 2025-10-06 14:02:39 +02:00
7f8d8f891e Configuration added for show concerned users number field in AsideActivity form 2025-10-06 14:02:16 +02:00
ddb932a4fa Feature: add form field concernedUsersCount in AsideActivityFormType.php 2025-10-06 13:48:25 +02:00
3a02f15bcd Feature: add property concernedUsersCount to the AsideActivity.php entity 2025-10-06 13:41:21 +02:00
bc2fbee5c6 Fix: notification edit template
form field addressesEmail removed
2025-10-06 12:14:00 +02:00
ebd10ca522 Merge branch 'fix/history-of-versions-stored-object' into 'master'
Fix the rendering of storedObject's history

See merge request Chill-Projet/chill-bundles!893
2025-10-03 20:47:06 +00:00
d3a31be412 Fix re-ordering of StoredObjectVersion in the list of versions
As some intermediate versions are remove, this may lead to situation where the indexes are not continous. In that case, the array is not a list, and is rendered as an array with numeric indexes, instead of a list of elements. The HistoryListItem component fails to render.

- Ensured proper handling of removed versions by using `array_values` to reindex items.
- Added test case to validate the result after removing a version.
- Asserted the results are a proper list in the API response.
2025-10-03 22:40:59 +02:00
d159a82f88 Update import paths in HistoryButtonListItem.vue to use aliases
- Changed types import to use `ChillDocStoreAssets/types`.
- Updated `ISOToDatetime` import to use `ChillMainAssets/chill/js/date`.
2025-10-03 22:20:51 +02:00
50 changed files with 416 additions and 1483 deletions

View File

@@ -0,0 +1,6 @@
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

View File

@@ -0,0 +1,6 @@
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

View File

@@ -120,3 +120,6 @@ chill_activity:
-
label: '5 hours'
seconds: 18000
chill_aside_activity:
show_concerned_persons_count: true

View File

@@ -17,9 +17,3 @@ 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'

View File

@@ -45,7 +45,6 @@
"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",
@@ -67,12 +66,10 @@
"mime": "^4.0.0",
"pdfjs-dist": "^4.3.136",
"vis-network": "^9.1.0",
"vue": "^3.5.x",
"vue": "^3.5.6",
"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": [

View File

@@ -25,6 +25,7 @@ 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', $config['show_concerned_persons_count']);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');

View File

@@ -141,6 +141,11 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->end()
->booleanNode('show_concerned_persons_count')
->defaultTrue()
->info('Show the concerned persons count field in aside activity forms and views')
->end()
->end();
return $treeBuilder;

View File

@@ -62,6 +62,10 @@ 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 = null;
public function getAgent(): ?User
{
return $this->agent;
@@ -186,4 +190,16 @@ 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;
}
}

View File

@@ -0,0 +1,86 @@
<?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';
}
}

View File

@@ -0,0 +1,116 @@
<?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,
];
}
}

View File

@@ -21,6 +21,7 @@ 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;
@@ -29,11 +30,13 @@ 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)
@@ -76,6 +79,16 @@ 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);

View File

@@ -42,6 +42,11 @@
{%- 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">

View File

@@ -38,6 +38,9 @@
<dt class="inline">{{ 'Duration'|trans }}</dt>
<dd>{{ entity.duration|date('H:i') }}</dd>
<dt class="inline">{{ 'Concerned persons count'|trans }}</dt>
<dd>{{ entity.concernedPersonsCount }}</dd>
<dt class="inline">{{ 'Remark'|trans }}</dt>
{%- if entity.note is empty -%}
<dd>

View File

@@ -20,6 +20,10 @@ 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
@@ -70,3 +74,7 @@ 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' }

View File

@@ -0,0 +1,33 @@
<?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 NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount');
}
}

View File

@@ -27,6 +27,7 @@ Emergency: Urgent
by: "Par "
location: Lieu
Asideactivity location: Localisation de l'activité
Concerned persons count: Nombre d'usager concernés
# Crud
crud:
@@ -190,6 +191,7 @@ 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é
@@ -210,6 +212,8 @@ 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

View File

@@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController
return new JsonResponse(
$this->serializer->serialize(
new Collection($items, $paginator),
new Collection(array_values($items->toArray()), $paginator),
'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
),

View File

@@ -3,9 +3,9 @@ import {
StoredObject,
StoredObjectPointInTime,
StoredObjectVersionWithPointInTime,
} from "./../../../types";
} from "ChillDocStoreAssets/types";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
import { ISOToDatetime } from "ChillMainAssets/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";

View File

@@ -40,6 +40,10 @@ 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)
@@ -53,6 +57,7 @@ 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']);
}

View File

@@ -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\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));
}
}

View File

@@ -1,47 +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\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);
}
}

View File

@@ -14,14 +14,13 @@ 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 AddressReferenceRepositoryInterface
final readonly class AddressReferenceRepository implements ObjectRepository
{
private EntityManagerInterface $entityManager;
@@ -66,121 +65,6 @@ final readonly class AddressReferenceRepository implements AddressReferenceRepos
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

View File

@@ -1,20 +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\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;
}

View File

@@ -1,69 +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\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
);
}
}

View File

@@ -1,20 +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\MainBundle\Repository;
/**
* Search for postal code using optimized materialized view.
*/
interface PostalCodeForAddressReferenceRepositoryInterface
{
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable;
}

View File

@@ -75,7 +75,6 @@ export interface Postcode {
name: string;
code: string;
center: Point;
country: Country;
}
export interface Point {
@@ -91,28 +90,6 @@ 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;
@@ -131,7 +108,7 @@ export interface Address {
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime | null; // TODO there is no null for validFrom
validFrom: DateTime;
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;

View File

@@ -1,33 +0,0 @@
<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>

View File

@@ -1,170 +0,0 @@
<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>

View File

@@ -1,25 +0,0 @@
<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>

View File

@@ -1,82 +0,0 @@
<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>

View File

@@ -1,44 +0,0 @@
<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>

View File

@@ -1,112 +0,0 @@
<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>

View File

@@ -1,39 +0,0 @@
<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>

View File

@@ -1,69 +0,0 @@
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);
}

View File

@@ -1,21 +0,0 @@
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,
}
}

View File

@@ -1,12 +0,0 @@
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);
});
});

View File

@@ -4,27 +4,24 @@
: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, AddressCreation} from "../../../types";
import { Address } 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|AddressCreation;
address: Address;
}
const props = defineProps<AddressModalContentProps>();

View File

@@ -12,91 +12,90 @@
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 {computed, onMounted, ref} from "vue";
import { 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, AddressCreation, Point} from "../../../../types";
import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet";
import { Address, Point } from "../../../../types";
const lonLatForLeaflet = (point: Point): LatLngTuple => {
return [point.coordinates[1], point.coordinates[0]];
};
export interface MapProps {
address: Address|AddressCreation;
address: Address;
}
const props = defineProps<MapProps>();
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];
}
return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
});
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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
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;
}
);
useLeafletDisplayLayer(map, tileLayer);
if (props.address.point !== null) {
map = L.map(map_div.value);
map.setView(lonLatForLeaflet(props.address.point), 18);
const marker = useLeafletMarker(latLngMarker, {icon: markerIcon});
useLeafletDisplayLayer(map, marker);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const markerIcon = L.icon({
iconUrl: markerIconPng,
iconAnchor: [12, 41],
});
const makeUrlGoogleMap = (address: Address|AddressCreation): string => {
marker = L.marker(lonLatForLeaflet(props.address.point), {
icon: markerIcon,
});
marker.addTo(map);
}
});
const makeUrlGoogleMap = (address: Address): string => {
const params = new URLSearchParams();
params.append("api", "1");
const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
if (address.point !== null && address.addressReference !== null) {
params.append(
"query",
`${point.coordinates[1]} ${point.coordinates[0]}`,
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`,
);
} else {
params.append("query", buildAddressLines(address).join(", "));
params.append("query", address.lines.join(", "));
}
return `https://www.google.com/maps/search/?${params.toString()}`;
};
const makeUrlOsm = (address: Address|AddressCreation): string => {
const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
const makeUrlOsm = (address: Address): string => {
if (address.point !== null && address.addressReference !== null) {
const params = new URLSearchParams();
params.append("mlat", `${point.coordinates[1]}`);
params.append("mlon", `${point.coordinates[0]}`);
params.append("mlat", `${address.point.coordinates[1]}`);
params.append("mlon", `${address.point.coordinates[0]}`);
const hashParams = new URLSearchParams();
hashParams.append(
"map",
`18/${point.coordinates[1]}/${point.coordinates[0]}`,
`18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`,
);
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
}
const params = new URLSearchParams();
params.append("query", buildAddressLines(address).join(", "));
params.append("query", address.lines.join(", "));
return `https://www.openstreetmap.org/search?${params.toString()}`;
};

View File

@@ -1,46 +0,0 @@
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;
}

View File

@@ -4,14 +4,14 @@
<div v-if="isConfidential">
<confidential :position-btn-far="true">
<template #confidential-content>
<div v-if="isMultiline">
<div v-if="isMultiline === true">
<p
v-for="(l, i) in buildAddressLines(address)"
v-for="(l, i) in address.lines"
:key="`line-${i}`"
>
{{ l }}
</p>
<p v-if="showButtonDetails && isAddress(address) ">
<p v-if="showButtonDetails">
<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="'' !== buildAddressText(address)" class="street">
{{ buildAddressText(address) }}
<p v-if="'' !== address.text" class="street">
{{ address.text }}
</p>
<p
v-if="null !== address.postcode"
@@ -29,8 +29,8 @@
{{ address.postcode.code }}
{{ address.postcode.name }}
</p>
<p v-if="null !== address.postcode" class="country">
{{ localizeString(address.postcode.country.name) }}
<p v-if="null !== address.country" class="country">
{{ localizeString(address.country.name) }}
</p>
</div>
</template>
@@ -38,11 +38,11 @@
</div>
<div v-if="!isConfidential">
<div v-if="isMultiline">
<p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
<div v-if="isMultiline === true">
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
{{ l }}
</p>
<p v-if="showButtonDetails && isAddress(address) ">
<p v-if="showButtonDetails">
<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="'' !== buildAddressText(address)" class="street">
{{ buildAddressText(address)}}
<template v-if="showButtonDetails && isAddress(address) ">
<p v-if="address.text" class="street">
{{ address.text }}
<template v-if="showButtonDetails">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -63,49 +63,68 @@
</div>
</component>
<div v-if="useDatePane" class="address-more">
<div v-if="useDatePane === true" class="address-more">
<div v-if="address.validFrom">
<span class="validFrom">
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
>: {{ address.validFrom?.datetime8601 }}
>: {{ $d(address.validFrom.date) }}
</span>
</div>
<div v-if="isAddress(address) && address.validTo !== null">
<div v-if="address.validTo">
<span class="validTo">
<b>{{ trans(ADDRESS_VALID_TO) }}</b
>: {{ address.validTo?.datetime8601 }}
>: {{ $d(address.validTo.date) }}
</span>
</div>
</div>
</component>
</template>
<script setup lang="ts">
import { computed } from "vue";
<script>
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";
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));
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;
},
},
};
</script>
<style lang="scss" scoped>

View File

@@ -1,15 +0,0 @@
{% 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 %}

View File

@@ -21,8 +21,6 @@
{{ 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">

View File

@@ -66,7 +66,6 @@ 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(),

View File

@@ -1,118 +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\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']);
}
}

View File

@@ -1,88 +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\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'];
}
}

View File

@@ -1,61 +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\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);
}
}

View File

@@ -595,44 +595,6 @@ 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:

View File

@@ -120,11 +120,6 @@ 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"

View File

@@ -1,52 +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\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');
}
}

View File

@@ -181,11 +181,6 @@ 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