Compare commits

..

14 Commits

Author SHA1 Message Date
e55a7f2993 Normalize address search terms by adding UNACCENT and LOWER transformation to AddressReferenceRepository. 2025-08-22 23:14:11 +02:00
8cade9ac77 [wip] Enhance AddressPicker to auto-select position when a single address with one position is found. 2025-08-22 23:09:38 +02:00
eb25f1408e [wip] Refactor AddressDetailsMap to use vue-use-leaflet and enhance AddressPicker layout with dynamic styling adjustments. 2025-08-22 23:05:44 +02:00
279901ac93 [WIP] refactorization to show details of an address 2025-08-18 13:14:10 +02:00
b1a7680b5e [WIP] Create Address button that will open AddressPicker in a modal 2025-08-16 00:29:43 +02:00
b1703a4187 [WIP] Add postal code search integration to AddressPicker
Implemented `getPostalCodes` function in `local-search` driver and connected it with `AddressPicker.vue`. Introduced UI changes to display postal codes alongside addresses and ensured search requests are abortable.
2025-08-16 00:08:03 +02:00
b16e6c4517 [WIP] Add postal code search endpoint and controller integration
Introduced a new API endpoint `/api/1.0/main/address-reference/postal-code/search` for searching postal codes matching a query string. Implemented `PostalCodeForAddressReferenceApiController` to handle requests and integrated with `PostalCodeForAddressReferenceRepository`. Enhanced repository to include `country_name` in results by decoding JSON data. Updated API specifications accordingly.
2025-08-15 23:38:12 +02:00
d4eddaf671 [WIP] Add PostalCodeForAddressReferenceRepository and associated tests
Introduced `PostalCodeForAddressReferenceRepository` and its interface to support optimized postal code search using materialized views. Updated `AddressReferenceRepository` to improve query handling. Added test coverage for the new repository functionality.
2025-08-15 23:17:10 +02:00
a126f2f06d [WIP] Refactor AddressReferenceRepository to use interface and add tests for AddressReferenceAggregatedApiController 2025-08-15 01:19:49 +02:00
845aa040cc [WIP] Integrate local aggregated address search in AddressPicker
Added a `local-search` driver to support aggregated address fetching. Integrated the `getAddressesAggregated` function with `AddressPicker.vue` for dynamic search suggestions and abortable fetch requests.
2025-08-15 01:08:56 +02:00
5a284fe6cf [WIP] Add aggregated address search API endpoint
Introduced a new API endpoint `/api/1.0/main/address-reference/aggregated/search` for aggregated address reference search with support for query filtering. Extended repository with `findAggregatedBySearchString` method and updated materialized view `view_chill_main_address_reference`. Added test coverage and API specification details.
2025-08-07 01:44:15 +02:00
a2b8e0e6ae [WIP] initialize search bar 2025-07-21 00:53:26 +02:00
189a26e0e8 [WIP] initialize an app for address-picker + a demo page 2025-07-21 00:20:27 +02:00
2e32b8946b Add materialized view and repository methods for address search
Introduced a materialized view `view_chill_main_address_reference` to optimize address search queries and added corresponding repository methods `findBySearchString` and `countBySearchString`. Also included test coverage for the repository to validate the new functionality.
2025-07-20 23:32:57 +02:00
54 changed files with 1341 additions and 587 deletions

View File

@@ -1,6 +0,0 @@
kind: Feature
body: Show filters on list pages unfolded by default
time: 2025-07-22T15:50:39.338057044+02:00
custom:
Issue: "399"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: adjust display logic for accompanying period dates, include closing date if period is closed.
time: 2025-08-06T13:46:09.241584292+02:00
custom:
Issue: "382"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: Fixed
body: add min and step attributes to integer field in DateIntervalType
time: 2025-08-06T17:35:27.413787704+02:00
custom:
Issue: "384"
SchemaChange: No schema change

View File

@@ -1,6 +0,0 @@
kind: UX
body: Limit display of participations in event list
time: 2025-07-22T13:26:37.500656935+02:00
custom:
Issue: ""
SchemaChange: No schema change

View File

@@ -17,3 +17,9 @@ when@dev:
defaults:
template: '@ChillMain/Dev/dev.assets.test2.html.twig'
sass_address_picker:
path: /_dev/address-picker
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
defaults:
template: '@ChillMain/Dev/dev.address-picker.html.twig'

