Merge remote-tracking branch 'origin/master' into issue279_accompanying_period_validation

This commit is contained in:
Julien Fastré 2021-12-06 15:08:56 +01:00
commit 96e5e4a7b2
34 changed files with 1066 additions and 122 deletions

View File

@ -12,8 +12,14 @@ and this project adheres to
<!-- write down unreleased development here -->
* [person] add validator for accompanying period with a test on social issues (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/76)
* [main] address: use search API end points for getting postal code and reference address (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/316)
* [main] address: in edit mode, select the encoded values in multiselect for address reference and city (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/316)
* [person search] fix bug when using birthdate after and birthdate before
* [person search] increase pertinence when lastname begins with search pattern
* [household] field to edit wheter person is titulaire of household or not removed (https://gitlab.com/champs-libres/departement-de-la-vendee/chill/-/issues/322)
* [activity] create work if a work with same social action is not associated to the activity
* [visgraph] improve and fix bugs on vis-network relationship graph
* [bugfix] posting of birth- and deathdate through api fixed.
## Test releases
@ -24,6 +30,7 @@ and this project adheres to
* [activity] layout for issues / actions
* [activity][bugfix] in edit mode, the form will now load the social action list
### Test release 2021-11-29
* [person] suggest entities (person | thirdparty) when creating/editing the accompanying course (https://gitlab.com/champs-libres/departement-de-la-vendee/accent-suivi-developpement/-/issues/119)
@ -51,6 +58,9 @@ and this project adheres to
* [activity] for a new activity: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties
* [calendar] for a new rdv: suggest and create on-the-fly locations based on the accompanying course location + location of the suggested parties
## Test releases
### Test release 2021-11-22
* [activity] delete admin_user_show in twig template because this route is not defined and should be defined

View File

@ -41,6 +41,7 @@ class ChillActivityExtension extends Extension implements PrependExtensionInterf
$loader->load('services/form.yaml');
$loader->load('services/templating.yaml');
$loader->load('services/accompanyingPeriodConsistency.yaml');
$loader->load('services/doctrine.entitylistener.yaml');
}
public function prepend(ContainerBuilder $container)

View File

@ -47,7 +47,8 @@ use Symfony\Component\Serializer\Annotation\SerializedName;
* })
* @ActivityValidator\ActivityValidity
*
* @UserCircleConsistency(
* TODO see if necessary
* UserCircleConsistency(
* "CHILL_ACTIVITY_SEE_DETAILS",
* getUserFunction="getUser",
* path="scope")

View File

@ -0,0 +1,77 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\ActivityBundle\EntityListener;
use Chill\ActivityBundle\Entity\Activity;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
use Chill\PersonBundle\Repository\AccompanyingPeriod\AccompanyingPeriodWorkRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use function in_array;
class ActivityEntityListener
{
private EntityManagerInterface $em;
private AccompanyingPeriodWorkRepository $workRepository;
public function __construct(EntityManagerInterface $em, AccompanyingPeriodWorkRepository $workRepository)
{
$this->em = $em;
$this->workRepository = $workRepository;
}
public function persistActionToCourse(Activity $activity)
{
if ($activity->getAccompanyingPeriod() instanceof AccompanyingPeriod) {
$period = $activity->getAccompanyingPeriod();
$accompanyingCourseWorks = $this->workRepository->findByAccompanyingPeriod($period);
$periodActions = [];
$now = new DateTimeImmutable();
foreach ($accompanyingCourseWorks as $key => $work) {
// take only the actions which are still opened
if ($work->getEndDate() === null || $work->getEndDate() > ($activity->getDate() ?? $now)) {
$periodActions[$key] = spl_object_hash($work->getSocialAction());
}
}
$associatedPersons = $activity->getPersonsAssociated();
$associatedThirdparties = $activity->getThirdParties();
foreach ($activity->getSocialActions() as $action) {
if (in_array(spl_object_hash($action), $periodActions, true)) {
continue;
}
$newAction = new AccompanyingPeriodWork();
$newAction->setSocialAction($action);
$period->addWork($newAction);
$date = DateTimeImmutable::createFromMutable($activity->getDate());
$newAction->setStartDate($date);
foreach ($associatedPersons as $person) {
$newAction->addPerson($person);
}
foreach ($associatedThirdparties as $thirdparty) {
$newAction->setHandlingThierparty($thirdparty);
}
$this->em->persist($newAction);
$this->em->flush();
}
}
}
}

View File

@ -143,7 +143,7 @@ class ActivityType extends AbstractType
return array_map(
fn (string $id): ?SocialIssue => $this->om->getRepository(SocialIssue::class)->findOneBy(['id' => (int) $id]),
explode(',', $socialIssuesAsString)
explode(',', (string) $socialIssuesAsString)
);
}
));
@ -169,7 +169,7 @@ class ActivityType extends AbstractType
return array_map(
fn (string $id): ?SocialAction => $this->om->getRepository(SocialAction::class)->findOneBy(['id' => (int) $id]),
explode(',', $socialActionsAsString)
explode(',', (string) $socialActionsAsString)
);
}
));
@ -266,7 +266,7 @@ class ActivityType extends AbstractType
return array_map(
fn (string $id): ?Person => $this->om->getRepository(Person::class)->findOneBy(['id' => (int) $id]),
explode(',', $personsAsString)
explode(',', (string) $personsAsString)
);
}
));
@ -292,7 +292,7 @@ class ActivityType extends AbstractType
return array_map(
fn (string $id): ?ThirdParty => $this->om->getRepository(ThirdParty::class)->findOneBy(['id' => (int) $id]),
explode(',', $thirdpartyAsString)
explode(',', (string) $thirdpartyAsString)
);
}
));
@ -329,7 +329,7 @@ class ActivityType extends AbstractType
return array_map(
fn (string $id): ?User => $this->om->getRepository(User::class)->findOneBy(['id' => (int) $id]),
explode(',', $usersAsString)
explode(',', (string) $usersAsString)
);
}
));
@ -344,7 +344,7 @@ class ActivityType extends AbstractType
return '';
}
return $location->getId();
return (string) $location->getId();
},
function (?string $id): ?Location {
return $this->om->getRepository(Location::class)->findOneBy(['id' => (int) $id]);

View File

@ -0,0 +1,15 @@
services:
Chill\ActivityBundle\EntityListener\ActivityEntityListener:
autowire: true
autoconfigure: true
tags:
-
name: 'doctrine.orm.entity_listener'
event: 'postPersist'
entity: 'Chill\ActivityBundle\Entity\Activity'
method: 'persistActionToCourse'
-
name: 'doctrine.orm.entity_listener'
event: 'postUpdate'
entity: 'Chill\ActivityBundle\Entity\Activity'
method: 'persistActionToCourse'

View File

@ -12,14 +12,66 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Chill\MainBundle\Repository\AddressReferenceRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use function trim;
/**
* Class AddressReferenceAPIController.
*/
class AddressReferenceAPIController extends ApiController
final class AddressReferenceAPIController extends ApiController
{
private AddressReferenceRepository $addressReferenceRepository;
private PaginatorFactory $paginatorFactory;
public function __construct(AddressReferenceRepository $addressReferenceRepository, PaginatorFactory $paginatorFactory)
{
$this->addressReferenceRepository = $addressReferenceRepository;
$this->paginatorFactory = $paginatorFactory;
}
/**
* @Route("/api/1.0/main/address-reference/by-postal-code/{id}/search.json")
*/
public function search(PostalCode $postalCode, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
if (!$request->query->has('q')) {
throw new BadRequestHttpException('You must supply a "q" parameter');
}
$pattern = $request->query->get('q');
if ('' === trim($pattern)) {
throw new BadRequestHttpException('the search pattern is empty');
}
$nb = $this->addressReferenceRepository->countByPostalCodePattern($postalCode, $pattern);
$paginator = $this->paginatorFactory->create($nb);
$addresses = $this->addressReferenceRepository->findByPostalCodePattern(
$postalCode,
$pattern,
false,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
return $this->json(
new Collection($addresses, $paginator),
Response::HTTP_OK,
[],
[AbstractNormalizer::GROUPS => ['read']]
);
}
protected function customizeQuery(string $action, Request $request, $qb): void
{
if ($request->query->has('postal_code')) {

View File

@ -12,13 +12,80 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Repository\CountryRepository;
use Chill\MainBundle\Repository\PostalCodeRepository;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
/**
* Class PostalCodeAPIController.
*/
class PostalCodeAPIController extends ApiController
final class PostalCodeAPIController extends ApiController
{
private CountryRepository $countryRepository;
private PaginatorFactory $paginatorFactory;
private PostalCodeRepository $postalCodeRepository;
public function __construct(
CountryRepository $countryRepository,
PostalCodeRepository $postalCodeRepository,
PaginatorFactory $paginatorFactory
) {
$this->countryRepository = $countryRepository;
$this->postalCodeRepository = $postalCodeRepository;
$this->paginatorFactory = $paginatorFactory;
}
/**
* @Route("/api/1.0/main/postal-code/search.json")
*/
public function search(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_USER');
if (!$request->query->has('q')) {
throw new BadRequestHttpException('You must supply a "q" parameter');
}
$pattern = $request->query->get('q');
if ('' === trim($pattern)) {
throw new BadRequestHttpException('the search pattern is empty');
}
if ($request->query->has('country')) {
$country = $this->countryRepository->find($request->query->getInt('country'));
if (null === $country) {
throw new NotFoundHttpException('country not found');
}
} else {
$country = null;
}
$nb = $this->postalCodeRepository->countByPattern($pattern, $country);
$paginator = $this->paginatorFactory->create($nb);
$codes = $this->postalCodeRepository->findByPattern(
$pattern,
$country,
$paginator->getCurrentPageFirstItemNumber(),
$paginator->getItemsPerPage()
);
return $this->json(
new Collection($codes, $paginator),
Response::HTTP_OK,
[],
[AbstractNormalizer::GROUPS => ['read']]
);
}
protected function customizeQuery(string $action, Request $request, $qb): void
{
if ($request->query->has('country')) {

View File

@ -22,6 +22,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
*/
class AddressReference
{
/**
* This is an internal column which is populated by database.
*
* This column will ease the search operations
*
* @ORM\Column(type="text", options={"default": ""})
*/
private string $addressCanonical = '';
/**
* @ORM\Id
* @ORM\GeneratedValue

View File

@ -29,6 +29,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
*/
class PostalCode
{
/**
* This is an internal column which is populated by database.
*
* This column will ease the search operations
*
* @ORM\Column(type="text", options={"default": ""})
*/
private string $canonical = '';
/**
* @var Point
*

View File

@ -109,6 +109,10 @@ class Paginator implements PaginatorInterface
return 1;
}
if (0 === $this->totalItems) {
return 1;
}
$nb = floor($this->totalItems / $this->itemPerPage);
if ($this->totalItems % $this->itemPerPage > 0) {
@ -211,6 +215,10 @@ class Paginator implements PaginatorInterface
public function hasPage($number)
{
if (0 === $this->totalItems) {
return 1 === $number;
}
return 0 < $number
&& $this->countPages() >= $number;
}

View File

@ -12,17 +12,29 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\AddressReference;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
use RuntimeException;
use function explode;
use function implode;
use function strtr;
use function trim;
final class AddressReferenceRepository implements ObjectRepository
{
private EntityManagerInterface $entityManager;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(AddressReference::class);
$this->entityManager = $entityManager;
}
public function countAll(): int
@ -33,6 +45,18 @@ final class AddressReferenceRepository implements ObjectRepository
return $qb->getQuery()->getSingleScalarResult();
}
public function countByPostalCodePattern(PostalCode $postalCode, string $pattern): int
{
$query = $this->buildQueryByPostalCodePattern($postalCode, $pattern);
$sql = $query->buildQuery(true);
$rsm = new ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$nq = $this->entityManager->createNativeQuery($sql, $rsm)->setParameters($query->buildParameters(true));
return (int) $nq->getSingleResult()['c'];
}
public function find($id, $lockMode = null, $lockVersion = null): ?AddressReference
{
return $this->repository->find($id, $lockMode, $lockVersion);
@ -57,6 +81,33 @@ final class AddressReferenceRepository implements ObjectRepository
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
/**
* @return AddressReference[]|array
*/
public function findByPostalCodePattern(PostalCode $postalCode, string $pattern, bool $simplify = false, int $start = 0, int $limit = 50): array
{
$query = $this->buildQueryByPostalCodePattern($postalCode, $pattern);
if (!$simplify) {
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'cma');
$query->addSelectClause($rsm->generateSelectClause());
} else {
throw new RuntimeException('not implemented');
}
$sql = strtr(
$query->buildQuery() . 'ORDER BY pertinence DESC, lpad(streetnumber, 10, \'0\') ASC OFFSET ? LIMIT ? ',
// little hack for adding sql method to point
['cma.point AS point' => 'ST_AsGeojson(cma.point) AS point']
);
$parameters = [...$query->buildParameters(), $start, $limit];
return $this->entityManager->createNativeQuery($sql, $rsm)
->setParameters($parameters)
->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?AddressReference
{
return $this->repository->findOneBy($criteria, $orderBy);
@ -66,4 +117,44 @@ final class AddressReferenceRepository implements ObjectRepository
{
return AddressReference::class;
}
private function buildQueryByPostalCodePattern(PostalCode $postalCode, string $pattern): SearchApiQuery
{
$pattern = trim($pattern);
if ('' === $pattern) {
throw new RuntimeException('the search pattern must not be empty');
}
$query = new SearchApiQuery();
$query
->setFromClause('chill_main_address_reference cma')
->andWhereClause('postcode_id = ?', [$postalCode->getId()]);
$pertinenceClause = ['STRICT_WORD_SIMILARITY(addresscanonical, UNACCENT(?))'];
$pertinenceArgs = [$pattern];
$orWhere = ['addresscanonical %>> UNACCENT(?)'];
$orWhereArgs = [$pattern];
foreach (explode(' ', $pattern) as $part) {
$part = trim($part);
if ('' === $part) {
continue;
}
$orWhere[] = "addresscanonical LIKE '%' || UNACCENT(LOWER(?)) || '%'";
$orWhereArgs[] = $part;
$pertinenceClause[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(addresscanonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int";
$pertinenceClause[] =
'(addresscanonical LIKE UNACCENT(LOWER(?)))::int';
array_push($pertinenceArgs, $part, $part);
}
$query
->setSelectPertinence(implode(' + ', $pertinenceClause), $pertinenceArgs)
->andWhereClause(implode(' OR ', $orWhere), $orWhereArgs);
return $query;
}
}

View File

@ -11,18 +11,39 @@ declare(strict_types=1);
namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Country;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Search\SearchApiQuery;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\Persistence\ObjectRepository;
use RuntimeException;
final class PostalCodeRepository implements ObjectRepository
{
private EntityManagerInterface $entityManager;
private EntityRepository $repository;
public function __construct(EntityManagerInterface $entityManager)
{
$this->repository = $entityManager->getRepository(PostalCode::class);
$this->entityManager = $entityManager;
}
public function countByPattern(string $pattern, ?Country $country): int
{
$query = $this->buildQueryByPattern($pattern, $country);
$sql = $query->buildQuery(true);
$rsm = new ResultSetMapping();
$rsm->addScalarResult('c', 'c');
$nq = $this->entityManager->createNativeQuery($sql, $rsm)
->setParameters($query->buildParameters(true));
return (int) $nq->getSingleResult()['c'];
}
public function find($id, $lockMode = null, $lockVersion = null): ?PostalCode
@ -49,6 +70,26 @@ final class PostalCodeRepository implements ObjectRepository
return $this->repository->findBy($criteria, $orderBy, $limit, $offset);
}
public function findByPattern(string $pattern, ?Country $country, ?int $start = 0, ?int $limit = 50): array
{
$query = $this->buildQueryByPattern($pattern, $country);
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata(PostalCode::class, 'cmpc');
$query->addSelectClause($rsm->generateSelectClause());
$sql = strtr(
$query->buildQuery() . 'ORDER BY pertinence DESC, canonical ASC OFFSET ? LIMIT ? ',
// little hack for adding sql method to point
['cmpc.center AS center' => 'ST_AsGeojson(cmpc.center) AS center']
);
$parameters = [...$query->buildParameters(), $start, $limit];
return $this->entityManager->createNativeQuery($sql, $rsm)
->setParameters($parameters)
->getResult();
}
public function findOneBy(array $criteria, ?array $orderBy = null): ?PostalCode
{
return $this->repository->findOneBy($criteria, $orderBy);
@ -58,4 +99,48 @@ final class PostalCodeRepository implements ObjectRepository
{
return PostalCode::class;
}
private function buildQueryByPattern(string $pattern, ?Country $country): SearchApiQuery
{
$pattern = trim($pattern);
if ('' === $pattern) {
throw new RuntimeException('the search pattern must not be empty');
}
$query = new SearchApiQuery();
$query
->setFromClause('chill_main_postal_code cmpc')
->andWhereClause('cmpc.origin = 0');
if (null !== $country) {
$query->andWhereClause('cmpc.country_id = ?', [$country->getId()]);
}
$pertinenceClause = ['STRICT_WORD_SIMILARITY(canonical, UNACCENT(?))'];
$pertinenceArgs = [$pattern];
$orWhere = ['canonical %>> UNACCENT(?)'];
$orWhereArgs = [$pattern];
foreach (explode(' ', $pattern) as $part) {
$part = trim($part);
if ('' === $part) {
continue;
}
$orWhere[] = "canonical LIKE '%' || UNACCENT(LOWER(?)) || '%'";
$orWhereArgs[] = $part;
$pertinenceClause[] =
"(EXISTS (SELECT 1 FROM unnest(string_to_array(canonical, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(?)))))::int";
$pertinenceClause[] =
'(canonical LIKE UNACCENT(LOWER(?)))::int';
array_push($pertinenceArgs, $part, $part);
}
$query
->setSelectPertinence(implode(' + ', $pertinenceClause), $pertinenceArgs)
->andWhereClause(implode(' OR ', $orWhere), $orWhereArgs);
return $query;
}
}

View File

@ -16,7 +16,8 @@ const fetchCountries = () => {
/**
* Endpoint chill_api_single_postal_code__index
* method GET, get Country Object
* method GET, get Cities Object
* @params {object} a country object
* @returns {Promise} a promise containing all Postal Code objects filtered with country
*/
const fetchCities = (country) => {
@ -29,6 +30,40 @@ const fetchCities = (country) => {
});
};
/**
* Endpoint chill_main_postalcodeapi_search
* method GET, get Cities Object
* @params {string} search a search string
* @params {object} country a country object
* @returns {Promise} a promise containing all Postal Code objects filtered with country and a search string
*/
const searchCities = (search, country) => {
const url = `/api/1.0/main/postal-code/search.json?q=${search}&country=${country.id}`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/**
* Endpoint chill_main_addressreferenceapi_search
* method GET, get AddressReference Object
* @params {string} search a search string
* @params {object} postalCode a postalCode object
* @returns {Promise} a promise containing all Postal Code objects filtered with country and a search string
*/
const searchReferenceAddresses = (search, postalCode) => {
const url = `/api/1.0/main/address-reference/by-postal-code/${postalCode.id}/search.json?q=${search}`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/**
* Endpoint chill_api_single_address_reference__index
* method GET, get AddressReference Object
@ -170,5 +205,7 @@ export {
postAddress,
patchAddress,
postPostalCode,
getAddress
getAddress,
searchCities,
searchReferenceAddresses
};

View File

@ -556,8 +556,8 @@ export default {
this.entity.selected.address.distribution = this.context.edit ? this.entity.address.distribution: null;
this.entity.selected.address.extra = this.context.edit ? this.entity.address.extra: null;
this.entity.selected.writeNew.address = this.context.edit;
this.entity.selected.writeNew.postcode = this.context.edit;
this.entity.selected.writeNew.address = this.context.edit && this.entity.address.addressReference === null && this.entity.address.street.length > 0
this.entity.selected.writeNew.postcode = false // NB: this used to be this.context.edit, but think it was erroneous;
console.log('!! just set writeNew.postcode to', this.entity.selected.writeNew.postcode);
},
@ -569,7 +569,6 @@ export default {
applyChanges()
{
console.log('apply changes');
let newAddress = {
'isNoAddress': this.entity.selected.isNoAddress,
'street': this.entity.selected.isNoAddress ? '' : this.entity.selected.address.street,
@ -633,7 +632,6 @@ export default {
if (!this.context.edit) {
this.addNewAddress(newAddress)
.then(payload => this.addressChangedCallback(payload));
} else {
this.updateAddress({
addressId: this.context.addressId,
@ -697,8 +695,7 @@ export default {
* Async PATCH transactions,
* then update existing address with backend datas when promise is resolved
*/
updateAddress(payload)
{
updateAddress(payload) {
this.flag.loading = true;
// TODO change the condition because it writes new postal code in edit mode now: !writeNewPostalCode

View File

@ -18,6 +18,7 @@
:taggable="true"
:multiple="false"
@tag="addAddress"
:loading="isLoading"
:options="addresses">
</VueMultiselect>
</div>
@ -48,14 +49,17 @@
<script>
import VueMultiselect from 'vue-multiselect';
import { searchReferenceAddresses, fetchReferenceAddresses } from '../../api.js';
export default {
name: 'AddressSelection',
components: { VueMultiselect },
props: ['entity', 'updateMapCenter'],
props: ['entity', 'context', 'updateMapCenter'],
data() {
return {
value: null
value: this.context.edit ? this.entity.address.addressReference : null,
isLoading: false
}
},
computed: {
@ -107,6 +111,36 @@ export default {
},
listenInputSearch(query) {
//console.log('listenInputSearch', query, this.isAddressSelectorOpen);
if (!this.entity.selected.writeNew.postcode) {
if (query.length > 2) {
this.isLoading = true;
searchReferenceAddresses(query, this.entity.selected.city).then(
addresses => new Promise((resolve, reject) => {
this.entity.loaded.addresses = addresses.results;
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error); //TODO better error handling
this.isLoading = false;
});
} else {
if (query.length === 0) { // Fetch all cities when suppressing the query
this.isLoading = true;
fetchReferenceAddresses(this.entity.selected.city).then(
addresses => new Promise((resolve, reject) => {
this.entity.loaded.addresses = addresses.results;
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error);
this.isLoading = false;
});
}
}
}
if (this.isAddressSelectorOpen) {
this.$data.value = { text: query };
} else if (this.isEnteredCustomAddress) {

View File

@ -18,6 +18,7 @@
:multiple="false"
@tag="addPostcode"
:tagPlaceholder="$t('create_postal_code')"
:loading="isLoading"
:options="cities">
</VueMultiselect>
</div>
@ -48,15 +49,17 @@
<script>
import VueMultiselect from 'vue-multiselect';
import { searchCities, fetchCities } from '../../api.js';
export default {
name: 'CitySelection',
components: { VueMultiselect },
props: ['entity', 'focusOnAddress', 'updateMapCenter'],
props: ['entity', 'context', 'focusOnAddress', 'updateMapCenter'],
emits: ['getReferenceAddresses'],
data() {
return {
value: null
value: this.context.edit ? this.entity.address.postcode : null,
isLoading: false
}
},
computed: {
@ -93,6 +96,15 @@ export default {
},
mounted() {
console.log('writeNew.postcode', this.entity.selected.writeNew.postcode, 'in mounted');
if (this.context.edit) {
this.entity.selected.city = this.value;
this.entity.selected.postcode.name = this.value.name;
this.entity.selected.postcode.code = this.value.code;
this.$emit('getReferenceAddresses', this.value);
if (this.value.center) {
this.updateMapCenter(this.value.center);
}
}
},
methods: {
transName(value) {
@ -105,7 +117,6 @@ export default {
this.entity.selected.postcode.code = value.code;
this.entity.selected.postcode.coordinates = value.center.coordinates;
this.entity.selected.writeNew.postcode = false;
console.log('writeNew.postcode false, in selectCity');
this.$emit('getReferenceAddresses', value);
this.focusOnAddress();
if (value.center) {
@ -113,7 +124,33 @@ export default {
}
},
listenInputSearch(query) {
//console.log('listenInputSearch', query, this.isCitySelectorOpen);
if (query.length > 2) {
this.isLoading = true;
searchCities(query, this.entity.selected.country).then(
cities => new Promise((resolve, reject) => {
this.entity.loaded.cities = cities.results.filter(c => c.origin !== 3); // filter out user-defined cities
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error); //TODO better error handling
this.isLoading = false;
});
} else {
if (query.length === 0) { // Fetch all cities when suppressing the query
this.isLoading = true;
fetchCities(this.entity.selected.country).then(
cities => new Promise((resolve, reject) => {
this.entity.loaded.cities = cities.results.filter(c => c.origin !== 3); // filter out user-defined cities
this.isLoading = false;
resolve();
}))
.catch((error) => {
console.log(error)
this.isLoading = false;
});
}
}
if (this.isCitySelectorOpen) {
this.$data.value = { text: query };
} else if (this.isEnteredCustomCity) {

View File

@ -30,6 +30,7 @@
<city-selection
v-bind:entity="entity"
v-bind:context="context"
v-bind:focusOnAddress="focusOnAddress"
v-bind:updateMapCenter="updateMapCenter"
@getReferenceAddresses="$emit('getReferenceAddresses', selected.city)">
@ -37,6 +38,7 @@
<address-selection v-if="!isNoAddress"
v-bind:entity="entity"
v-bind:context="context"
v-bind:updateMapCenter="updateMapCenter">
</address-selection>

View File

@ -0,0 +1,67 @@
<?php
/**
* 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.
*/
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 Controller;
use Chill\MainBundle\Entity\PostalCode;
use Chill\MainBundle\Test\PrepareClientTrait;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* @internal
* @coversNothing
*/
final class AddressReferenceApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
public function provideData()
{
self::bootKernel();
/** @var EntityManagerInterface $em */
$em = self::$container->get(EntityManagerInterface::class);
$postalCode = $em->createQueryBuilder()
->select('pc')
->from(PostalCode::class, 'pc')
->where('pc.origin = :origin')
->setParameter('origin', 0)
->setMaxResults(1)
->getQuery()
->getSingleResult();
yield [$postalCode->getId(), 'rue'];
}
/**
* @dataProvider provideData
*/
public function testSearch(int $postCodeId, string $pattern)
{
$client = $this->getClientAuthenticated();
$client->request(
'GET',
"/api/1.0/main/address-reference/by-postal-code/{$postCodeId}/search.json",
['q' => $pattern]
);
$this->assertResponseIsSuccessful();
}
}

View File

@ -0,0 +1,57 @@
<?php
/**
* 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.
*/
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 Controller;
use Chill\MainBundle\Test\PrepareClientTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use function json_decode;
/**
* @internal
* @coversNothing
*/
final class PostalCodeApiControllerTest extends WebTestCase
{
use PrepareClientTrait;
public function testSearch()
{
$client = $this->getClientAuthenticated();
$client->request(
'GET',
'/api/1.0/main/postal-code/search.json',
['q' => 'fontenay le comte']
);
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertEquals('Fontenay Le Comte', $data['results'][0]['name']);
// test response with invalid search pattern
$client->request(
'GET',
'/api/1.0/main/postal-code/search.json',
['q' => '']
);
$this->assertResponseStatusCodeSame(400);
}
}

View File

@ -204,6 +204,14 @@ final class PaginatorTest extends KernelTestCase
);
}
public function testPagesWithoutResult()
{
$paginator = $this->generatePaginator(0, 10);
$this->assertEquals(0, $paginator->getCurrentPageFirstItemNumber());
$this->assertEquals(10, $paginator->getItemsPerPage());
}
/**
* @param int $itemPerPage
* @param string $route

View File

@ -360,6 +360,40 @@ paths:
401:
description: "Unauthorized"
/1.0/main/address-reference/by-postal-code/{id}/search.json:
get:
tags:
- address
- search
summary: Return a reference address by id
parameters:
- name: id
in: path
required: true
description: The reference address id
schema:
type: integer
format: integer
minimum: 1
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: '#/components/schemas/AddressReference'
404:
description: "not found"
401:
description: "Unauthorized"
400:
description: "Bad request"
/1.0/main/postal-code.json:
get:
tags:
@ -430,6 +464,37 @@ paths:
401:
description: "Unauthorized"
/1.0/main/postal-code/search.json:
get:
tags:
- address
- search
summary: Search a postal code
parameters:
- name: q
in: query
required: true
description: The search pattern
schema:
type: string
- name: country
in: query
required: false
description: The country id
schema:
type: integer
responses:
200:
description: "ok"
content:
application/json:
schema:
$ref: '#/components/schemas/PostalCode'
404:
description: "not found"
400:
description: "Bad Request"
/1.0/main/country.json:
get:
tags:

View File

@ -0,0 +1,85 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211125142016 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER canonicalize_address_reference_on_insert ON chill_main_address_reference');
$this->addSql('DROP TRIGGER canonicalize_address_reference_on_update ON chill_main_address_reference');
$this->addSql('DROP FUNCTION canonicalize_address_reference()');
$this->addSql('ALTER TABLE chill_main_address_reference DROP COLUMN addressCanonical');
}
public function getDescription(): string
{
return 'Add a column "canonicalized" on chill_main_address_reference and add trigger and indexed on it';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_address_reference ADD addressCanonical TEXT DEFAULT \'\' NOT NULL');
$this->addSql('UPDATE chill_main_address_reference
SET addresscanonical =
TRIM(
UNACCENT(
LOWER(
street ||
\' \' ||
streetnumber
)
)
)');
$this->addSql('CREATE OR REPLACE FUNCTION public.canonicalize_address_reference() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
BEGIN
NEW.addresscanonical =
TRIM(
UNACCENT(
LOWER(
NEW.street ||
\' \' ||
NEW.streetnumber
)
)
)
;
return NEW;
END
$$');
$this->addSql('CREATE TRIGGER canonicalize_address_reference_on_insert
BEFORE INSERT
ON chill_main_address_reference
FOR EACH ROW
EXECUTE procedure canonicalize_address_reference()');
$this->addSql('CREATE TRIGGER canonicalize_address_reference_on_update
BEFORE UPDATE
ON chill_main_address_reference
FOR EACH ROW
EXECUTE procedure canonicalize_address_reference()');
$this->addSql('CREATE INDEX chill_internal_address_reference_canonicalized ON chill_main_address_reference USING GIST (postcode_id, addressCanonical gist_trgm_ops)');
}
}

View File

@ -0,0 +1,85 @@
<?php
/**
* 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.
*/
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20211125142017 extends AbstractMigration
{
public function down(Schema $schema): void
{
$this->addSql('DROP TRIGGER canonicalize_postal_code_on_insert ON chill_main_postal_code');
$this->addSql('DROP TRIGGER canonicalize_postal_code_on_update ON chill_main_postal_code');
$this->addSql('DROP FUNCTION canonicalize_postal_code()');
$this->addSql('ALTER TABLE chill_main_postal_code DROP COLUMN canonical');
}
public function getDescription(): string
{
return 'Add a column "canonicalized" on postal code';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_postal_code ADD canonical TEXT DEFAULT \'\' NOT NULL');
$this->addSql('UPDATE chill_main_postal_code
SET canonical =
TRIM(
UNACCENT(
LOWER(
code ||
\' \' ||
label
)
)
)');
$this->addSql('CREATE OR REPLACE FUNCTION public.canonicalize_postal_code() RETURNS TRIGGER
LANGUAGE plpgsql
AS
$$
BEGIN
NEW.canonical =
TRIM(
UNACCENT(
LOWER(
NEW.code ||
\' \' ||
NEW.label
)
)
)
;
return NEW;
END
$$');
$this->addSql('CREATE TRIGGER canonicalize_postal_code_on_insert
BEFORE INSERT
ON chill_main_postal_code
FOR EACH ROW
EXECUTE procedure canonicalize_postal_code()');
$this->addSql('CREATE TRIGGER canonicalize_postal_code_on_update
BEFORE UPDATE
ON chill_main_postal_code
FOR EACH ROW
EXECUTE procedure canonicalize_postal_code()');
$this->addSql('CREATE INDEX chill_internal_postal_code_canonicalized ON chill_main_postal_code USING GIST (canonical gist_trgm_ops) WHERE origin = 0');
}
}

View File

@ -14,7 +14,6 @@ namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
class HouseholdMemberType extends AbstractType
@ -26,17 +25,6 @@ class HouseholdMemberType extends AbstractType
'label' => 'household.Start date',
'input' => 'datetime_immutable',
]);
if ($options['data']->getPosition()->isAllowHolder()) {
$builder
->add('holder', ChoiceType::class, [
'label' => 'household.holder',
'choices' => [
'household.is holder' => true,
'household.is not holder' => false,
],
]);
}
$builder
->add('comment', ChillTextareaType::class, [
'label' => 'household.Comment',

View File

@ -53,6 +53,7 @@
<div class="row">
<div class="col-12 text-center">{{ $t('visgraph.between') }}<br>{{ $t('visgraph.and') }}</div>
<div class="col">
<small>{{ getPersonAge(modal.data.from) }}</small>
<h4>{{ getPerson(modal.data.from).text }}</h4>
<p class="text-start" v-if="relation && relation.title">
<span v-if="reverse">
@ -64,6 +65,7 @@
</p>
</div>
<div class="col text-end">
<small>{{ getPersonAge(modal.data.to) }}</small>
<h4>{{ getPerson(modal.data.to).text }}</h4>
<p class="text-end" v-if="relation && relation.title">
<span v-if="reverse">
@ -119,8 +121,9 @@ import vis from 'vis-network/dist/vis-network'
import { mapState, mapGetters } from "vuex"
import Modal from 'ChillMainAssets/vuejs/_components/Modal'
import VueMultiselect from 'vue-multiselect'
import { getRelationsList, postRelationship, patchRelationship, deleteRelationship } from "./api";
import { splitId } from "./vis-network";
import { getRelationsList, postRelationship, patchRelationship, deleteRelationship } from "./api"
import { splitId, getAge } from "./vis-network"
import { visMessages } from "./i18n";
export default {
name: "App",
@ -128,6 +131,7 @@ export default {
Modal,
VueMultiselect
},
props: ['household_id'],
data() {
return {
container: '',
@ -152,7 +156,9 @@ export default {
class: null,
text: null
},
}
},
canvas: null,
link: null,
}
},
computed: {
@ -164,7 +170,7 @@ export default {
]),
visgraph_data() {
console.log('::: visgraph_data :::', this.nodes.length, 'nodes,', this.edges.length, 'edges')
//console.log('::: visgraph_data :::', this.nodes.length, 'nodes,', this.edges.length, 'edges')
return {
nodes: this.nodes,
edges: this.edges
@ -172,12 +178,12 @@ export default {
},
refreshNetwork() {
console.log('--- refresh network')
//console.log('--- refresh network')
window.network.setData(this.visgraph_data)
},
legendLayers() {
console.log('--- refresh legend and rebuild checked Layers')
//console.log('--- refresh legend and rebuild checked Layers')
this.checkedLayers = []
let layersDisplayed = [
...this.nodes.filter(n => n.id.startsWith('household')),
@ -193,7 +199,7 @@ export default {
},
checkedLayers() { // required to refresh data checkedLayers
console.log('--- checkedLayers')
//console.log('--- checkedLayers')
return this.checkedLayers
},
@ -218,7 +224,7 @@ export default {
},
watch: {
updateHack(newValue, oldValue) {
console.log(`--- updateHack ${oldValue} <> ${newValue}`)
//console.log(`--- updateHack ${oldValue} <> ${newValue}`)
if (oldValue !== newValue) {
this.forceUpdateComponent()
}
@ -229,6 +235,9 @@ export default {
this.initGraph()
this.listenOnGraph()
this.getRelationsList()
this.canvas = document.getElementById('visgraph').querySelector('canvas')
this.link = document.getElementById('exportCanvasBtn')
},
methods: {
@ -255,27 +264,27 @@ export default {
case 'person':
let person = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', person.id)
//console.log('@@@@@@ event on selected Node', person.id)
if (this.listenPersonFlag === 'normal') {
if (person.folded === true) {
console.log(' @@> expand mode event')
//console.log(' @@> expand mode event')
this.$store.commit('unfoldPerson', person)
}
} else {
console.log(' @@> create link mode event')
//console.log(' @@> create link mode event')
this.listenStepsToAddRelationship(person)
}
break
case 'household':
let household = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', household.id)
//console.log('@@@@@@ event on selected Node', household.id)
this.$store.dispatch('unfoldPersonsByHousehold', household)
break
case 'accompanying_period':
let course = this.nodes.filter(n => n.id === node)[0]
console.log('@@@@@@ event on selected Node', course.id)
//console.log('@@@@@@ event on selected Node', course.id)
this.$store.dispatch('unfoldPersonsByCourse', course)
break
@ -290,7 +299,7 @@ export default {
}
let link = data.edges[0]
let linkType = splitId(link, 'link')
console.log('@@@@@ event on selected Edge', data.edges.length, linkType, data)
//console.log('@@@@@ event on selected Edge', data.edges.length, linkType, data)
if (linkType.startsWith('relationship')) {
//console.log('linkType relationship')
@ -314,7 +323,7 @@ export default {
})
},
listenStepsToAddRelationship(person) {
console.log(' @@> listenStep', this.listenPersonFlag)
//console.log(' @@> listenStep', this.listenPersonFlag)
if (this.listenPersonFlag === 'step2') {
//console.log(' @@> person 2', person)
this.newEdgeData.to = person.id
@ -333,7 +342,7 @@ export default {
/// control Layers
toggleLayer(value) {
let id = value.target.value
console.log('@@@@@@ toggle Layer', id)
//console.log('@@@@@@ toggle Layer', id)
this.forceUpdateComponent()
if (this.checkedLayers.includes(id)) {
this.removeLayer(id)
@ -382,7 +391,7 @@ export default {
title: null,
button: { class: null, text: null, }
}
console.log('==- reset Form', this.modal.data)
//console.log('==- reset Form', this.modal.data)
},
getRelationsList() {
//console.log('fetch relationsList')
@ -400,12 +409,16 @@ export default {
let person = this.persons.filter(p => p.id === id)
return person[0]
},
getPersonAge(id) {
let person = this.getPerson(id)
return getAge(person)
},
// actions
createRelationship() {
this.displayHelpMessage = true
this.listenPersonFlag = 'step1' // toggle listener in create link mode
console.log(' @@> switch listener to create link mode:', this.listenPersonFlag)
//console.log(' @@> switch listener to create link mode:', this.listenPersonFlag)
},
dropRelationship() {
//console.log('delete', this.modal.data)
@ -417,13 +430,13 @@ export default {
this.forceUpdateComponent()
},
submitRelationship() {
console.log('submitRelationship', this.modal.action)
//console.log('submitRelationship', this.modal.action)
switch (this.modal.action) {
case 'create':
return postRelationship(this.modal.data)
.then(relationship => new Promise(resolve => {
console.log('post relationship response', relationship)
//console.log('post relationship response', relationship)
this.$store.dispatch('addLinkFromRelationship', relationship)
this.modal.showModal = false
this.resetForm()
@ -435,7 +448,7 @@ export default {
case 'edit':
return patchRelationship(this.modal.data)
.then(relationship => new Promise(resolve => {
console.log('patch relationship response', relationship)
//console.log('patch relationship response', relationship)
this.$store.commit('updateLink', relationship)
this.modal.showModal = false
this.resetForm()
@ -450,39 +463,44 @@ export default {
},
// export image
exportCanvasAsImage() {
const canvas = document.getElementById('visgraph')
.querySelector('canvas')
console.log(canvas)
async exportCanvasAsImage() {
let link = document.getElementById('exportCanvasBtn')
link.download = "filiation.png"
let
filename = `filiation_${this.household_id}.jpg`,
mime = 'image/jpeg',
quality = 0.85,
footer = `© Chill ${new Date().getFullYear()}`,
timestamp = `${visMessages.fr.visgraph.relationship_household}${this.household_id}${new Date().toLocaleString()}`
canvas.toBlob(blob => {
console.log(blob)
link.href = URL.createObjectURL(blob)
}, 'image/png')
// resolve toBlob in a Promise
const getCanvasBlob = canvas => new Promise(resolve => {
canvas.toBlob(blob => resolve(blob), mime, quality)
})
/*
TODO improve feature
// 1. fonctionne, mais pas de contrôle sur le nom
if (canvas && canvas.getContext('2d')) {
let img = canvas.toDataURL('image/png;base64;')
img = img.replace('image/png','image/octet-stream')
window.open(img, '', 'width=1000, height=1000')
}
// 2. fonctionne, mais 2 click et pas compatible avec tous les browsers
let link = document.getElementById('exportCanvasBtn')
link.download = "image.png"
canvas.toBlob(blob => {
link.href = URL.createObjectURL(blob)
}, 'image/png')
*/
// build image from new temporary canvas
let tmpCanvas = document.createElement('canvas')
tmpCanvas.width = this.canvas.width
tmpCanvas.height = this.canvas.height
let ctx = tmpCanvas.getContext('2d')
ctx.beginPath()
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height);
ctx.fillStyle = '#9d4600'
ctx.fillText(footer +' — '+ timestamp, 5, tmpCanvas.height - 10)
ctx.drawImage(this.canvas, 0, 0)
return await getCanvasBlob(tmpCanvas)
.then(blob => {
let url = document.createElement("a")
url.download = filename
url.href = window.URL.createObjectURL(blob)
url.click()
console.log('url', url.href)
URL.revokeObjectURL(url.href)
})
}
}
}
</script>

View File

@ -24,6 +24,7 @@ const visMessages = {
refresh: "Rafraîchir",
screenshot: "Prendre une photo",
choose_relation: "Choisissez le lien de parenté",
relationship_household: "Filiation du ménage",
},
edit: 'Éditer',
del: 'Supprimer',

View File

@ -16,7 +16,12 @@ persons.forEach(person => {
})
const app = createApp({
template: `<app></app>`
template: `<app :household_id="this.household_id"></app>`,
data() {
return {
household_id: JSON.parse(container.dataset.householdId)
}
}
})
.use(store)
.use(i18n)

View File

@ -112,7 +112,7 @@ const store = createStore({
}
})
//console.log('array', array.map(item => item.person.id))
console.log('get persons group', group.map(f => f.id))
//console.log('get persons group', group.map(f => f.id))
return group
},
@ -120,13 +120,17 @@ const store = createStore({
},
mutations: {
addPerson(state, [person, options]) {
let age = getAge(person)
age = (age === '')? '' : ' - ' + age
let debug = ''
/// Debug mode: uncomment to display person_id on visgraph
//debug = `\nid ${person.id}`
person.group = person.type
person._id = person.id
person.id = `person_${person.id}`
person.label = `*${person.text}*\n_${getGender(person.gender)} - ${getAge(person.birthdate)}_${debug}` //
person.label = `*${person.text}*\n_${getGender(person.gender)}${age}_${debug}`
person.folded = false
// folded is used for missing persons
if (options.folded) {
@ -161,7 +165,7 @@ const store = createStore({
state.links.push(link)
},
updateLink(state, link) {
console.log('updateLink', link)
//console.log('updateLink', link)
let link_ = {
from: `person_${link.fromPerson.id}`,
to: `person_${link.toPerson.id}`,
@ -264,7 +268,7 @@ const store = createStore({
fetchInfoForPerson({ dispatch }, person) {
// TODO enfants hors ménages
// example: household 61
// console.log(person.text, 'household', person.current_household_id)
//console.log(person.text, 'household', person.current_household_id)
if (null !== person.current_household_id) {
dispatch('fetchHouseholdForPerson', person)
}
@ -305,15 +309,16 @@ const store = createStore({
*/
addLinkFromPersonsToHousehold({ commit, getters, dispatch }, household) {
let members = getters.getMembersByHousehold(household.id)
console.log('add link for', members.length, 'members')
//console.log('add link for', members.length, 'members')
members.forEach(m => {
commit('addLink', {
from: `${m.person.type}_${m.person.id}`,
to: `household_${m.person.current_household_id}`,
id: `household_${m.person.current_household_id}-person_${m.person.id}`,
to: `${household.id}`,
id: `${household.id}-person_${m.person.id}`,
arrows: 'from',
color: 'pink',
font: { color: '#D04A60' },
dashes: (getHouseholdWidth(m) === 1)? [0,4] : false, //edge style: [dash, gap, dash, gap]
label: getHouseholdLabel(m),
width: getHouseholdWidth(m),
})
@ -362,7 +367,7 @@ const store = createStore({
*/
addLinkFromPersonsToCourse({ commit, getters, dispatch }, course) {
const participations = getters.getParticipationsByCourse(course.id)
console.log('add link for', participations.length, 'participations')
//console.log('add link for', participations.length, 'participations')
participations.forEach(p => {
//console.log(p.person.id)
commit('addLink', {
@ -445,7 +450,7 @@ const store = createStore({
* @param array
*/
addMissingPerson({ commit, getters, dispatch }, [person, parent]) {
console.log('! add missing Person', person.id)
//console.log('! add missing Person', person.id)
commit('markPersonLoaded', person.id)
commit('addPerson', [person, { folded: true }])
if (getters.isExcludedNode(parent.id)) {
@ -467,7 +472,7 @@ const store = createStore({
getters.getPersonsGroup(participations)
.forEach(person => {
if (person.folded === true) {
console.log('-=. unfold and expand person', person.id)
//console.log('-=. unfold and expand person', person.id)
commit('unfoldPerson', person)
dispatch('fetchInfoForPerson', person)
}
@ -485,7 +490,7 @@ const store = createStore({
getters.getPersonsGroup(members)
.forEach(person => {
if (person.folded === true) {
console.log('-=. unfold and expand person', person.id)
//console.log('-=. unfold and expand person', person.id)
commit('unfoldPerson', person)
dispatch('fetchInfoForPerson', person)
}

View File

@ -13,13 +13,12 @@ window.options = {
locale: 'fr',
locales: visMessages,
/*
*/
configure: {
enabled: true,
filter: 'nodes,edges',
//container: undefined,
filter: 'physics',
showButton: true
},
*/
physics: {
enabled: true,
barnesHut: {
@ -37,8 +36,8 @@ window.options = {
centralGravity: 0.01,
springLength: 100,
springConstant: 0.08,
damping: 0.4,
avoidOverlap: 0
damping: 0.75,
avoidOverlap: 0.00
},
repulsion: {
centralGravity: 0.2,
@ -159,17 +158,21 @@ const getGender = (gender) => {
}
/**
* TODO Repeat getAge() in PersonRenderBox.vue
* @param birthdate
* TODO only one abstract function (-> getAge() is repeated in PersonRenderBox.vue)
* @param person
* @returns {string|null}
*/
const getAge = (birthdate) => {
if (null === birthdate) {
return null
const getAge = (person) => {
if (person.birthdate) {
let birthdate = new Date(person.birthdate.datetime)
if (person.deathdate) {
let deathdate = new Date(person.deathdate.datetime)
return (deathdate.getFullYear() - birthdate.getFullYear()) + visMessages.fr.visgraph.years
}
let now = new Date()
return (now.getFullYear() - birthdate.getFullYear()) + visMessages.fr.visgraph.years
}
const birthday = new Date(birthdate.datetime)
const now = new Date()
return (now.getFullYear() - birthday.getFullYear()) + ' '+ visMessages.fr.visgraph.years
return ''
}
/**

View File

@ -192,6 +192,7 @@ export default {
return `/fr/person/${this.person.id}/general`;
},
getAge: function() {
// TODO only one abstract function
if(this.person.birthdate && !this.person.deathdate){
const birthday = new Date(this.person.birthdate.datetime)
const now = new Date()

View File

@ -17,7 +17,8 @@
<div id="relationship-graph"
style="margin-top: -3rem"
data-persons="{{ persons|e('html_attr') }}">
data-persons="{{ persons|e('html_attr') }}"
data-household-id="{{ household.id|e('html_attr') }}">
</div>
</div>
{% endblock %}

View File

@ -18,6 +18,7 @@ use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\PersonRepository;
use DateTime;
use DateTimeImmutable;
use LogicException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
@ -114,18 +115,14 @@ class PersonJsonNormalizer implements
case 'birthdate':
$object = $this->denormalizer->denormalize($data[$item], DateTime::class, $format, $context);
if ($object instanceof DateTime) {
$person->setBirthdate($object);
}
$person->setBirthdate($object);
break;
case 'deathdate':
$object = $this->denormalizer->denormalize($data[$item], DateTime::class, $format, $context);
$object = $this->denormalizer->denormalize($data[$item], DateTimeImmutable::class, $format, $context);
if ($object instanceof DateTime) {
$person->setDeathdate($object);
}
$person->setDeathdate($object);
break;

View File

@ -353,6 +353,19 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Person"
examples:
Update a person:
value:
type: "person"
firstName: "string"
lastName: "string"
birthdate:
datetime: "2016-06-01T00:00:00+02:00"
deathdate:
datetime: "2021-06-01T00:00:00+02:00"
phonenumber: "string"
mobilenumber: "string"
gender: "male"
responses:
401:
description: "Unauthorized"
@ -375,6 +388,19 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Person"
examples:
Create a new person:
value:
type: "person"
firstName: "string"
lastName: "string"
birthdate:
datetime: "2016-06-01T00:00:00+02:00"
deathdate:
datetime: "2021-06-01T00:00:00+02:00"
phonenumber: "string"
mobilenumber: "string"
gender: "male"
responses:
200:
description: "OK"