mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-10 08:44:58 +00:00
Compare commits
14 Commits
master
...
#361-impro
Author | SHA1 | Date | |
---|---|---|---|
e55a7f2993
|
|||
8cade9ac77
|
|||
eb25f1408e
|
|||
279901ac93
|
|||
b1a7680b5e
|
|||
b1703a4187
|
|||
b16e6c4517
|
|||
d4eddaf671
|
|||
a126f2f06d
|
|||
845aa040cc
|
|||
5a284fe6cf
|
|||
a2b8e0e6ae
|
|||
189a26e0e8
|
|||
2e32b8946b
|
@@ -17,3 +17,9 @@ when@dev:
|
|||||||
defaults:
|
defaults:
|
||||||
template: '@ChillMain/Dev/dev.assets.test2.html.twig'
|
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"
|
"webpack-cli": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fragaria/address-formatter": "^6.6.1",
|
||||||
"@fullcalendar/core": "^6.1.4",
|
"@fullcalendar/core": "^6.1.4",
|
||||||
"@fullcalendar/daygrid": "^6.1.4",
|
"@fullcalendar/daygrid": "^6.1.4",
|
||||||
"@fullcalendar/interaction": "^6.1.4",
|
"@fullcalendar/interaction": "^6.1.4",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
"vue-i18n": "^9.1.6",
|
"vue-i18n": "^9.1.6",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-toast-notification": "^3.1.2",
|
"vue-toast-notification": "^3.1.2",
|
||||||
|
"vue-use-leaflet": "^0.1.7",
|
||||||
"vuex": "^4.0.0"
|
"vuex": "^4.0.0"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
|
@@ -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\AddressReference;
|
||||||
use Chill\MainBundle\Entity\PostalCode;
|
use Chill\MainBundle\Entity\PostalCode;
|
||||||
use Chill\MainBundle\Search\SearchApiQuery;
|
use Chill\MainBundle\Search\SearchApiQuery;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Doctrine\ORM\EntityRepository;
|
use Doctrine\ORM\EntityRepository;
|
||||||
|
use Doctrine\ORM\NativeQuery;
|
||||||
use Doctrine\ORM\Query\ResultSetMapping;
|
use Doctrine\ORM\Query\ResultSetMapping;
|
||||||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||||
use Doctrine\Persistence\ObjectRepository;
|
|
||||||
|
|
||||||
final readonly class AddressReferenceRepository implements ObjectRepository
|
final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
|
||||||
{
|
{
|
||||||
private EntityManagerInterface $entityManager;
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
@@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository
|
|||||||
return $this->repository->findAll();
|
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 $limit
|
||||||
* @param mixed|null $offset
|
* @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;
|
||||||
|
}
|
@@ -74,6 +74,7 @@ export interface Postcode {
|
|||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
center: Point;
|
center: Point;
|
||||||
|
country: Country;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Point {
|
export interface Point {
|
||||||
@@ -89,6 +90,28 @@ export interface Country {
|
|||||||
|
|
||||||
export type AddressRefStatus = "match" | "to_review" | "reviewed";
|
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 {
|
export interface Address {
|
||||||
type: "address";
|
type: "address";
|
||||||
address_id: number;
|
address_id: number;
|
||||||
@@ -107,7 +130,7 @@ export interface Address {
|
|||||||
confidential: boolean;
|
confidential: boolean;
|
||||||
lines: string[];
|
lines: string[];
|
||||||
addressReference: AddressReference | null;
|
addressReference: AddressReference | null;
|
||||||
validFrom: DateTime;
|
validFrom: DateTime | null; // TODO there is no null for validFrom
|
||||||
validTo: DateTime | null;
|
validTo: DateTime | null;
|
||||||
point: Point | null;
|
point: Point | null;
|
||||||
refStatus: AddressRefStatus;
|
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,142 @@
|
|||||||
|
<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";
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</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"></address-details-form>
|
||||||
|
</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,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {AddressReference} from "ChillMainAssets/types";
|
||||||
|
import {computed} from "vue";
|
||||||
|
import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper";
|
||||||
|
import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue";
|
||||||
|
|
||||||
|
export interface AddressDetailsFormProps {
|
||||||
|
address: AddressReference;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<AddressDetailsFormProps>();
|
||||||
|
|
||||||
|
const address = computed(() => addressReferenceToAddress(props.address));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
FORM
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<address-details-content :address="address"></address-details-content>
|
||||||
|
</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"
|
:show-button-details="false"
|
||||||
></address-render-box>
|
></address-render-box>
|
||||||
<address-details-ref-matching
|
<address-details-ref-matching
|
||||||
|
v-if="isAddress(props.address)"
|
||||||
:address="props.address"
|
:address="props.address"
|
||||||
@update-address="onUpdateAddress"
|
@update-address="onUpdateAddress"
|
||||||
></address-details-ref-matching>
|
></address-details-ref-matching>
|
||||||
<address-details-map :address="props.address"></address-details-map>
|
<address-details-map :address="props.address"></address-details-map>
|
||||||
<address-details-geographical-layers
|
<address-details-geographical-layers
|
||||||
|
v-if="isAddress(props.address)"
|
||||||
:address="props.address"
|
:address="props.address"
|
||||||
></address-details-geographical-layers>
|
></address-details-geographical-layers>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Address } from "../../../types";
|
import {Address, AddressCreation} from "../../../types";
|
||||||
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
|
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
|
||||||
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
|
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
|
||||||
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
|
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
|
||||||
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
|
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
|
||||||
|
import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
|
||||||
|
|
||||||
interface AddressModalContentProps {
|
interface AddressModalContentProps {
|
||||||
address: Address;
|
address: Address|AddressCreation;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<AddressModalContentProps>();
|
const props = defineProps<AddressModalContentProps>();
|
||||||
|
@@ -12,90 +12,91 @@
|
|||||||
Voir sur
|
Voir sur
|
||||||
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
|
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
|
||||||
>Google Maps</a
|
>Google Maps</a
|
||||||
>
|
> <a
|
||||||
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a>
|
:href="makeUrlOsm(props.address)" target="_blank"
|
||||||
|
>OSM</a>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, ref } from "vue";
|
import {computed, onMounted, ref} from "vue";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import markerIconPng from "leaflet/dist/images/marker-icon.png";
|
import markerIconPng from "leaflet/dist/images/marker-icon.png";
|
||||||
import L, { LatLngExpression, LatLngTuple } from "leaflet";
|
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 => {
|
const lonLatForLeaflet = (point: Point): LatLngTuple => {
|
||||||
return [point.coordinates[1], point.coordinates[0]];
|
return [point.coordinates[1], point.coordinates[0]];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MapProps {
|
export interface MapProps {
|
||||||
address: Address;
|
address: Address|AddressCreation;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<MapProps>();
|
const props = defineProps<MapProps>();
|
||||||
|
|
||||||
const map_div = ref<HTMLDivElement | null>(null);
|
const markerIcon = L.icon({
|
||||||
let map: L.Map | null = null;
|
iconUrl: markerIconPng,
|
||||||
let marker: L.Marker | null = null;
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
onMounted(() => {
|
const latLngMarker = computed((): LatLngExpression => {
|
||||||
if (map_div.value === null) {
|
if (props.address === null || props.address.point === null) {
|
||||||
// there is no map div when the address does not have any Point
|
return [0, 0, 0];
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.address.point !== null) {
|
return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
const params = new URLSearchParams();
|
||||||
params.append("api", "1");
|
params.append("api", "1");
|
||||||
if (address.point !== null && address.addressReference !== null) {
|
const point = getAddressPoint(address);
|
||||||
|
if (point !== null && address.addressReference !== null) {
|
||||||
params.append(
|
params.append(
|
||||||
"query",
|
"query",
|
||||||
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`,
|
`${point.coordinates[1]} ${point.coordinates[0]}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
params.append("query", address.lines.join(", "));
|
params.append("query", buildAddressLines(address).join(", "));
|
||||||
}
|
}
|
||||||
|
|
||||||
return `https://www.google.com/maps/search/?${params.toString()}`;
|
return `https://www.google.com/maps/search/?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeUrlOsm = (address: Address): string => {
|
const makeUrlOsm = (address: Address|AddressCreation): string => {
|
||||||
if (address.point !== null && address.addressReference !== null) {
|
const point = getAddressPoint(address);
|
||||||
|
if (point !== null && address.addressReference !== null) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("mlat", `${address.point.coordinates[1]}`);
|
params.append("mlat", `${point.coordinates[1]}`);
|
||||||
params.append("mlon", `${address.point.coordinates[0]}`);
|
params.append("mlon", `${point.coordinates[0]}`);
|
||||||
const hashParams = new URLSearchParams();
|
const hashParams = new URLSearchParams();
|
||||||
hashParams.append(
|
hashParams.append(
|
||||||
"map",
|
"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()}`;
|
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append("query", address.lines.join(", "));
|
params.append("query", buildAddressLines(address).join(", "));
|
||||||
|
|
||||||
return `https://www.openstreetmap.org/search?${params.toString()}`;
|
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">
|
<div v-if="isConfidential">
|
||||||
<confidential :position-btn-far="true">
|
<confidential :position-btn-far="true">
|
||||||
<template #confidential-content>
|
<template #confidential-content>
|
||||||
<div v-if="isMultiline === true">
|
<div v-if="isMultiline">
|
||||||
<p
|
<p
|
||||||
v-for="(l, i) in address.lines"
|
v-for="(l, i) in buildAddressLines(address)"
|
||||||
:key="`line-${i}`"
|
:key="`line-${i}`"
|
||||||
>
|
>
|
||||||
{{ l }}
|
{{ l }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="showButtonDetails">
|
<p v-if="showButtonDetails && isAddress(address) ">
|
||||||
<address-details-button
|
<address-details-button
|
||||||
:address_id="address.address_id"
|
:address_id="address.address_id"
|
||||||
:address_ref_status="address.refStatus"
|
:address_ref_status="address.refStatus"
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p v-if="'' !== address.text" class="street">
|
<p v-if="'' !== buildAddressText(address)" class="street">
|
||||||
{{ address.text }}
|
{{ buildAddressText(address) }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="null !== address.postcode"
|
v-if="null !== address.postcode"
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
{{ address.postcode.code }}
|
{{ address.postcode.code }}
|
||||||
{{ address.postcode.name }}
|
{{ address.postcode.name }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="null !== address.country" class="country">
|
<p v-if="null !== address.postcode" class="country">
|
||||||
{{ localizeString(address.country.name) }}
|
{{ localizeString(address.postcode.country.name) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -38,11 +38,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isConfidential">
|
<div v-if="!isConfidential">
|
||||||
<div v-if="isMultiline === true">
|
<div v-if="isMultiline">
|
||||||
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
|
<p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
|
||||||
{{ l }}
|
{{ l }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="showButtonDetails">
|
<p v-if="showButtonDetails && isAddress(address) ">
|
||||||
<address-details-button
|
<address-details-button
|
||||||
:address_id="address.address_id"
|
:address_id="address.address_id"
|
||||||
:address_ref_status="address.refStatus"
|
:address_ref_status="address.refStatus"
|
||||||
@@ -50,9 +50,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p v-if="address.text" class="street">
|
<p v-if="'' !== buildAddressText(address)" class="street">
|
||||||
{{ address.text }}
|
{{ buildAddressText(address)}}
|
||||||
<template v-if="showButtonDetails">
|
<template v-if="showButtonDetails && isAddress(address) ">
|
||||||
<address-details-button
|
<address-details-button
|
||||||
:address_id="address.address_id"
|
:address_id="address.address_id"
|
||||||
:address_ref_status="address.refStatus"
|
:address_ref_status="address.refStatus"
|
||||||
@@ -63,68 +63,49 @@
|
|||||||
</div>
|
</div>
|
||||||
</component>
|
</component>
|
||||||
|
|
||||||
<div v-if="useDatePane === true" class="address-more">
|
<div v-if="useDatePane" class="address-more">
|
||||||
<div v-if="address.validFrom">
|
<div v-if="address.validFrom">
|
||||||
<span class="validFrom">
|
<span class="validFrom">
|
||||||
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
|
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
|
||||||
>: {{ $d(address.validFrom.date) }}
|
>: {{ address.validFrom?.datetime8601 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="address.validTo">
|
<div v-if="isAddress(address) && address.validTo !== null">
|
||||||
<span class="validTo">
|
<span class="validTo">
|
||||||
<b>{{ trans(ADDRESS_VALID_TO) }}</b
|
<b>{{ trans(ADDRESS_VALID_TO) }}</b
|
||||||
>: {{ $d(address.validTo.date) }}
|
>: {{ address.validTo?.datetime8601 }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
|
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
|
||||||
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
|
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
|
||||||
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
|
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
|
||||||
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
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 {
|
const props = withDefaults(
|
||||||
name: "AddressRenderBox",
|
defineProps<{
|
||||||
methods: { localizeString },
|
address: Address|AddressCreation;
|
||||||
components: {
|
isMultiline?: boolean;
|
||||||
Confidential,
|
useDatePane?: boolean;
|
||||||
AddressDetailsButton,
|
showButtonDetails?: boolean;
|
||||||
},
|
}>(),
|
||||||
props: {
|
{
|
||||||
address: {
|
isMultiline: true,
|
||||||
type: Object,
|
useDatePane: false,
|
||||||
},
|
showButtonDetails: true,
|
||||||
isMultiline: {
|
}
|
||||||
default: true,
|
);
|
||||||
type: Boolean,
|
|
||||||
},
|
const component = computed(() => (props.isMultiline ? "div" : "span"));
|
||||||
useDatePane: {
|
const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
|
||||||
default: false,
|
const isConfidential = computed(() => Boolean(props.address?.confidential));
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 %}
|
@@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
|
|||||||
'name' => $address->getPostCode()->getName(),
|
'name' => $address->getPostCode()->getName(),
|
||||||
'code' => $address->getPostCode()->getCode(),
|
'code' => $address->getPostCode()->getCode(),
|
||||||
'center' => $address->getPostcode()->getCenter(),
|
'center' => $address->getPostcode()->getCenter(),
|
||||||
|
'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]),
|
||||||
],
|
],
|
||||||
'country' => [
|
'country' => [
|
||||||
'id' => $address->getPostCode()->getCountry()->getId(),
|
'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:
|
401:
|
||||||
description: "Unauthorized"
|
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:
|
/1.0/main/postal-code/search.json:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
@@ -120,5 +120,9 @@ module.exports = function (encore, entries) {
|
|||||||
"vue_onthefly",
|
"vue_onthefly",
|
||||||
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
||||||
);
|
);
|
||||||
|
encore.addEntry(
|
||||||
|
'address_picker',
|
||||||
|
__dirname + "/Resources/public/vuejs/AddressPicker/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
|
buildingName: résidence
|
||||||
extra: ""
|
extra: ""
|
||||||
distribution: cedex
|
distribution: cedex
|
||||||
|
|
||||||
|
address_picker:
|
||||||
|
# placeholder
|
||||||
|
Search for addresses: Chercher des adresses
|
||||||
|
|
||||||
Create a new address: Créer une nouvelle adresse
|
Create a new address: Créer une nouvelle adresse
|
||||||
Create an address: Créer une adresse
|
Create an address: Créer une adresse
|
||||||
Update address: Modifier l'adresse
|
Update address: Modifier l'adresse
|
||||||
|
Reference in New Issue
Block a user