View File

@@ -45,6 +45,7 @@
"webpack-cli": "^5.0.1"
},
"dependencies": {
"@fragaria/address-formatter": "^6.6.1",
"@fullcalendar/core": "^6.1.4",
"@fullcalendar/daygrid": "^6.1.4",
"@fullcalendar/interaction": "^6.1.4",
@@ -69,6 +70,7 @@
"vue-i18n": "^9.1.6",
"vue-multiselect": "3.0.0-alpha.2",
"vue-toast-notification": "^3.1.2",
"vue-use-leaflet": "^0.1.7",
"vuex": "^4.0.0"
},
"browserslist": [

View File

@@ -54,14 +54,14 @@ block js %}
{% if e.participations|length > 0 %}
<div class="item-row separator">
<strong>{{ "Participations" | trans }}&nbsp;: </strong>
{% for part in e.participations|slice(0, 5) %} {% include
{% for part in e.participations|slice(0, 20) %} {% include
'@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: part.person.id }, action:
'show', displayBadge: true, buttonText:
part.person|chill_entity_render_string, isDead:
part.person.deathdate is not null } %} {% endfor %}
{% if e.participations|length > 5 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 5}) }}
part.person.deathdate is not null } %} {% endfor %} {% if
e.participations|length > 20 %}
{{ 'events.and_other_count_participants'|trans({'count': e.participations|length - 20}) }}
{% endif %}
</div>
{% endif %}

View File

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

View File

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

View File

@@ -55,10 +55,6 @@ class DateIntervalType extends AbstractType
{
$builder
->add('n', IntegerType::class, [
'attr' => [
'min' => 0,
'step' => 1,
],
'constraints' => [
new GreaterThan([
'value' => 0,

View File

@@ -14,13 +14,14 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NativeQuery;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
final readonly class AddressReferenceRepository implements ObjectRepository
final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
{
private EntityManagerInterface $entityManager;
@@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository
return $this->repository->findAll();
}
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$connection = $this->entityManager->getConnection();
$qb = $connection->createQueryBuilder();
$qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions')
->from('view_chill_main_address_reference', 'var')
->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id')
->groupBy('cmpc.id', 'var.street')
->setFirstResult($firstResult)
->setMaxResults($maxResults);
$paramId = 0;
foreach ($terms as $term) {
$qb->andWhere('var.address like UNACCENT(LOWER(?))');
$qb->setParameter(++$paramId, "%{$term}%");
}
if (null !== $postalCode) {
$qb->andWhere('var.postcode_id = ?');
$qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$result = $qb->executeQuery();
foreach ($result->iterateAssociative() as $row) {
yield [...$row, 'positions' => json_decode($row['positions'], true, 512, JSON_THROW_ON_ERROR)];
}
}
/**
* @return iterable<AddressReference>
*/
public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return [];
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar');
$baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN
view_chill_main_address_reference var ON var.address_id = ar.id';
$nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode);
$orderBy = [];
$pertinence = [];
foreach ($terms as $k => $term) {
$pertinence[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int";
$pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int";
$nql->setParameter('order'.$k, $term);
}
$orderBy[] = implode(' + ', $pertinence).' ASC';
$orderBy[] = implode('row_number ASC', $orderBy);
$nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy));
return $nql->toIterable();
}
public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int
{
$terms = $this->buildTermsFromSearchString($search);
if ([] === $terms) {
return 0;
}
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addScalarResult('c', 'c', Types::INTEGER);
$nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode);
return $nql->getSingleScalarResult();
}
private function buildTermsFromSearchString(string $search): array
{
return array_filter(
array_map(
static fn (string $term) => trim($term),
explode(' ', $search)
),
static fn (string $term) => '' !== $term
);
}
private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery
{
$nql = $this->entityManager->createNativeQuery('', $rsm);
$sql = $select.' WHERE ';
$wheres = [];
foreach ($terms as $k => $term) {
$wheres[] = "var.address like :w{$k}";
$nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING);
}
if (null !== $postalCode) {
$wheres[] = 'var.postcode_id = :postalCode';
$nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
}
$nql->setSQL($sql.implode(' AND ', $wheres));
return $nql;
}
/**
* @param mixed|null $limit
* @param mixed|null $offset

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,7 @@ export interface Postcode {
name: string;
code: string;
center: Point;
country: Country;
}
export interface Point {
@@ -89,6 +90,28 @@ export interface Country {
export type AddressRefStatus = "match" | "to_review" | "reviewed";
/**
* An interface to create an address
*/
export interface AddressCreation {
confidential: boolean;
isNoAddress: boolean;
street: string;
streetNumber: string;
postcode: Postcode;
point: Point; // [number, number]; // [longitude, latitude]
addressReference: AddressReference;
validFrom: DateTime|null;
floor: string;
corridor: string;
steps: string;
flat: string;
buildingName: string;
distribution: string;
extra: string;
}
export interface Address {
type: "address";
address_id: number;
@@ -107,7 +130,7 @@ export interface Address {
confidential: boolean;
lines: string[];
addressReference: AddressReference | null;
validFrom: DateTime;
validFrom: DateTime | null; // TODO there is no null for validFrom
validTo: DateTime | null;
point: Point | null;
refStatus: AddressRefStatus;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

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

View File

@@ -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,
}
}

View File

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

View File

@@ -4,24 +4,27 @@
:show-button-details="false"
></address-render-box>
<address-details-ref-matching
v-if="isAddress(props.address)"
:address="props.address"
@update-address="onUpdateAddress"
></address-details-ref-matching>
<address-details-map :address="props.address"></address-details-map>
<address-details-geographical-layers
v-if="isAddress(props.address)"
:address="props.address"
></address-details-geographical-layers>
</template>
<script lang="ts" setup>
import { Address } from "../../../types";
import {Address, AddressCreation} from "../../../types";
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
interface AddressModalContentProps {
address: Address;
address: Address|AddressCreation;
}
const props = defineProps<AddressModalContentProps>();

View File

@@ -12,90 +12,91 @@
Voir sur
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
>Google Maps</a
>
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a>
> <a
:href="makeUrlOsm(props.address)" target="_blank"
>OSM</a>
</p>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import {computed, onMounted, ref} from "vue";
import "leaflet/dist/leaflet.css";
import markerIconPng from "leaflet/dist/images/marker-icon.png";
import L, { LatLngExpression, LatLngTuple } from "leaflet";
import { Address, Point } from "../../../../types";
import {Address, AddressCreation, Point} from "../../../../types";
import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet";
const lonLatForLeaflet = (point: Point): LatLngTuple => {
return [point.coordinates[1], point.coordinates[0]];
};
export interface MapProps {
address: Address;
address: Address|AddressCreation;
}
const props = defineProps<MapProps>();
const map_div = ref<HTMLDivElement | null>(null);
let map: L.Map | null = null;
let marker: L.Marker | null = null;
onMounted(() => {
if (map_div.value === null) {
// there is no map div when the address does not have any Point
return;
const markerIcon = L.icon({
iconUrl: markerIconPng,
iconAnchor: [12, 41],
});
const latLngMarker = computed((): LatLngExpression => {
if (props.address === null || props.address.point === null) {
return [0, 0, 0];
}
if (props.address.point !== null) {
map = L.map(map_div.value);
map.setView(lonLatForLeaflet(props.address.point), 18);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
const markerIcon = L.icon({
iconUrl: markerIconPng,
iconAnchor: [12, 41],
});
marker = L.marker(lonLatForLeaflet(props.address.point), {
icon: markerIcon,
});
marker.addTo(map);
}
return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
});
const makeUrlGoogleMap = (address: Address): string => {
const map_div = ref<HTMLDivElement | null>(null);
const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker});
const tileLayer = useLeafletTileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
);
useLeafletDisplayLayer(map, tileLayer);
const marker = useLeafletMarker(latLngMarker, {icon: markerIcon});
useLeafletDisplayLayer(map, marker);
const makeUrlGoogleMap = (address: Address|AddressCreation): string => {
const params = new URLSearchParams();
params.append("api", "1");
if (address.point !== null && address.addressReference !== null) {
const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
params.append(
"query",
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`,
`${point.coordinates[1]} ${point.coordinates[0]}`,
);
} else {
params.append("query", address.lines.join(", "));
params.append("query", buildAddressLines(address).join(", "));
}
return `https://www.google.com/maps/search/?${params.toString()}`;
};
const makeUrlOsm = (address: Address): string => {
if (address.point !== null && address.addressReference !== null) {
const makeUrlOsm = (address: Address|AddressCreation): string => {
const point = getAddressPoint(address);
if (point !== null && address.addressReference !== null) {
const params = new URLSearchParams();
params.append("mlat", `${address.point.coordinates[1]}`);
params.append("mlon", `${address.point.coordinates[0]}`);
params.append("mlat", `${point.coordinates[1]}`);
params.append("mlon", `${point.coordinates[0]}`);
const hashParams = new URLSearchParams();
hashParams.append(
"map",
`18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`,
`18/${point.coordinates[1]}/${point.coordinates[0]}`,
);
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
}
const params = new URLSearchParams();
params.append("query", address.lines.join(", "));
params.append("query", buildAddressLines(address).join(", "));
return `https://www.openstreetmap.org/search?${params.toString()}`;
};

View File

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

View File

@@ -4,14 +4,14 @@
<div v-if="isConfidential">
<confidential :position-btn-far="true">
<template #confidential-content>
<div v-if="isMultiline === true">
<div v-if="isMultiline">
<p
v-for="(l, i) in address.lines"
v-for="(l, i) in buildAddressLines(address)"
:key="`line-${i}`"
>
{{ l }}
</p>
<p v-if="showButtonDetails">
<p v-if="showButtonDetails && isAddress(address) ">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -19,8 +19,8 @@
</p>
</div>
<div v-else>
<p v-if="'' !== address.text" class="street">
{{ address.text }}
<p v-if="'' !== buildAddressText(address)" class="street">
{{ buildAddressText(address) }}
</p>
<p
v-if="null !== address.postcode"
@@ -29,8 +29,8 @@
{{ address.postcode.code }}
{{ address.postcode.name }}
</p>
<p v-if="null !== address.country" class="country">
{{ localizeString(address.country.name) }}
<p v-if="null !== address.postcode" class="country">
{{ localizeString(address.postcode.country.name) }}
</p>
</div>
</template>
@@ -38,11 +38,11 @@
</div>
<div v-if="!isConfidential">
<div v-if="isMultiline === true">
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
<div v-if="isMultiline">
<p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
{{ l }}
</p>
<p v-if="showButtonDetails">
<p v-if="showButtonDetails && isAddress(address) ">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -50,9 +50,9 @@
</p>
</div>
<div v-else>
<p v-if="address.text" class="street">
{{ address.text }}
<template v-if="showButtonDetails">
<p v-if="'' !== buildAddressText(address)" class="street">
{{ buildAddressText(address)}}
<template v-if="showButtonDetails && isAddress(address) ">
<address-details-button
:address_id="address.address_id"
:address_ref_status="address.refStatus"
@@ -63,68 +63,49 @@
</div>
</component>
<div v-if="useDatePane === true" class="address-more">
<div v-if="useDatePane" class="address-more">
<div v-if="address.validFrom">
<span class="validFrom">
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
>: {{ $d(address.validFrom.date) }}
>: {{ address.validFrom?.datetime8601 }}
</span>
</div>
<div v-if="address.validTo">
<div v-if="isAddress(address) && address.validTo !== null">
<span class="validTo">
<b>{{ trans(ADDRESS_VALID_TO) }}</b
>: {{ $d(address.validTo.date) }}
>: {{ address.validTo?.datetime8601 }}
</span>
</div>
</div>
</component>
</template>
<script>
<script setup lang="ts">
import { computed } from "vue";
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import {Address, AddressCreation} from "ChillMainAssets/types";
import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
export default {
name: "AddressRenderBox",
methods: { localizeString },
components: {
Confidential,
AddressDetailsButton,
},
props: {
address: {
type: Object,
},
isMultiline: {
default: true,
type: Boolean,
},
useDatePane: {
default: false,
type: Boolean,
},
showButtonDetails: {
default: true,
type: Boolean,
},
},
setup() {
return { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO };
},
computed: {
component() {
return this.isMultiline === true ? "div" : "span";
},
multiline() {
return this.isMultiline === true ? "multiline" : "";
},
isConfidential() {
return this.address.confidential;
},
},
};
const props = withDefaults(
defineProps<{
address: Address|AddressCreation;
isMultiline?: boolean;
useDatePane?: boolean;
showButtonDetails?: boolean;
}>(),
{
isMultiline: true,
useDatePane: false,
showButtonDetails: true,
}
);
const component = computed(() => (props.isMultiline ? "div" : "span"));
const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
const isConfidential = computed(() => Boolean(props.address?.confidential));
</script>
<style lang="scss" scoped>

View File

@@ -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 %}

View File

@@ -5,7 +5,7 @@
<strong><i class="fa fa-fw fa-filter"></i>Filtrer la liste</strong>
</button>
</h2>
<div id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
<div class="accordion-collapse collapse" id="filterOrderCollapse" aria-labelledby="filterOrderHeading" data-bs-parent="#filterOrderAccordion">
{% set btnSubmit = 0 %}
<div class="accordion-body chill_filter_order container-xxl p-5 py-2">
<div class="row my-2">

View File

@@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
'name' => $address->getPostCode()->getName(),
'code' => $address->getPostCode()->getCode(),
'center' => $address->getPostcode()->getCenter(),
'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]),
],
'country' => [
'id' => $address->getPostCode()->getCountry()->getId(),

View File

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

View File

@@ -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'];
}
}

View File

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

View File

@@ -595,6 +595,44 @@ paths:
401:
description: "Unauthorized"
/1.0/main/address-reference/aggregated/search:
get:
tags:
- address
summary: Search for address reference aggregated
parameters:
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
responses:
200:
description: "ok"
401:
description: "Unauthorized"
400:
description: "Bad Request"
/1.0/main/address-reference/postal-code/search:
get:
tags:
- address
summary: Search for postal code that can contains the search query
parameters:
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
responses:
200:
description: "ok"
401:
description: "Unauthorized"
400:
description: "Bad Request"
/1.0/main/postal-code/search.json:
get:
tags:

View File

@@ -120,5 +120,9 @@ 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",
)
};

View File

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

View File

@@ -181,6 +181,11 @@ address more:
buildingName: résidence
extra: ""
distribution: cedex
address_picker:
# placeholder
Search for addresses: Chercher des adresses
Create a new address: Créer une nouvelle adresse
Create an address: Créer une adresse
Update address: Modifier l'adresse

View File

@@ -32,16 +32,9 @@
<div class="wl-col list">
<div class="d-flex flex-column justify-content-center">
{% if app != null %}
{% if acp.closingDate != null %}
{{ 'accompanying_period.dates_from_%opening_date%_to_%closing_date%'|trans({
'%opening_date%': acp.openingDate|format_date('long'),
'%closing_date%': acp.closingDate|format_date('long')}
) }}
{% else %}
<div class="date">
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
</div>
{% endif %}
<div class="date">
{{ 'Since %date%'|trans({'%date%': app.startDate|format_date('medium') }) }}
</div>
{% endif %}
{% set notif_counter = chill_count_notifications('Chill\\PersonBundle\\Entity\\AccompanyingPeriod', acp.id) %}
@@ -77,20 +70,6 @@
</div>
</div>
{% if acp.step == 'CLOSED' and acp.closingMotive is not null %}
<div class="wl-row">
<div class="wl-col title">
<h3 class="closingMotive">{{ 'Closing motive'|trans }}</h3>
</div>
<div class="wl-col list">
<div>
{{ acp.closingMotive.name|localize_translatable_string }}
</div>
</div>
</div>
{% endif %}
{% if acp.user is not null %}
<div class="wl-row">
<div class="wl-col title">

View File

@@ -13,6 +13,7 @@ namespace Chill\TaskBundle\Controller;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcherInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Serializer\Model\Counter;
use Chill\MainBundle\Templating\Listing\FilterOrderHelper;
@@ -22,7 +23,6 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskEvent;
use Chill\TaskBundle\Event\UI\UIEvent;
use Chill\TaskBundle\Form\SingleTaskType;
@@ -48,6 +48,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class SingleTaskController extends AbstractController
{
public function __construct(
private readonly CenterResolverDispatcherInterface $centerResolverDispatcher,
private readonly PaginatorFactory $paginatorFactory,
private readonly SingleTaskAclAwareRepositoryInterface $singleTaskAclAwareRepository,
private readonly TranslatorInterface $translator,
@@ -168,9 +169,6 @@ final class SingleTaskController extends AbstractController
->setForm($this->setCreateForm($task, TaskVoter::UPDATE));
$this->eventDispatcher->dispatch($event, UIEvent::EDIT_FORM);
// To keep track of specific assignee change
$initialAssignee = $task->getAssignee();
$form = $event->getForm();
$form->handleRequest($request);
@@ -180,13 +178,6 @@ final class SingleTaskController extends AbstractController
$em = $this->managerRegistry->getManager();
$em->persist($task);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, $initialAssignee),
AssignTaskEvent::PERSIST
);
}
$em->flush();
$this->addFlash('success', $this->translator
@@ -534,13 +525,6 @@ final class SingleTaskController extends AbstractController
$this->eventDispatcher->dispatch(new TaskEvent($task), TaskEvent::PERSIST);
if (null !== $task->getAssignee()) {
$this->eventDispatcher->dispatch(
new AssignTaskEvent($task, null),
AssignTaskEvent::PERSIST
);
}
$em->flush();
$this->addFlash('success', $this->translator->trans('The task is created'));
@@ -640,7 +624,8 @@ final class SingleTaskController extends AbstractController
->addCheckbox('status', $statuses, $statuses, $statusTrans);
$states = $this->singleTaskStateRepository->findAllExistingStates();
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['to_validate', 'in_progress', 'closed', 'canceled', 'validated'], true)));
$checked = array_values(array_filter($states, fn (string $state) => !in_array($state, ['in_progress', 'closed', 'canceled', 'validated'], true)));
if ([] !== $states) {
$filterBuilder
->addCheckbox('states', $states, $checked);

View File

@@ -42,7 +42,6 @@ class ChillTaskExtension extends Extension implements PrependExtensionInterface
$loader->load('services/timeline.yaml');
$loader->load('services/fixtures.yaml');
$loader->load('services/form.yaml');
$loader->load('services/notification.yaml');
}
public function prepend(ContainerBuilder $container)

View File

@@ -1,41 +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\TaskBundle\Event;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Symfony\Contracts\EventDispatcher\Event;
class AssignTaskEvent extends Event
{
final public const PERSIST = 'chill_task.assign_task';
public function __construct(
private readonly SingleTask $task,
private readonly ?User $initialAssignee,
) {}
public function getTask(): SingleTask
{
return $this->task;
}
public function getInitialAssignee(): ?User
{
return $this->initialAssignee;
}
public function hasAssigneeChanged(): bool
{
return $this->initialAssignee !== $this->task->getAssignee();
}
}

View File

@@ -1,66 +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\TaskBundle\Event;
use Chill\MainBundle\Entity\Notification;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
readonly class TaskAssignEventSubscriber implements EventSubscriberInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private \Twig\Environment $engine,
) {}
public static function getSubscribedEvents(): array
{
return [
AssignTaskEvent::PERSIST => ['onTaskAssigned', 0],
];
}
/**
* Send a notification when a user is assigned to a task.
* Only triggers when the assignee actually changes.
*/
public function onTaskAssigned(AssignTaskEvent $event): void
{
if (!$event->hasAssigneeChanged()) {
return;
}
$task = $event->getTask();
$assignedUser = $task->getAssignee();
$title = $task->getTitle();
$context = [
'task' => $task,
'assignedUser' => $assignedUser,
'title' => $title,
];
$notification = new Notification();
$notification
->setRelatedEntityId($task->getId())
->setRelatedEntityClass(SingleTask::class)
->setTitle($this->engine->render('@ChillTask/Notification/task_assignment_notification_title.txt.twig', $context))
->setMessage($this->engine->render('@ChillTask/Notification/task_assignment_notification_content.txt.twig', $context))
->addAddressee($assignedUser)
->setType(AssignTaskNotificationFlagProvider::FLAG);
$this->entityManager->persist($notification);
}
}

View File

@@ -1,31 +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\TaskBundle\Notification;
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
class AssignTaskNotificationFlagProvider implements NotificationFlagProviderInterface
{
public const FLAG = 'task-assign-notif';
public function getFlag(): string
{
return self::FLAG;
}
public function getLabel(): TranslatableInterface
{
return new TranslatableMessage('notification.flags.task_assign');
}
}

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\TaskBundle\Notification;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Notification\NotificationHandlerInterface;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Repository\SingleTaskRepository;
use Symfony\Component\Translation\TranslatableMessage;
use Symfony\Contracts\Translation\TranslatableInterface;
final readonly class TaskNotificationHandler implements NotificationHandlerInterface
{
public function __construct(private SingleTaskRepository $taskRepository) {}
public function getTemplate(Notification $notification, array $options = []): string
{
return '@ChillTask/SingleTask/showInNotification.html.twig';
}
public function getTemplateData(Notification $notification, array $options = []): array
{
return [
'notification' => $notification,
'task' => $this->taskRepository->find($notification->getRelatedEntityId()),
];
}
public function supports(Notification $notification, array $options = []): bool
{
return SingleTask::class === $notification->getRelatedEntityClass();
}
public function getTitle(Notification $notification, array $options = []): TranslatableInterface
{
if (null === $task = $this->getRelatedEntity($notification)) {
return new TranslatableMessage('task.deleted');
}
return new TranslatableMessage('notification.task.title %title%', ['title' => $task->getTitle()]);
}
public function getAssociatedPersons(Notification $notification, array $options = []): array
{
if (null === $task = $this->getRelatedEntity($notification)) {
return [];
}
if (null !== $task->getCourse()) {
return $task->getCourse()->getParticipations()->getValues();
}
return [$task->getPerson()];
}
public function getRelatedEntity(Notification $notification): ?object
{
return $this->taskRepository->find($notification->getRelatedEntityId());
}
}

View File

@@ -1,15 +0,0 @@
{{ assignedUser.label }},
{{ 'notification.email.task_assigned'|trans({}, null, assignedUser.getLocale) }}
{{ 'notification.email.title_label'|trans({}, null, assignedUser.getLocale) }} "{{ task.title }}".
{% if task.endDate %}
{{ 'notification.email.deadline'|trans({'%date%': task.endDate|format_date('long')}, null, assignedUser.getLocale) }}
{% endif %}
{{ 'notification.email.view_task'|trans({}, null, assignedUser.getLocale) }}
{{ absolute_url(path('chill_task_single_task_show', {'id': task.id, '_locale': assignedUser.getLocale})) }}
{{ 'notification.email.regards'|trans({}, null, assignedUser.getLocale) }},

View File

@@ -1,3 +0,0 @@
{{ 'notification.email.title'|trans({}, null, assignedUser.getLocale) }}

View File

@@ -18,14 +18,14 @@
<div>
{% if task.person is not null %}
<span class="chill-task-list__row__person">
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id },
action: 'show',
displayBadge: true,
buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null
} %}
</span>
{% include '@ChillMain/OnTheFly/_insert_vue_onthefly.html.twig' with {
targetEntity: { name: 'person', id: task.person.id },
action: 'show',
displayBadge: true,
buttonText: task.person|chill_entity_render_string,
isDead: task.person.deathdate is not null
} %}
</span>
{% elseif task.course is not null %}
<div style="margin-bottom: 1rem;">
{% for part in task.course.currentParticipations %}

View File

@@ -110,5 +110,4 @@
</li>
{% endif %}
</ul>
</div>
</ul></div>

View File

@@ -1,14 +0,0 @@
{% macro recordAction(task) %}
<li>
<a href="{{ path('chill_person_accompanying_course_index', { 'task_id': task }) }}"
class="btn btn-show" title="{{ 'See task'|trans }}"></a>
</li>
{% endmacro %}
{% if task is not null %}
{# <div>Todo : display task? </div>#}
{% else %}
<div class="alert alert-warning border-warning border-1">
{{ 'You are getting a notification for a task which does not exist any more'|trans }}
</div>
{% endif %}

View File

@@ -1,131 +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\TaskBundle\Tests\EventSubscriber;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\TaskBundle\Entity\SingleTask;
use Chill\TaskBundle\Event\AssignTaskEvent;
use Chill\TaskBundle\Event\TaskAssignEventSubscriber;
use Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class TaskAssignEventSubscriberTest extends TestCase
{
private EntityManagerInterface $entityManager;
private Environment $twig;
private TaskAssignEventSubscriber $subscriber;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->twig = $this->createMock(Environment::class);
$this->subscriber = new TaskAssignEventSubscriber($this->entityManager, $this->twig);
}
public function testGetSubscribedEvents(): void
{
$events = TaskAssignEventSubscriber::getSubscribedEvents();
$this->assertArrayHasKey(AssignTaskEvent::PERSIST, $events);
$this->assertEquals(['onTaskAssigned', 0], $events[AssignTaskEvent::PERSIST]);
}
public function testOnTaskAssignedCreatesNotificationWhenAssigneeChanges(): void
{
// Arrange
$task = $this->createMock(SingleTask::class);
$assignee = $this->createMock(User::class);
$event = $this->createMock(AssignTaskEvent::class);
$task->method('getId')->willReturn(123);
$task->method('getTitle')->willReturn('Test Task');
$task->method('getAssignee')->willReturn($assignee);
$event->method('hasAssigneeChanged')->willReturn(true);
$event->method('getTask')->willReturn($task);
$this->twig->expects($this->exactly(2))
->method('render')
->with(
$this->logicalOr(
'@ChillTask/Notification/task_assignment_notification_title.txt.twig',
'@ChillTask/Notification/task_assignment_notification_content.txt.twig'
),
$this->isType('array')
)
->willReturnOnConsecutiveCalls('Notification Title', 'Notification Content');
$this->entityManager->expects($this->once())
->method('persist')
->with($this->isInstanceOf(Notification::class));
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testOnTaskAssignedDoesNothingWhenAssigneeDoesNotChange(): void
{
// Arrange
$event = $this->createMock(AssignTaskEvent::class);
$event->method('hasAssigneeChanged')->willReturn(false);
$this->twig->expects($this->never())->method('render');
$this->entityManager->expects($this->never())->method('persist');
// Act
$this->subscriber->onTaskAssigned($event);
}
public function testNotificationHasCorrectProperties(): void
{
// Arrange
$task = $this->createMock(SingleTask::class);
$assignee = $this->createMock(User::class);
$event = $this->createMock(AssignTaskEvent::class);
$task->method('getId')->willReturn(456);
$task->method('getTitle')->willReturn('Important Task');
$task->method('getAssignee')->willReturn($assignee);
$event->method('hasAssigneeChanged')->willReturn(true);
$event->method('getTask')->willReturn($task);
$this->twig->method('render')->willReturn('Test Content');
// Capture the persisted notification
$persistedNotification = null;
$this->entityManager->expects($this->once())
->method('persist')
->willReturnCallback(function ($notification) use (&$persistedNotification) {
$persistedNotification = $notification;
});
// Act
$this->subscriber->onTaskAssigned($event);
// Assert
$this->assertInstanceOf(Notification::class, $persistedNotification);
$this->assertEquals(456, $persistedNotification->getRelatedEntityId());
$this->assertEquals(SingleTask::class, $persistedNotification->getRelatedEntityClass());
$this->assertEquals(AssignTaskNotificationFlagProvider::FLAG, $persistedNotification->getType());
$this->assertEquals('Test Content', $persistedNotification->getTitle());
$this->assertEquals('Test Content', $persistedNotification->getMessage());
}
}

View File

@@ -1,13 +1,7 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Event\Lifecycle\TaskLifecycleEvent:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$tokenStorage: '@Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface'
tags:
- { name: kernel.event_subscriber }
Chill\TaskBundle\Event\TaskAssignEventSubscriber: ~

View File

@@ -1,7 +0,0 @@
services:
_defaults:
autowire: true
autoconfigure: true
Chill\TaskBundle\Notification\TaskNotificationHandler: ~
Chill\TaskBundle\Notification\AssignTaskNotificationFlagProvider: ~

View File

@@ -116,16 +116,3 @@ CHILL_TASK_TASK_UPDATE: Modifier une tâche
CHILL_TASK_TASK_CREATE_FOR_COURSE: Créer une tâche pour un parcours
CHILL_TASK_TASK_CREATE_FOR_PERSON: Créer une tâche pour un usager
notification:
task:
title %title%: "Tâche: title"
flags:
task_assign: Lorsqu'un autre utilisateur m'assigne à une tâche.
email:
title: "Une tâche demande votre attention"
task_assigned: "Une tâche vous a été assignée."
title_label: "Titre de la tâche:"
deadline: "Vous êtes invités à accomplir cette tâche avant le %date%"
view_task: "Vous pouvez visualiser la tâche sur cette page:"
regards: "Cordialement"