Merge remote-tracking branch 'origin/master' into fix-person-tests

This commit is contained in:
Julien Fastré 2021-05-26 22:44:45 +02:00
commit df00ac66f5
148 changed files with 7220 additions and 1192 deletions

View File

@ -11,9 +11,10 @@ before_script:
- PGPASSWORD=$POSTGRES_PASSWORD psql -U $POSTGRES_USER -h db -c "CREATE EXTENSION IF NOT EXISTS unaccent; CREATE EXTENSION IF NOT EXISTS pg_trgm;"
# Install and run Composer
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install
- php -d memory_limit=2G composer.phar install
- php tests/app/bin/console doctrine:migrations:migrate -n
- php tests/app/bin/console doctrine:fixtures:load -n
- php -d memory_limit=2G tests/app/bin/console doctrine:fixtures:load -n
- echo "before_script finished"
# Bring in any services we need http://docs.gitlab.com/ee/ci/docker/using_docker_images.html#what-is-a-service
# See http://docs.gitlab.com/ee/ci/services/README.html for examples.
@ -38,4 +39,4 @@ variables:
# Run our tests
test:
script:
- bin/phpunit --colors=never
- php -d memory_limit=3G bin/phpunit --colors=never

View File

@ -73,7 +73,8 @@
"symfony/web-profiler-bundle": "^5.0",
"symfony/var-dumper": "4.*",
"symfony/debug-bundle": "^5.1",
"symfony/phpunit-bridge": "^5.2"
"symfony/phpunit-bridge": "^5.2",
"nelmio/alice": "^3.8"
},
"scripts": {
"auto-scripts": {

View File

@ -13,6 +13,9 @@ API
Chill provides a basic framework to build REST api.
Basic configuration
*******************
Configure a route
=================
@ -34,7 +37,7 @@ You can also:
* `How to create your custom normalizer <https://symfony.com/doc/current/serializer/custom_normalizer.html>`_
Auto-loading the routes
***********************
=======================
Ensure that those lines are present in your file `app/config/routing.yml`:
@ -47,7 +50,7 @@ Ensure that those lines are present in your file `app/config/routing.yml`:
Create your model
*****************
=================
Create your model on the usual way:
@ -87,7 +90,7 @@ Create your model on the usual way:
Configure api
*************
=============
Configure the api using Yaml (see the full configuration: :ref:`api_full_configuration`):
@ -171,7 +174,7 @@ Configure the api using Yaml (see the full configuration: :ref:`api_full_configu
}
The :code:`_index` and :code:`_entity` action
=============================================
*********************************************
The :code:`_index` and :code:`_entity` action are default actions:
@ -189,7 +192,7 @@ Entity:
Path: :code:`/api/1.0/person/accompanying-period/origin/{id}.{_format}`
Role
====
****
By default, the key `base_role` is used to check ACL. Take care of creating the :code:`Voter` required to take that into account.
@ -216,7 +219,7 @@ You can also define a role for each method. In this case, this role is used for
HEAD: MY ROLE_SEE
Customize the controller
========================
************************
You can customize the controller by hooking into the default actions. Take care of extending :code:`Chill\MainBundle\CRUD\Controller\ApiController`.
@ -264,7 +267,7 @@ And set your controller in configuration:
HEAD: true
Create your own actions
=======================
***********************
You can add your own actions:
@ -361,8 +364,297 @@ Then, create the corresponding action into your controller:
}
}
Managing association
********************
ManyToOne association
=====================
In ManyToOne association, you can add associated entities using the :code:`PATCH` request. By default, the serializer deserialize entities only with their id and discriminator type, if any.
Example:
.. code-block:: bash
curl -X 'PATCH' \
'http://localhost:8001/api/1.0/person/accompanying-course/2668.json' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
# see the data sent to the server: \
-d '{
"type": "accompanying_period",
"id": 2668,
"origin": { "id": 11 }
}'
ManyToMany associations
=======================
In OneToMany association, you can easily create route for adding and removing entities, using :code:`POST` and :code:`DELETE` requests.
Prepare your entity, creating the methods :code:`addYourEntity` and :code:`removeYourEntity`:
.. code-block:: php
namespace Chill\PersonBundle\Entity;
use Chill\MainBundle\Entity\Scope;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* AccompanyingPeriod Class
*
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period")
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period"=AccompanyingPeriod::class
* })
*/
class AccompanyingPeriod
{
/**
* @var Collection
* @ORM\ManyToMany(
* targetEntity=Scope::class,
* cascade={}
* )
* @Groups({"read"})
*/
private $scopes;
public function addScope(Scope $scope): self
{
$this->scopes[] = $scope;
return $this;
}
public function removeScope(Scope $scope): void
{
$this->scopes->removeElement($scope);
}
Create your route into the configuration:
.. code-block:: yaml
chill_main:
apis:
-
class: Chill\PersonBundle\Entity\AccompanyingPeriod
name: accompanying_course
base_path: /api/1.0/person/accompanying-course
controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController
actions:
scope:
methods:
POST: true
DELETE: true
GET: false
HEAD: false
PUT: false
PATCH: false
roles:
POST: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
DELETE: CHILL_PERSON_ACCOMPANYING_PERIOD_SEE
GET: null
HEAD: null
PUT: null
PATCH: null
controller_action: null
path: null
single-collection: single
This will create a new route, which will accept two methods: DELETE and POST:
.. code-block:: raw
+--------------+---------------------------------------------------------------------------------------+
| Property | Value |
+--------------+---------------------------------------------------------------------------------------+
| Route Name | chill_api_single_accompanying_course_scope |
| Path | /api/1.0/person/accompanying-course/{id}/scope.{_format} |
| Path Regex | {^/api/1\.0/person/accompanying\-course/(?P<id>[^/]++)/scope\.(?P<_format>[^/]++)$}sD |
| Host | ANY |
| Host Regex | |
| Scheme | ANY |
| Method | POST|DELETE |
| Requirements | {id}: \d+ |
| Class | Symfony\Component\Routing\Route |
| Defaults | _controller: csapi_accompanying_course_controller:scopeApi |
| Options | compiler_class: Symfony\Component\Routing\RouteCompiler |
+--------------+---------------------------------------------------------------------------------------+
Then, create the controller action. Call the method:
.. code-block:: php
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Chill\MainBundle\Entity\Scope;
class MyController extends ApiController
{
public function scopeApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('scope', $id, $request, $_format, 'scope', Scope::class, [ 'groups' => [ 'read' ] ]);
}
}
This will allow to add a scope by his id, and delete them.
Curl requests:
.. code-block:: bash
# add a scope with id 5
curl -X 'POST' \
'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"type": "scope",
"id": 5
}'
# remove a scope with id 5
curl -X 'DELETE' \
'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"id": 5,
"type": "scope"
}'
Deserializing an association where multiple types are allowed
=============================================================
Sometimes, multiples types are allowed as association to one entity:
.. code-block:: php
namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\Mapping as ORM;
class Resource
{
/**
* @ORM\ManyToOne(targetEntity=ThirdParty::class)
* @ORM\JoinColumn(nullable=true)
*/
private $thirdParty;
/**
* @ORM\ManyToOne(targetEntity=Person::class)
* @ORM\JoinColumn(nullable=true)
*/
private $person;
/**
*
* @param $resource Person|ThirdParty
*/
public function setResource($resource): self
{
// ...
}
/**
* @return ThirdParty|Person
* @Groups({"read", "write"})
*/
public function getResource()
{
return $this->person ?? $this->thirdParty;
}
}
This is not well taken into account by the Symfony serializer natively.
You must, then, create your own CustomNormalizer. You can help yourself using this:
.. code-block:: php
namespace Chill\PersonBundle\Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Repository\AccompanyingPeriod\ResourceRepository;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait;
use Symfony\Component\Serializer\Exception;
use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer;
class AccompanyingPeriodResourceNormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
use ObjectToPopulateTrait;
public function __construct(ResourceRepository $repository)
{
$this->repository = $repository;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
// .. snipped for brevity
if ($resource === NULL) {
$resource = new Resource();
}
if (\array_key_exists('resource', $data)) {
$res = $this->denormalizer->denormalize(
$data['resource'],
// call for a "multiple type"
DiscriminatedObjectDenormalizer::TYPE,
$format,
// into the context, we add the list of allowed types:
[
DiscriminatedObjectDenormalizer::ALLOWED_TYPES =>
[
Person::class, ThirdParty::class
]
]
);
$resource->setResource($res);
}
return $resource;
}
public function supportsDenormalization($data, string $type, string $format = null)
{
return $type === Resource::class;
}
}
Serialization for collection
============================
****************************
A specific model has been defined for returning collection:
@ -381,8 +673,9 @@ A specific model has been defined for returning collection:
}
}
Where this is relevant, this model should be re-used in custom controller actions.
This can be achieved quickly by assembling results into a :code:`Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using :code:`Paginator` (see :ref:`Pagination <pagination-ref>`).
In custom actions, this can be achieved quickly by assembling results into a :code:`Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using :code:`Paginator` (see :ref:`Pagination <pagination-ref>`).
.. code-block:: php
@ -400,10 +693,11 @@ This can be achieved quickly by assembling results into a :code:`Chill\MainBundl
}
}
.. _api_full_configuration:
Full configuration example
==========================
**************************
.. code-block:: yaml

View File

@ -82,7 +82,7 @@ Chill will be available at ``http://localhost:8001.`` Currently, there isn't any
.. code-block:: bash
docker-compose exec --user $(id -u) php bin/console doctrine:fixtures:load
docker-compose exec --user $(id -u) php bin/console doctrine:fixtures:load --purge-with-truncate
There are several users available:

View File

@ -18,6 +18,7 @@
<testsuite name="MainBundle">
<directory suffix="Test.php">src/Bundle/ChillMainBundle/Tests/</directory>
</testsuite>
<!-- remove tests for person temporarily
<testsuite name="PersonBundle">
<directory suffix="Test.php">src/Bundle/ChillPersonBundle/Tests/</directory>
<!-- test for export will be runned later -->
@ -29,7 +30,8 @@
<!-- find a solution to create multiple configs -->
<exclude>src/Bundle/ChillPersonBundle/Tests/Controller/PersonControllerUpdateWithHiddenFieldsTest.php</exclude>
</testsuite>
</testsuites>
-->
</testsuites>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />

View File

@ -5,7 +5,7 @@ namespace Chill\MainBundle\CRUD\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
@ -25,12 +25,33 @@ class AbstractCRUDController extends AbstractController
*
* @param string $id
* @return object
* @throw Symfony\Component\HttpKernel\Exception\NotFoundHttpException if the object is not found
*/
protected function getEntity($action, $id, Request $request): ?object
protected function getEntity($action, $id, Request $request): object
{
return $this->getDoctrine()
$e = $this->getDoctrine()
->getRepository($this->getEntityClass())
->find($id);
if (NULL === $e) {
throw $this->createNotFoundException(sprintf("The object %s for id %s is not found", $this->getEntityClass(), $id));
}
return $e;
}
/**
* Create an entity.
*
* @param string $action
* @param Request $request
* @return object
*/
protected function createEntity(string $action, Request $request): object
{
$type = $this->getEntityClass();
return new $type;
}
/**
@ -222,4 +243,9 @@ class AbstractCRUDController extends AbstractController
{
return $this->container->get('chill_main.paginator_factory');
}
protected function getValidator(): ValidatorInterface
{
return $this->get('validator');
}
}

View File

@ -8,6 +8,10 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Chill\MainBundle\Serializer\Model\Collection;
use Chill\MainBundle\Pagination\PaginatorInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Serializer\Exception\NotEncodableValueException;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class ApiController extends AbstractCRUDController
{
@ -38,11 +42,6 @@ class ApiController extends AbstractCRUDController
return $postFetch;
}
if (NULL === $entity) {
throw $this->createNotFoundException(sprintf("The %s with id %s "
. "is not found", $this->getCrudName(), $id));
}
$response = $this->checkACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
@ -81,12 +80,186 @@ class ApiController extends AbstractCRUDController
{
switch ($request->getMethod()) {
case Request::METHOD_GET:
case REQUEST::METHOD_HEAD:
case Request::METHOD_HEAD:
return $this->entityGet('_entity', $request, $id, $_format);
case Request::METHOD_PUT:
case Request::METHOD_PATCH:
return $this->entityPut('_entity', $request, $id, $_format);
case Request::METHOD_POST:
return $this->entityPostAction('_entity', $request, $id, $_format);
default:
throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented");
}
}
public function entityPost(Request $request, $_format): Response
{
switch($request->getMethod()) {
case Request::METHOD_POST:
return $this->entityPostAction('_entity', $request, $_format);
default:
throw new \Symfony\Component\HttpFoundation\Exception\BadRequestException("This method is not implemented");
}
}
protected function entityPostAction($action, Request $request, string $_format): Response
{
$entity = $this->createEntity($action, $request);
try {
$entity = $this->deserialize($action, $request, $_format, $entity);
} catch (NotEncodableValueException $e) {
throw new BadRequestException("invalid json", 400, $e);
}
$errors = $this->validate($action, $request, $_format, $entity);
$response = $this->onAfterValidation($action, $request, $_format, $entity, $errors);
if ($response instanceof Response) {
return $response;
}
if ($errors->count() > 0) {
$response = $this->json($errors);
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
return $response;
}
$response = $this->checkACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onPostCheckACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$this->getDoctrine()->getManager()->persist($entity);
$this->getDoctrine()->getManager()->flush();
$response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);
if ($response instanceof Response) {
return $response;
}
$response = $this->onBeforeSerialize($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
return $this->json(
$entity,
Response::HTTP_OK,
[],
$this->getContextForSerializationPostAlter($action, $request, $_format, $entity)
);
}
public function entityPut($action, Request $request, $id, string $_format): Response
{
$entity = $this->getEntity($action, $id, $request, $_format);
$postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format);
if ($postFetch instanceof Response) {
return $postFetch;
}
if (NULL === $entity) {
throw $this->createNotFoundException(sprintf("The %s with id %s "
. "is not found", $this->getCrudName(), $id));
}
$response = $this->checkACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onPostCheckACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onBeforeSerialize($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
try {
$entity = $this->deserialize($action, $request, $_format, $entity);
} catch (NotEncodableValueException $e) {
throw new BadRequestException("invalid json", 400, $e);
}
$errors = $this->validate($action, $request, $_format, $entity);
$response = $this->onAfterValidation($action, $request, $_format, $entity, $errors);
if ($response instanceof Response) {
return $response;
}
if ($errors->count() > 0) {
$response = $this->json($errors);
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
return $response;
}
$this->getDoctrine()->getManager()->flush();
$response = $this->onAfterFlush($action, $request, $_format, $entity, $errors);
if ($response instanceof Response) {
return $response;
}
return $this->json(
$entity,
Response::HTTP_OK,
[],
$this->getContextForSerializationPostAlter($action, $request, $_format, $entity)
);
}
protected function onAfterValidation(string $action, Request $request, string $_format, $entity, ConstraintViolationListInterface $errors, array $more = []): ?Response
{
return null;
}
protected function onAfterFlush(string $action, Request $request, string $_format, $entity, ConstraintViolationListInterface $errors, array $more = []): ?Response
{
return null;
}
protected function getValidationGroups(string $action, Request $request, string $_format, $entity): ?array
{
return null;
}
protected function validate(string $action, Request $request, string $_format, $entity, array $more = []): ConstraintViolationListInterface
{
$validationGroups = $this->getValidationGroups($action, $request, $_format, $entity);
return $this->getValidator()->validate($entity, null, $validationGroups);
}
/**
* Deserialize the content of the request into the class associated with the curd
*/
protected function deserialize(string $action, Request $request, string $_format, $entity = null): object
{
$default = [];
if (NULL !== $entity) {
$default[AbstractNormalizer::OBJECT_TO_POPULATE] = $entity;
}
$context = \array_merge(
$default,
$this->getContextForSerialization($action, $request, $_format, $entity)
);
return $this->getSerializer()->deserialize($request->getContent(), $this->getEntityClass(), $_format, $context);
}
/**
* Base action for indexing entities
@ -172,6 +345,110 @@ class ApiController extends AbstractCRUDController
return $this->serializeCollection($action, $request, $_format, $paginator, $entities);
}
/**
* Add or remove an associated entity, using `add` and `remove` methods.
*
* This method:
*
* 1. Fetch the base entity (throw 404 if not found)
* 2. checkACL,
* 3. run onPostCheckACL, return response if any,
* 4. deserialize posted data into the entity given by $postedDataType, with the context in $postedDataContext
* 5. run 'add+$property' for POST method, or 'remove+$property' for DELETE method
* 6. validate the base entity (not the deserialized one). Groups are fetched from getValidationGroups, validation is perform by `validate`
* 7. run onAfterValidation
* 8. if errors, return a 422 response with errors
* 9. flush the data
* 10. run onAfterFlush
* 11. return a 202 response for DELETE with empty body, or HTTP 200 for post with serialized posted entity
*
* @param string action
* @param mixed id
* @param Request $request
* @param string $_format
* @param string $property the name of the property. This will be used to make a `add+$property` and `remove+$property` method
* @param string $postedDataType the type of the posted data (the content)
* @param string $postedDataContext a context to deserialize posted data (the content)
* @throw BadRequestException if unable to deserialize the posted data
* @throw BadRequestException if the method is not POST or DELETE
*
*/
protected function addRemoveSomething(string $action, $id, Request $request, string $_format, string $property, string $postedDataType, $postedDataContext = []): Response
{
$entity = $this->getEntity($action, $id, $request);
$postFetch = $this->onPostFetchEntity($action, $request, $entity, $_format);
if ($postFetch instanceof Response) {
return $postFetch;
}
$response = $this->checkACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onPostCheckACL($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
$response = $this->onBeforeSerialize($action, $request, $_format, $entity);
if ($response instanceof Response) {
return $response;
}
try {
$postedData = $this->getSerializer()->deserialize($request->getContent(), $postedDataType, $_format, $postedDataContext);
} catch (\Symfony\Component\Serializer\Exception\UnexpectedValueException $e) {
throw new BadRequestException(sprintf("Unable to deserialize posted ".
"data: %s", $e->getMessage()), 0, $e);
}
switch ($request->getMethod()) {
case Request::METHOD_DELETE:
// oups... how to use property accessor to remove element ?
$entity->{'remove'.\ucfirst($property)}($postedData);
break;
case Request::METHOD_POST:
$entity->{'add'.\ucfirst($property)}($postedData);
break;
default:
throw new BadRequestException("this method is not supported");
}
$errors = $this->validate($action, $request, $_format, $entity, [$postedData]);
$response = $this->onAfterValidation($action, $request, $_format, $entity, $errors, [$postedData]);
if ($response instanceof Response) {
return $response;
}
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors, 422);
}
$this->getDoctrine()->getManager()->flush();
$response = $this->onAfterFlush($action, $request, $_format, $entity, $errors, [$postedData]);
if ($response instanceof Response) {
return $response;
}
switch ($request->getMethod()) {
case Request::METHOD_DELETE:
return $this->json('', Response::HTTP_OK);
case Request::METHOD_POST:
return $this->json(
$postedData,
Response::HTTP_OK,
[],
$this->getContextForSerializationPostAlter($action, $request, $_format, $entity, [$postedData])
);
}
}
/**
* Serialize collections
@ -189,7 +466,27 @@ class ApiController extends AbstractCRUDController
protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{
return [];
switch ($request->getMethod()) {
case Request::METHOD_GET:
return [ 'groups' => [ 'read' ]];
case Request::METHOD_PUT:
case Request::METHOD_PATCH:
case Request::METHOD_POST:
return [ 'groups' => [ 'write' ]];
default:
throw new \LogicException("get context for serialization is not implemented for this method");
}
}
/**
* Get the context for serialization post alter query (in case of
* PATCH, PUT, or POST method)
*
* This is called **after** the entity was altered.
*/
protected function getContextForSerializationPostAlter(string $action, Request $request, string $_format, $entity, array $more = []): array
{
return [ 'groups' => [ 'read' ]];
}
/**

View File

@ -183,48 +183,26 @@ class CRUDRoutesLoader extends Loader
$methods = \array_keys(\array_filter($action['methods'], function($value, $key) { return $value; },
ARRAY_FILTER_USE_BOTH));
$route = new Route($path, $defaults, $requirements);
$route->setMethods($methods);
$collection->add('chill_api_single_'.$crudConfig['name'].'_'.$name, $route);
}
if (count($methods) === 0) {
throw new \RuntimeException("The api configuration named \"{$crudConfig['name']}\", action \"{$name}\", ".
"does not have any allowed methods. You should remove this action from the config ".
"or allow, at least, one method");
}
return $collection;
}
if ('_entity' === $name && \in_array(Request::METHOD_POST, $methods)) {
unset($methods[\array_search(Request::METHOD_POST, $methods)]);
$entityPostRoute = $this->createEntityPostRoute($name, $crudConfig, $action,
$controller);
$collection->add("chill_api_single_{$crudConfig['name']}_{$name}_create",
$entityPostRoute);
}
/**
* Load routes for api multi
*
* @param $crudConfig
* @return RouteCollection
*/
protected function loadApiMultiConfig(array $crudConfig): RouteCollection
{
$collection = new RouteCollection();
$controller ='csapi_'.$crudConfig['name'].'_controller';
foreach ($crudConfig['actions'] as $name => $action) {
// filter only on single actions
$singleCollection = $action['single-collection'] ?? $name === '_index' ? 'collection' : NULL;
if ('single' === $singleCollection) {
if (count($methods) === 0) {
// the only method was POST,
// continue to next
continue;
}
$defaults = [
'_controller' => $controller.':'.($action['controller_action'] ?? '_entity' === $name ? 'entityApi' : $name.'Api')
];
// path are rewritten
// if name === 'default', we rewrite it to nothing :-)
$localName = '_entity' === $name ? '' : '/'.$name;
$localPath = $action['path'] ?? '/{id}'.$localName.'.{_format}';
$path = $crudConfig['base_path'].$localPath;
$requirements = $action['requirements'] ?? [ '{id}' => '\d+' ];
$methods = \array_keys(\array_filter($action['methods'], function($value, $key) { return $value; },
ARRAY_FILTER_USE_BOTH));
$route = new Route($path, $defaults, $requirements);
$route->setMethods($methods);
@ -233,4 +211,18 @@ class CRUDRoutesLoader extends Loader
return $collection;
}
private function createEntityPostRoute(string $name, $crudConfig, array $action, $controller): Route
{
$localPath = $action['path'].'.{_format}';
$defaults = [
'_controller' => $controller.':'.($action['controller_action'] ?? 'entityPost')
];
$path = $crudConfig['base_path'].$localPath;
$requirements = $action['requirements'] ?? [];
$route = new Route($path, $defaults, $requirements);
$route->setMethods([ Request::METHOD_POST ]);
return $route;
}
}

View File

@ -22,6 +22,7 @@
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\Serializer\Model\Collection;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Chill\MainBundle\Search\UnknowSearchDomainException;
@ -34,6 +35,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Chill\MainBundle\Search\SearchProvider;
use Symfony\Contracts\Translation\TranslatorInterface;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Search\SearchApi;
/**
* Class SearchController
@ -42,32 +44,24 @@ use Chill\MainBundle\Pagination\PaginatorFactory;
*/
class SearchController extends AbstractController
{
/**
*
* @var SearchProvider
*/
protected $searchProvider;
protected SearchProvider $searchProvider;
/**
*
* @var TranslatorInterface
*/
protected $translator;
protected TranslatorInterface $translator;
/**
*
* @var PaginatorFactory
*/
protected $paginatorFactory;
protected PaginatorFactory $paginatorFactory;
protected SearchApi $searchApi;
function __construct(
SearchProvider $searchProvider,
TranslatorInterface $translator,
PaginatorFactory $paginatorFactory
PaginatorFactory $paginatorFactory,
SearchApi $searchApi
) {
$this->searchProvider = $searchProvider;
$this->translator = $translator;
$this->paginatorFactory = $paginatorFactory;
$this->searchApi = $searchApi;
}
@ -152,6 +146,19 @@ class SearchController extends AbstractController
array('results' => $results, 'pattern' => $pattern)
);
}
public function searchApi(Request $request, $_format): JsonResponse
{
//TODO this is an incomplete implementation
$query = $request->query->get('q', '');
$results = $this->searchApi->getResults($query, 0, 150);
$paginator = $this->paginatorFactory->create(count($results));
$collection = new Collection($results, $paginator);
return $this->json($collection);
}
public function advancedSearchListAction(Request $request)
{

View File

@ -55,11 +55,11 @@ class LoadCenters extends AbstractFixture implements OrderedFixtureInterface
public function load(ObjectManager $manager)
{
foreach (static::$centers as $new) {
$centerA = new Center();
$centerA->setName($new['name']);
$center = new Center();
$center->setName($new['name']);
$manager->persist($centerA);
$this->addReference($new['ref'], $centerA);
$manager->persist($center);
$this->addReference($new['ref'], $center);
static::$refs[] = $new['ref'];
}

View File

@ -35,6 +35,9 @@ use Chill\MainBundle\Doctrine\DQL\OverlapsI;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Chill\MainBundle\Doctrine\DQL\Replace;
use Chill\MainBundle\Doctrine\Type\NativeDateIntervalType;
use Chill\MainBundle\Doctrine\Type\PointType;
use Symfony\Component\HttpFoundation\Request;
/**
* Class ChillMainExtension
@ -133,7 +136,7 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface,
$loader->load('services/search.yaml');
$loader->load('services/serializer.yaml');
$this->configureCruds($container, $config['cruds'], $config['apis'], $loader);
$this->configureCruds($container, $config['cruds'], $config['apis'], $loader);
}
/**
@ -166,37 +169,49 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface,
$container->prependExtensionConfig('twig', $twigConfig);
//add DQL function to ORM (default entity_manager)
$container->prependExtensionConfig('doctrine', array(
'orm' => array(
'dql' => array(
'string_functions' => array(
'unaccent' => Unaccent::class,
'GET_JSON_FIELD_BY_KEY' => GetJsonFieldByKey::class,
'AGGREGATE' => JsonAggregate::class,
'REPLACE' => Replace::class,
),
'numeric_functions' => [
'JSONB_EXISTS_IN_ARRAY' => JsonbExistsInArray::class,
'SIMILARITY' => Similarity::class,
'OVERLAPSI' => OverlapsI::class
]
)
)
));
$container
->prependExtensionConfig(
'doctrine',
[
'orm' => [
'dql' => [
'string_functions' => [
'unaccent' => Unaccent::class,
'GET_JSON_FIELD_BY_KEY' => GetJsonFieldByKey::class,
'AGGREGATE' => JsonAggregate::class,
'REPLACE' => Replace::class,
],
'numeric_functions' => [
'JSONB_EXISTS_IN_ARRAY' => JsonbExistsInArray::class,
'SIMILARITY' => Similarity::class,
'OVERLAPSI' => OverlapsI::class,
],
],
],
],
);
//add dbal types (default entity_manager)
$container->prependExtensionConfig('doctrine', array(
'dbal' => [
'types' => [
'dateinterval' => [
'class' => \Chill\MainBundle\Doctrine\Type\NativeDateIntervalType::class
],
'point' => [
'class' => \Chill\MainBundle\Doctrine\Type\PointType::class
]
]
]
));
$container
->prependExtensionConfig(
'doctrine',
[
'dbal' => [
// This is mandatory since we are using postgis as database.
'mapping_types' => [
'geometry' => 'string',
],
'types' => [
'dateinterval' => [
'class' => NativeDateIntervalType::class
],
'point' => [
'class' => PointType::class
]
]
]
]
);
//add current route to chill main
$container->prependExtensionConfig('chill_main', array(
@ -212,6 +227,9 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface,
$container->prependExtensionConfig('monolog', array(
'channels' => array('chill')
));
//add crud api
$this->prependCruds($container);
}
/**
@ -235,4 +253,97 @@ class ChillMainExtension extends Extension implements PrependExtensionInterface,
// Note: the controller are loaded inside compiler pass
}
/**
* @param ContainerBuilder $container
*/
protected function prependCruds(ContainerBuilder $container)
{
$container->prependExtensionConfig('chill_main', [
'apis' => [
[
'class' => \Chill\MainBundle\Entity\Address::class,
'name' => 'address',
'base_path' => '/api/1.0/main/address',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_POST => true,
Request::METHOD_HEAD => true
]
],
]
],
[
'class' => \Chill\MainBundle\Entity\AddressReference::class,
'name' => 'address_reference',
'base_path' => '/api/1.0/main/address-reference',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
],
[
'class' => \Chill\MainBundle\Entity\PostalCode::class,
'name' => 'postal_code',
'base_path' => '/api/1.0/main/postal-code',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
],
[
'class' => \Chill\MainBundle\Entity\Country::class,
'name' => 'country',
'base_path' => '/api/1.0/main/country',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
]
]
]);
}
}

View File

@ -221,6 +221,7 @@ class Configuration implements ConfigurationInterface
->booleanNode(Request::METHOD_POST)->defaultFalse()->end()
->booleanNode(Request::METHOD_DELETE)->defaultFalse()->end()
->booleanNode(Request::METHOD_PUT)->defaultFalse()->end()
->booleanNode(Request::METHOD_PATCH)->defaultFalse()->end()
->end()
->end()
->arrayNode('roles')
@ -232,6 +233,7 @@ class Configuration implements ConfigurationInterface
->scalarNode(Request::METHOD_POST)->defaultNull()->end()
->scalarNode(Request::METHOD_DELETE)->defaultNull()->end()
->scalarNode(Request::METHOD_PUT)->defaultNull()->end()
->scalarNode(Request::METHOD_PATCH)->defaultNull()->end()
->end()
->end()
->end()

View File

@ -0,0 +1,65 @@
<?php
namespace Chill\MainBundle\Doctrine\Event;
use Chill\MainBundle\Entity\User;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Symfony\Component\Security\Core\Security;
class TrackCreateUpdateSubscriber implements EventSubscriber
{
private Security $security;
/**
* @param Security $security
*/
public function __construct(Security $security)
{
$this->security = $security;
}
/**
* {@inheritDoc}
*/
public function getSubscribedEvents()
{
return [
Events::prePersist,
Events::preUpdate
];
}
public function prePersist(LifecycleEventArgs $args): void
{
$object = $args->getObject();
if ($object instanceof TrackCreationInterface
&& $this->security->getUser() instanceof User) {
$object->setCreatedBy($this->security->getUser());
$object->setCreatedAt(new \DateTimeImmutable('now'));
}
$this->onUpdate($object);
}
public function preUpdate(LifecycleEventArgs $args): void
{
$object = $args->getObject();
$this->onUpdate($object);
}
protected function onUpdate(object $object): void
{
if ($object instanceof TrackUpdateInterface
&& $this->security->getUser() instanceof User) {
$object->setUpdatedBy($this->security->getUser());
$object->setUpdatedAt(new \DateTimeImmutable('now'));
}
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Chill\MainBundle\Doctrine\Model;
use Chill\MainBundle\Entity\User;
interface TrackCreationInterface
{
public function setCreatedBy(User $user): self;
public function setCreatedAt(\DateTimeInterface $datetime): self;
}

View File

@ -0,0 +1,12 @@
<?php
namespace Chill\MainBundle\Doctrine\Model;
use Chill\MainBundle\Entity\User;
interface TrackUpdateInterface
{
public function setUpdatedBy(User $user): self;
public function setUpdatedAt(\DateTimeInterface $datetime): self;
}

View File

@ -137,7 +137,7 @@ class Address
* @var ThirdParty|null
*
* @ORM\ManyToOne(targetEntity="Chill\ThirdPartyBundle\Entity\ThirdParty")
* @ORM\JoinColumn(nullable=true)
* @ORM\JoinColumn(nullable=true, onDelete="SET NULL")
*/
private $linkedToThirdParty;

View File

@ -2,12 +2,11 @@
namespace Chill\MainBundle\Entity;
use Chill\MainBundle\Entity\AddressReferenceRepository;
use Doctrine\ORM\Mapping as ORM;
use Chill\MainBundle\Doctrine\Model\Point;
/**
* @ORM\Entity(repositoryClass=AddressReferenceRepository::class)
* @ORM\Entity()
* @ORM\Table(name="chill_main_address_reference")
* @ORM\HasLifecycleCallbacks()
*/

View File

@ -24,13 +24,16 @@ use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;
use Chill\MainBundle\Entity\RoleScope;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* @ORM\Entity()
* @ORM\Table(name="scopes")
* @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="acl_cache_region")
*
* @author Julien Fastré <julien.fastre@champs-libres.coop>
* @DiscriminatorMap(typeProperty="type", mapping={
* "scope"=Scope::class
* })
*/
class Scope
{
@ -40,6 +43,7 @@ class Scope
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
* @Groups({"read"})
*/
private $id;
@ -49,6 +53,7 @@ class Scope
* @var array
*
* @ORM\Column(type="json_array")
* @Groups({"read"})
*/
private $name = [];

View File

@ -7,6 +7,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Security\Core\User\AdvancedUserInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* User
@ -14,6 +15,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* @ORM\Entity(repositoryClass="Chill\MainBundle\Repository\UserRepository")
* @ORM\Table(name="users")
* @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="acl_cache_region")
* @DiscriminatorMap(typeProperty="type", mapping={
* "user"=User::class
* })
*/
class User implements AdvancedUserInterface {

View File

@ -13,9 +13,9 @@
div#header-accompanying_course-name {
background: none repeat scroll 0 0 #718596;
color: #FFF;
padding-top: 1em;
padding-bottom: 1em;
h1 {
margin: 0.4em 0;
}
span {
a {
color: white;
@ -39,115 +39,25 @@ div.subheader {
height: 130px;
}
//// VUEJS ////
div.vue-component {
padding: 1.5em;
margin: 2em 0;
border: 2px dashed grey;
position: relative;
&:before {
content: "vuejs component";
position: absolute;
left: 1.5em;
top: -0.9em;
background-color: white;
color: grey;
padding: 0 0.3em;
}
dd { margin-left: 1em; }
}
//// MODAL ////
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75);
display: table;
transition: opacity 0.3s ease;
}
.modal-header .close { // bootstrap classes, override sc-button 0 radius
border-top-right-radius: 0.3rem;
}
/*
* The following styles are auto-applied to elements with
* transition="modal" when their visibility is toggled
* by Vue.js.
*
* You can easily play with the modal transition by editing
* these styles.
*/
.modal-enter {
opacity: 0;
}
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
-webkit-transform: scale(1.1);
transform: scale(1.1);
}
//// AddPersons modal
div.modal-body.up {
margin: auto 4em;
div.search {
position: relative;
input {
padding: 1.2em 1.5em 1.2em 2.5em;
margin: 1em 0;
}
i {
position: absolute;
top: 50%;
left: 0.5em;
padding: 0.65em 0;
opacity: 0.5;
}
}
}
div.results {
div.count {
margin: -0.5em 0 0.7em;
display: flex;
justify-content: space-between;
}
div.list-item {
line-height: 26pt;
padding: 0.3em 0.8em;
display: flex;
flex-direction: row;
&.checked {
background-color: #ececec;
border-bottom: 1px dotted #8b8b8b;
}
div.container {
& > input {
margin-right: 0.8em;
}
}
div.right_actions {
margin: 0 0 0 auto;
& > * {
margin-left: 0.5em;
}
a.sc-button {
border: 1px solid lightgrey;
font-size: 70%;
padding: 4px;
}
//// SCRATCH BUTTONS
.sc-button {
&.disabled {
cursor: default;
&.bt-remove {
background-color: #d9d9d9;
}
}
}
//// à ranger
.discret {
color: grey;
margin-right: 1em;
}
table {
ul.record_actions {
margin: 0;
padding: 0.5em;
}
}

View File

@ -0,0 +1,41 @@
<template>
<div v-if="address.address">
{{ address.address.street }}, {{ address.address.streetNumber }}
</div>
<div v-if="address.city">
{{ address.city.code }} {{ address.city.name }}
</div>
<div v-if="address.country">
{{ address.country.name }}
</div>
<add-address
@addNewAddress="addNewAddress">
</add-address>
</template>
<script>
import { mapState } from 'vuex';
import AddAddress from '../_components/AddAddress.vue';
export default {
name: 'App',
components: {
AddAddress
},
computed: {
address() {
return this.$store.state.address;
}
},
methods: {
addNewAddress({ address, modal }) {
console.log('@@@ CLICK button addNewAdress', address);
this.$store.dispatch('addAddress', address.selected);
modal.showModal = false;
}
}
};
</script>

View File

@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { _createI18n } from 'ChillMainAssets/vuejs/_js/i18n'
import { addressMessages } from './js/i18n'
import { store } from './store'
import App from './App.vue';
const i18n = _createI18n(addressMessages);
const app = createApp({
template: `<app></app>`,
})
.use(store)
.use(i18n)
.component('app', App)
.mount('#address');

View File

@ -0,0 +1,22 @@
const addressMessages = {
fr: {
add_an_address: 'Ajouter une adresse',
select_an_address: 'Sélectionner une adresse',
fill_an_address: 'Compléter l\'adresse',
select_country: 'Choisir le pays',
select_city: 'Choisir une localité',
select_address: 'Choisir une adresse',
isNoAddress: 'L\'adresse n\'est pas celle d\'un domicile fixe ?',
floor: 'Étage',
corridor: 'Couloir',
steps: 'Escalier',
flat: 'Appartement',
buildingName: 'Nom du batiment',
extra: 'Complément d\'adresse',
distribution: 'Service particulier de distribution'
}
};
export {
addressMessages
};

View File

@ -0,0 +1,43 @@
import 'es6-promise/auto';
import { createStore } from 'vuex';
// le fetch POST serait rangé dans la logique du composant qui appelle AddAddress
//import { postAddress } from '... api'
const debug = process.env.NODE_ENV !== 'production';
const store = createStore({
strict: debug,
state: {
address: {},
errorMsg: {}
},
getters: {
},
mutations: {
addAddress(state, address) {
console.log('@M addAddress address', address);
state.address = address;
}
},
actions: {
addAddress({ commit }, payload) {
console.log('@A addAddress payload', payload);
commit('addAddress', payload); // à remplacer par
// fetch POST qui envoie l'adresse, et récupère la confirmation que c'est ok.
// La confirmation est l'adresse elle-même.
//
// postAddress(payload)
// .fetch(address => new Promise((resolve, reject) => {
// commit('addAddress', address);
// resolve();
// }))
// .catch((error) => {
// state.errorMsg.push(error.message);
// });
}
}
});
export { store };

View File

@ -0,0 +1,46 @@
/*
* Endpoint countries GET
* TODO
*/
const fetchCountries = () => {
console.log('<<< fetching countries');
return [
{id: 1, name: 'France', countryCode: 'FR'},
{id: 2, name: 'Belgium', countryCode: 'BE'}
];
};
/*
* Endpoint cities GET
* TODO
*/
const fetchCities = (country) => {
console.log('<<< fetching cities for', country);
return [
{id: 1, name: 'Bruxelles', code: '1000', country: 'BE'},
{id: 2, name: 'Aisne', code: '85045', country: 'FR'},
{id: 3, name: 'Saint-Gervais', code: '85230', country: 'FR'}
];
};
/*
* Endpoint chill_main_address_reference_api_show
* method GET, get AddressReference Object
* @returns {Promise} a promise containing all AddressReference object
*/
const fetchReferenceAddresses = (city) => {
console.log('<<< fetching references addresses for', city); // city n'est pas utilisé pour le moment
const url = `/api/1.0/main/address-reference.json`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
export {
fetchCountries,
fetchCities,
fetchReferenceAddresses
};

View File

@ -0,0 +1,219 @@
<template>
<button class="sc-button bt-create centered mt-4" @click="openModal">
{{ $t('add_an_address') }}
</button>
<teleport to="body">
<modal v-if="modal.showModal"
v-bind:modalDialogClass="modal.modalDialogClass"
@close="modal.showModal = false">
<template v-slot:header>
<h3 class="modal-title">{{ $t('add_an_address') }}</h3>
</template>
<template v-slot:body>
<h4>{{ $t('select_an_address') }}</h4>
<label for="isNoAddress">
<input type="checkbox"
name="isNoAddress"
v-bind:placeholder="$t('isNoAddress')"
v-model="isNoAddress"
v-bind:value="value"/>
{{ $t('isNoAddress') }}
</label>
<country-selection
v-bind:address="address"
v-bind:getCities="getCities">
</country-selection>
<city-selection
v-bind:address="address"
v-bind:getReferenceAddresses="getReferenceAddresses">
</city-selection>
<address-selection
v-bind:address="address"
v-bind:updateMapCenter="updateMapCenter">
</address-selection>
<address-map
v-bind:address="address"
ref="addressMap">
</address-map>
<address-more
v-if="!isNoAddress"
v-bind:address="address">
</address-more>
<!--
<div class="address_form__fields__isNoAddress"></div>
<div class="address_form__select">
<div class="address_form__select__header"></div>
<div class="address_form__select__left"></div>
<div class="address_form__map"></div>
</div>
<div class="address_form__fields">
<div class="address_form__fields__header"></div>
<div class="address_form__fields__left"></div>
<div class="address_form__fields__right"></div>
</div>
à discuter,
mais je pense qu'il est préférable de profiter de l'imbriquation des classes css
div.address_form {
div.select {
div.header {}
div.left {}
div.map {}
}
}
-->
</template>
<template v-slot:footer>
<button class="sc-button green"
@click.prevent="$emit('addNewAddress', { address, modal })">
<i class="fa fa-plus fa-fw"></i>{{ $t('action.add')}}
</button>
</template>
</modal>
</teleport>
</template>
<script>
import Modal from './Modal';
import { fetchCountries, fetchCities, fetchReferenceAddresses } from '../_api/AddAddress'
import CountrySelection from './AddAddress/CountrySelection';
import CitySelection from './AddAddress/CitySelection';
import AddressSelection from './AddAddress/AddressSelection';
import AddressMap from './AddAddress/AddressMap';
import AddressMore from './AddAddress/AddressMore'
export default {
name: 'AddAddresses',
components: {
Modal,
CountrySelection,
CitySelection,
AddressSelection,
AddressMap,
AddressMore
},
props: [
],
emits: ['addNewAddress'],
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl"
},
address: {
loaded: {
countries: [],
cities: [],
addresses: [],
},
selected: {
country: {},
city: {},
address: {},
},
addressMap: {
center : [48.8589, 2.3469], // Note: LeafletJs demands [lat, lon] cfr https://macwright.com/lonlat/
zoom: 12
},
isNoAddress: false,
floor: null,
corridor: null,
steps: null,
floor: null,
flat: null,
buildingName: null,
extra: null,
distribution: null,
},
errorMsg: {}
}
},
computed: {
isNoAddress: {
set(value) {
console.log('value', value);
this.address.isNoAddress = value;
},
get() {
return this.address.isNoAddress;
}
}
},
methods: {
openModal() {
this.modal.showModal = true;
this.resetAll();
this.getCountries();
//this.$nextTick(function() {
// this.$refs.search.focus(); // positionner le curseur à l'ouverture de la modale
//})
},
getCountries() {
console.log('getCountries');
this.address.loaded.countries = fetchCountries(); // à remplacer par
// fetchCountries().then(countries => new Promise((resolve, reject) => {
// this.address.loaded.countries = countries;
// resolve()
// }))
// .catch((error) => {
// this.errorMsg.push(error.message);
// });
},
getCities(country) {
console.log('getCities for', country.name);
this.address.loaded.cities = fetchCities(); // à remplacer par
// fetchCities(country).then(cities => new Promise((resolve, reject) => {
// this.address.loaded.cities = cities;
// resolve()
// }))
// .catch((error) => {
// this.errorMsg.push(error.message);
// });
},
getReferenceAddresses(city) {
console.log('getReferenceAddresses for', city.name);
fetchReferenceAddresses(city) // il me semble que le paramètre city va limiter le poids des adresses de références reçues
.then(addresses => new Promise((resolve, reject) => {
console.log('addresses', addresses);
this.address.loaded.addresses = addresses.results;
resolve();
}))
.catch((error) => {
this.errorMsg.push(error.message);
});
},
updateMapCenter(point) {
console.log('point', point);
this.address.addressMap.center[0] = point.coordinates[1]; // TODO use reverse()
this.address.addressMap.center[1] = point.coordinates[0];
this.$refs.addressMap.update(); // cast child methods
},
resetAll() {
console.log('reset all selected');
this.address.loaded.addresses = [];
this.address.selected.address = {};
this.address.loaded.cities = [];
this.address.selected.city = {};
this.address.selected.country = {};
console.log('cities and addresses', this.address.loaded.cities, this.address.loaded.addresses);
}
}
}
</script>

View File

@ -0,0 +1,47 @@
<template>
<div class="container">
<div id='address_map' style='height:400px; width:400px;'></div>
</div>
</template>
<script>
import L from 'leaflet';
import markerIconPng from 'leaflet/dist/images/marker-icon.png'
import 'leaflet/dist/leaflet.css';
let map;
export default {
name: 'AddressMap',
props: ['address'],
computed: {
center() {
return this.address.addressMap.center;
},
},
methods:{
init() {
map = L.map('address_map').setView([48.8589, 2.3469], 12);
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,
});
L.marker([48.8589, 2.3469], {icon: markerIcon}).addTo(map);
},
update() {
console.log('update map with : ', this.address.addressMap.center)
map.setView(this.address.addressMap.center, 12);
}
},
mounted(){
this.init()
}
}
</script>

View File

@ -0,0 +1,112 @@
<template>
<div>
<h4>{{ $t('fill_an_address') }}</h4>
<input
type="text"
name="floor"
:placeholder="$t('floor')"
v-model="floor"/>
<input
type="text"
name="corridor"
:placeholder="$t('corridor')"
v-model="corridor"/>
<input
type="text"
name="steps"
:placeholder="$t('steps')"
v-model="steps"/>
<input
type="text"
name="flat"
:placeholder="$t('flat')"
v-model="flat"/>
<input
type="text"
name="buildingName"
:placeholder="$t('buildingName')"
v-model="buildingName"/>
<input
type="text"
name="extra"
:placeholder="$t('extra')"
v-model="extra"/>
<input
type="text"
name="distribution"
:placeholder="$t('distribution')"
v-model="distribution"/>
</div>
</template>
<script>
export default {
name: "AddressMore",
props: ['address'],
computed: {
floor: {
set(value) {
console.log('value', value);
this.address.floor = value;
},
get() {
return this.address.floor;
}
},
corridor: {
set(value) {
console.log('value', value);
this.address.corridor = value;
},
get() {
return this.address.corridor;
}
},
steps: {
set(value) {
console.log('value', value);
this.address.steps = value;
},
get() {
return this.address.steps;
}
},
flat: {
set(value) {
console.log('value', value);
this.address.flat = value;
},
get() {
return this.address.flat;
}
},
buildingName: {
set(value) {
console.log('value', value);
this.address.buildingName = value;
},
get() {
return this.address.buildingName;
}
},
extra: {
set(value) {
console.log('value', value);
this.address.extra = value;
},
get() {
return this.address.extra;
}
},
distribution: {
set(value) {
console.log('value', value);
this.address.distribution = value;
},
get() {
return this.address.distribution;
}
}
}
}
</script>

View File

@ -0,0 +1,38 @@
<template>
<div class="container">
<select
v-model="selected">
<option :value="{}" disabled selected>{{ $t('select_address') }}</option>
<option
v-for="item in this.addresses"
v-bind:item="item"
v-bind:key="item.id"
v-bind:value="item">
{{ item.street }}, {{ item.streetNumber }}
</option>
</select>
</div>
</template>
<script>
export default {
name: 'AddressSelection',
props: ['address', 'updateMapCenter'],
computed: {
addresses() {
return this.address.loaded.addresses;
},
selected: {
set(value) {
console.log('selected value', value);
this.address.selected.address = value;
this.updateMapCenter(value.point);
},
get() {
return this.address.selected.address;
}
},
}
};
</script>

View File

@ -0,0 +1,38 @@
<template>
<div class="container">
<select
v-model="selected">
<option :value="{}" disabled selected>{{ $t('select_city') }}</option>
<option
v-for="item in this.cities"
v-bind:item="item"
v-bind:key="item.id"
v-bind:value="item">
{{ item.code }}-{{ item.name }}
</option>
</select>
</div>
</template>
<script>
export default {
name: 'CitySelection',
props: ['address', 'getReferenceAddresses'],
computed: {
cities() {
return this.address.loaded.cities;
},
selected: {
set(value) {
console.log('selected value', value.name);
this.address.selected.city = value;
this.getReferenceAddresses(value);
},
get() {
return this.address.selected.city;
}
},
}
};
</script>

View File

@ -0,0 +1,38 @@
<template>
<div class="container">
<select
v-model="selected">
<option :value="{}" disabled selected>{{ $t('select_country') }}</option>
<option
v-for="item in this.countries"
v-bind:item="item"
v-bind:key="item.id"
v-bind:value="item">
{{ item.name }}
</option>
</select>
</div>
</template>
<script>
export default {
name: 'CountrySelection',
props: ['address', 'getCities'],
computed: {
countries() {
return this.address.loaded.countries;
},
selected: {
set(value) {
console.log('selected value', value.name);
this.address.selected.country = value;
this.getCities(value);
},
get() {
return this.address.selected.country;
}
}
}
};
</script>

View File

@ -1,41 +1,40 @@
<template>
<transition name="modal">
<div class="modal-mask">
<!-- :: styles bootstrap :: -->
<div class="modal-dialog" :class="modalDialogClass">
<div class="modal-content">
<div class="modal-header">
<slot name="header"></slot>
<button class="close sc-button grey" @click="$emit('close')">
<i class="fa fa-times" aria-hidden="true"></i></button>
</div>
<div class="modal-body up" style="overflow-y: unset;">
<slot name="body-fixed"></slot>
</div>
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer">
<button class="sc-button cancel" @click="$emit('close')">{{ $t('action.close') }}</button>
<slot name="footer"></slot>
<transition name="modal">
<div class="modal-mask">
<!-- :: styles bootstrap :: -->
<div class="modal-dialog" :class="modalDialogClass">
<div class="modal-content">
<div class="modal-header">
<slot name="header"></slot>
<button class="close sc-button grey" @click="$emit('close')">
<i class="fa fa-times" aria-hidden="true"></i></button>
</div>
<div class="body-head">
<slot name="body-head"></slot>
</div>
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer">
<button class="sc-button cancel" @click="$emit('close')">{{ $t('action.close') }}</button>
<slot name="footer"></slot>
</div>
</div>
</div>
<!-- :: end styles bootstrap :: -->
</div>
<!-- :: end styles bootstrap :: -->
</div>
</transition>
</transition>
</template>
<script>
/*
* This Modal component is a mix between :
* - Vue3 modal implementation
* => with 'v-if:showModal' directive:parameter, html scope is added/removed not just shown/hidden
* => with slot we can pass content from parent component
* => some classes are passed from parent component
* - Bootstrap 4.6 _modal.scss module
* => using bootstrap css classes, the modal have a responsive behaviour,
* => modal design can be configured using css classes (size, scroll)
* This Modal component is a mix between Vue3 modal implementation
* [+] with 'v-if:showModal' directive:parameter, html scope is added/removed not just shown/hidden
* [+] with slot we can pass content from parent component
* [+] some classes are passed from parent component
* and Bootstrap 4.6 _modal.scss module
* [+] using bootstrap css classes, the modal have a responsive behaviour,
* [+] modal design can be configured using css classes (size, scroll)
*/
export default {
name: 'Modal',
@ -43,3 +42,39 @@ export default {
emits: ['close']
}
</script>
<style lang="scss">
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.75);
display: table;
transition: opacity 0.3s ease;
}
.modal-header .close { // bootstrap classes, override sc-button 0 radius
border-top-right-radius: 0.3rem;
}
/*
* The following styles are auto-applied to elements with
* transition="modal" when their visibility is toggled
* by Vue.js.
*
* You can easily play with the modal transition by editing
* these styles.
*/
.modal-enter {
opacity: 0;
}
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
-webkit-transform: scale(1.1);
transform: scale(1.1);
}
</style>

View File

@ -7,11 +7,15 @@ const datetimeFormats = {
month: "numeric",
day: "numeric"
},
text: {
year: "numeric",
month: "long",
day: "numeric",
},
long: {
year: "numeric",
month: "short",
month: "numeric",
day: "numeric",
weekday: "short",
hour: "numeric",
minute: "numeric",
hour12: false
@ -33,12 +37,16 @@ const messages = {
ok: "OK",
cancel: "Annuler",
close: "Fermer",
next: "Suivant",
previous: "Précédent",
back: "Retour",
check_all: "cocher tout",
reset: "réinitialiser"
},
nav: {
next: "Suivant",
previous: "Précédent",
top: "Haut",
bottom: "Bas",
}
}
};

View File

@ -1,4 +1,4 @@
<footer class="footer">
<p>{{ 'This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>'|trans|raw }}
<br/> <a href="https://{{ app.request.locale }}.wikibooks.org/wiki/Chill" target="_blank">{{ 'User manual'|trans }}</a></p>
</footer>
<br/> <a name="bottom" href="https://{{ app.request.locale }}.wikibooks.org/wiki/Chill" target="_blank">{{ 'User manual'|trans }}</a></p>
</footer>

View File

@ -1 +1 @@
<img class="logo" src="{{ asset('build/images/logo-chill-sans-slogan_white.png') }}">
<img name="top" class="logo" src="{{ asset('build/images/logo-chill-sans-slogan_white.png') }}">

View File

@ -0,0 +1,36 @@
<?php
namespace Chill\MainBundle\Search\Model;
class Result
{
private float $relevance;
/**
* mixed an arbitrary result
*/
private $result;
/**
* @param float $relevance
* @param $result
*/
public function __construct(float $relevance, $result)
{
$this->relevance = $relevance;
$this->result = $result;
}
public function getRelevance(): float
{
return $this->relevance;
}
public function getResult()
{
return $this->result;
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Chill\MainBundle\Search;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\EntityManagerInterface;
use Chill\MainBundle\Search\SearchProvider;
use Symfony\Component\VarDumper\Resources\functions\dump;
/**
* ***Warning*** This is an incomplete implementation ***Warning***
*/
class SearchApi
{
private EntityManagerInterface $em;
private SearchProvider $search;
public function __construct(EntityManagerInterface $em, SearchProvider $search)
{
$this->em = $em;
$this->search = $search;
}
/**
* @return Model/Result[]
*/
public function getResults(string $query, int $offset, int $maxResult): array
{
// **warning again**: this is an incomplete implementation
$results = [];
foreach ($this->getPersons($query) as $p) {
$results[] = new Model\Result((float)\rand(0, 100) / 100, $p);
}
foreach ($this->getThirdParties($query) as $t) {
$results[] = new Model\Result((float)\rand(0, 100) / 100, $t);
}
\usort($results, function(Model\Result $a, Model\Result $b) {
return ($a->getRelevance() <=> $b->getRelevance()) * -1;
});
return $results;
}
public function countResults(string $query): int
{
return 0;
}
private function getThirdParties(string $query)
{
$thirdPartiesIds = $this->em->createQuery('SELECT t.id FROM '.ThirdParty::class.' t')
->getScalarResult();
$nbResults = rand(0, 15);
if ($nbResults === 1) {
$nbResults++;
} elseif ($nbResults === 0) {
return [];
}
$ids = \array_map(function ($e) use ($thirdPartiesIds) { return $thirdPartiesIds[$e]['id'];},
\array_rand($thirdPartiesIds, $nbResults));
$a = $this->em->getRepository(ThirdParty::class)
->findById($ids);
return $a;
}
private function getPersons(string $query)
{
$params = [
SearchInterface::SEARCH_PREVIEW_OPTION => false
];
$search = $this->search->getResultByName($query, 'person_regular', 0, 50, $params, 'json');
$ids = \array_map(function($r) { return $r['id']; }, $search['results']);
if (count($ids) === 0) {
return [];
}
return $this->em->getRepository(Person::class)
->findById($ids)
;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Address;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class AddressNormalizer implements NormalizerAwareInterface, NormalizerInterface
{
use NormalizerAwareTrait;
public function normalize($address, string $format = null, array $context = [])
{
$data['address_id'] = $address->getId();
$data['text'] = $address->getStreet().', '.$address->getBuildingName();
$data['postcode']['name'] = $address->getPostCode()->getName();
return $data;
}
public function supportsNormalization($data, string $format = null)
{
return $data instanceof Address;
}
}

View File

@ -20,19 +20,32 @@
namespace Chill\MainBundle\Serializer\Normalizer;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Repository\CenterRepository;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
/**
*
*
*/
class CenterNormalizer implements NormalizerInterface
class CenterNormalizer implements NormalizerInterface, DenormalizerInterface
{
private CenterRepository $repository;
public function __construct(CenterRepository $repository)
{
$this->repository = $repository;
}
public function normalize($center, string $format = null, array $context = array())
{
/** @var Center $center */
return [
'id' => $center->getId(),
'type' => 'center',
'name' => $center->getName()
];
}
@ -41,4 +54,30 @@ class CenterNormalizer implements NormalizerInterface
{
return $data instanceof Center;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
if (FALSE === \array_key_exists('type', $data)) {
throw new InvalidArgumentException('missing "type" key in data');
}
if ('center' !== $data['type']) {
throw new InvalidArgumentException('type should be equal to "center"');
}
if (FALSE === \array_key_exists('id', $data)) {
throw new InvalidArgumentException('missing "id" key in data');
}
$center = $this->repository->find($data['id']);
if (null === $center) {
throw new UnexpectedValueException("The type with id {$data['id']} does not exists");
}
return $center;
}
public function supportsDenormalization($data, string $type, string $format = null)
{
return $type === Center::class;
}
}

View File

@ -20,15 +20,16 @@
namespace Chill\MainBundle\Serializer\Normalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
class DateNormalizer implements NormalizerInterface
class DateNormalizer implements NormalizerInterface, DenormalizerInterface
{
public function normalize($date, string $format = null, array $context = array())
{
/** @var \DateTimeInterface $date */
return [
'datetime' => $date->format(\DateTimeInterface::ISO8601),
'u' => $date->getTimestamp()
'datetime' => $date->format(\DateTimeInterface::ISO8601)
];
}
@ -36,4 +37,24 @@ class DateNormalizer implements NormalizerInterface
{
return $data instanceof \DateTimeInterface;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
switch ($type) {
case \DateTime::class:
return \DateTime::createFromFormat(\DateTimeInterface::ISO8601, $data['datetime']);
case \DateTimeInterface::class:
case \DateTimeImmutable::class:
default:
return \DateTimeImmutable::createFromFormat(\DateTimeInterface::ISO8601, $data['datetime']);
}
}
public function supportsDenormalization($data, string $type, string $format = null): bool
{
return $type === \DateTimeInterface::class ||
$type === \DateTime::class ||
$type === \DateTimeImmutable::class ||
(\is_array($data) && array_key_exists('datetime', $data));
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Chill\MainBundle\Serializer\Normalizer;
use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Exception\RuntimeException;
/**
* Denormalize an object given a list of supported class
*/
class DiscriminatedObjectDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
/**
* The type to set for enabling this type
*/
public const TYPE = '@multi';
/**
* Should be present in context and contains an array of
* allowed types.
*/
public const ALLOWED_TYPES = 'denormalize_multi.allowed_types';
/**
* {@inheritDoc}
*/
public function denormalize($data, string $type, string $format = null, array $context = [])
{
foreach ($context[self::ALLOWED_TYPES] as $localType) {
if ($this->denormalizer->supportsDenormalization($data, $localType, $format)) {
try {
return $this->denormalizer->denormalize($data, $localType, $format, $context); } catch (RuntimeException $e) {
$lastException = $e;
}
}
}
throw new RuntimeException(sprintf("Could not find any denormalizer for those ".
"ALLOWED_TYPES: %s", \implode(", ", $context[self::ALLOWED_TYPES])));
}
/**
* {@inheritDoc}
*/
public function supportsDenormalization($data, string $type, string $format = null, array $context = [])
{
if (self::TYPE !== $type) {
return false;
}
if (0 === count($context[self::ALLOWED_TYPES] ?? [])) {
throw new \LogicException("The context should contains a list of
allowed types");
}
foreach ($context[self::ALLOWED_TYPES] as $localType) {
if ($this->denormalizer->supportsDenormalization($data, $localType, $format)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Chill\MainBundle\Serializer\Normalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Doctrine\ORM\Mapping\ClassMetadata;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Symfony\Component\Serializer\Mapping\ClassMetadataInterface as SerializerMetadata;
class DoctrineExistingEntityNormalizer implements DenormalizerInterface
{
private EntityManagerInterface $em;
private ClassMetadataFactoryInterface $serializerMetadataFactory;
public function __construct(EntityManagerInterface $em, ClassMetadataFactoryInterface $serializerMetadataFactory)
{
$this->em = $em;
$this->serializerMetadataFactory = $serializerMetadataFactory;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
if (\array_key_exists(AbstractNormalizer::OBJECT_TO_POPULATE, $context)) {
return $context[AbstractNormalizer::OBJECT_TO_POPULATE];
}
return $this->em->getRepository($type)
->find($data['id']);
}
public function supportsDenormalization($data, string $type, string $format = null)
{
if (FALSE === \is_array($data)) {
return false;
}
if (FALSE === \array_key_exists('id', $data)) {
return false;
}
if (FALSE === $this->em->getClassMetadata($type) instanceof ClassMetadata) {
return false;
}
// does have serializer metadata, and class discriminator ?
if ($this->serializerMetadataFactory->hasMetadataFor($type)) {
$classDiscriminator = $this->serializerMetadataFactory
->getMetadataFor($type)->getClassDiscriminatorMapping();
if ($classDiscriminator) {
$typeProperty = $classDiscriminator->getTypeProperty();
// check that only 2 keys
// that the second key is property
// and that the type match the class for given type property
return count($data) === 2
&& \array_key_exists($typeProperty, $data)
&& $type === $classDiscriminator->getClassForType($data[$typeProperty]);
}
}
// we do not have any class discriminator. Check that the id is the only one key
return count($data) === 1;
}
}

View File

@ -24,7 +24,7 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
*
*
* @internal we keep this normalizer, because the property 'text' may be replace by a rendering in the future
*/
class UserNormalizer implements NormalizerInterface
{
@ -32,8 +32,10 @@ class UserNormalizer implements NormalizerInterface
{
/** @var User $user */
return [
'type' => 'user',
'id' => $user->getId(),
'username' => $user->getUsername()
'username' => $user->getUsername(),
'text' => $user->getUsername()
];
}

View File

@ -0,0 +1,51 @@
<?php
namespace Chill\MainBundle\Tests\Serializer\Normalizer;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Chill\MainBundle\Serializer\Normalizer\DoctrineExistingEntityNormalizer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface;
use Chill\MainBundle\Entity\User;
class DoctrineExistingEntityNormalizerTest extends KernelTestCase
{
protected DoctrineExistingEntityNormalizer $normalizer;
protected function setUp()
{
self::bootKernel();
$em = self::$container->get(EntityManagerInterface::class);
$serializerFactory = self::$container->get(ClassMetadataFactoryInterface::class);
$this->normalizer = new DoctrineExistingEntityNormalizer($em, $serializerFactory);
}
/**
* @dataProvider dataProviderUserId
*/
public function testGetMappedClass($userId)
{
$data = [ 'type' => 'user', 'id' => $userId];
$supports = $this->normalizer->supportsDenormalization($data, User::class);
$this->assertTrue($supports);
}
public function dataProviderUserId()
{
self::bootKernel();
$userIds = self::$container->get(EntityManagerInterface::class)
->getRepository(User::class)
->createQueryBuilder('u')
->select('u.id')
->setMaxResults(1)
->getQuery()
->getResult()
;
yield [ $userIds[0]['id'] ];
}
}

View File

@ -0,0 +1,58 @@
---
openapi: "3.0.0"
info:
version: "1.0.0"
title: "Chill api"
description: "Api documentation for chill. Currently, work in progress"
servers:
- url: "/api"
description: "Your current dev server"
components:
schemas:
Center:
type: object
properties:
id:
type: integer
name:
type: string
paths:
/1.0/search.json:
get:
summary: perform a search across multiple entities
tags:
- search
- person
- thirdparty
description: >
**Warning**: This is currently a stub (not really implemented
The search is performed across multiple entities. The entities must be listed into
`type` parameters.
The results are ordered by relevance, from the most to the lowest relevant.
parameters:
- name: q
in: query
required: true
description: the pattern to search
schema:
type: string
- name: type[]
in: query
required: true
description: the type entities amongst the search is performed
schema:
type: array
items:
type: string
enum:
- person
- thirdparty
responses:
200:
description: "OK"

View File

@ -62,5 +62,7 @@ module.exports = function(encore, entries)
buildCKEditor(encore);
encore.addEntry('ckeditor5', __dirname + '/Resources/public/modules/ckeditor5/index.js');
// Address
encore.addEntry('address', __dirname + '/Resources/public/vuejs/Address/index.js');
};

View File

@ -73,6 +73,13 @@ chill_main_search:
requirements:
_format: html|json
chill_main_search_global:
path: '/api/1.0/search.{_format}'
controller: Chill\MainBundle\Controller\SearchController::searchApi
format: 'json'
requirements:
_format: 'json'
chill_main_advanced_search:
path: /{_locale}/search/advanced/{name}
controller: Chill\MainBundle\Controller\SearchController::advancedSearchAction

View File

@ -3,6 +3,18 @@ parameters:
services:
Chill\MainBundle\Serializer\Normalizer\:
resource: '../Serializer/Normalizer'
autowire: true
tags:
- { name: 'serializer.normalizer', priority: 64 }
Chill\MainBundle\Doctrine\Event\:
resource: '../Doctrine/Event/'
autowire: true
tags:
- { name: 'doctrine.event_subscriber' }
chill.main.helper.translatable_string:
class: Chill\MainBundle\Templating\TranslatableStringHelper
arguments:

View File

@ -16,6 +16,7 @@ services:
$searchProvider: '@chill_main.search_provider'
$translator: '@Symfony\Contracts\Translation\TranslatorInterface'
$paginatorFactory: '@Chill\MainBundle\Pagination\PaginatorFactory'
$searchApi: '@Chill\MainBundle\Search\SearchApi'
tags: ['controller.service_arguments']
Chill\MainBundle\Controller\PermissionsGroupController:

View File

@ -1,3 +1,10 @@
services:
chill_main.search_provider:
class: Chill\MainBundle\Search\SearchProvider
class: Chill\MainBundle\Search\SearchProvider
Chill\MainBundle\Search\SearchProvider: '@chill_main.search_provider'
Chill\MainBundle\Search\SearchApi:
arguments:
$em: '@Doctrine\ORM\EntityManagerInterface'
$search: '@Chill\MainBundle\Search\SearchProvider'

View File

@ -1,17 +1,11 @@
---
services:
Chill\MainBundle\Serializer\Normalizer\CenterNormalizer:
tags:
- { name: 'serializer.normalizer', priority: 64 }
# note: the autowiring for serializers and normalizers is declared
# into ../services.yaml
Chill\MainBundle\Serializer\Normalizer\DateNormalizer:
Chill\MainBundle\Serializer\Normalizer\DoctrineExistingEntityNormalizer:
autowire: true
tags:
- { name: 'serializer.normalizer', priority: 64 }
- { name: 'serializer.normalizer', priority: 8 }
Chill\MainBundle\Serializer\Normalizer\UserNormalizer:
tags:
- { name: 'serializer.normalizer', priority: 64 }
Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer:
tags:
- { name: 'serializer.normalizer', priority: 64 }

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Chill\Migrations\Main;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Specify ON DELETE behaviour to handle deletion of parents in associated tables
*/
final class Version20210525144016 extends AbstractMigration
{
public function getDescription(): string
{
return 'Specify ON DELETE behaviour to handle deletion of parents in associated tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_address DROP CONSTRAINT FK_165051F6114B8DD9');
$this->addSql('ALTER TABLE chill_main_address ADD CONSTRAINT FK_165051F6114B8DD9 FOREIGN KEY (linkedToThirdParty_id) REFERENCES chill_3party.third_party (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_main_address DROP CONSTRAINT fk_165051f6114b8dd9');
$this->addSql('ALTER TABLE chill_main_address ADD CONSTRAINT fk_165051f6114b8dd9 FOREIGN KEY (linkedtothirdparty_id) REFERENCES chill_3party.third_party (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
}

View File

@ -5,13 +5,19 @@ namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Entity\Person;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Symfony\Component\Serializer\Exception\RuntimeException;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\MainBundle\Entity\Scope;
use Symfony\Component\Workflow\Registry;
class AccompanyingCourseApiController extends ApiController
{
@ -19,10 +25,37 @@ class AccompanyingCourseApiController extends ApiController
protected ValidatorInterface $validator;
public function __construct(EventDispatcherInterface $eventDispatcher, $validator)
{
private Registry $registry;
public function __construct(
EventDispatcherInterface $eventDispatcher,
ValidatorInterface $validator,
Registry $registry
) {
$this->eventDispatcher = $eventDispatcher;
$this->validator = $validator;
$this->registry = $registry;
}
public function confirmApi($id, Request $request, $_format): Response
{
/** @var AccompanyingPeriod $accompanyingPeriod */
$accompanyingPeriod = $this->getEntity('participation', $id, $request);
$this->checkACL('confirm', $request, $_format, $accompanyingPeriod);
$workflow = $this->registry->get($accompanyingPeriod);
if (FALSE === $workflow->can($accompanyingPeriod, 'confirm')) {
throw new BadRequestException('It is not possible to confirm this period');
}
$workflow->apply($accompanyingPeriod, 'confirm');
$this->getDoctrine()->getManager()->flush();
return $this->json($accompanyingPeriod, Response::HTTP_OK, [], [
'groups' => [ 'read' ]
]);
}
public function participationApi($id, Request $request, $_format)
@ -42,11 +75,10 @@ class AccompanyingCourseApiController extends ApiController
switch ($request->getMethod()) {
case Request::METHOD_POST:
$participation = $accompanyingPeriod->addPerson($person);
$participation = $accompanyingPeriod->createParticipationFor($person);
break;
case Request::METHOD_DELETE:
$participation = $accompanyingPeriod->removePerson($person);
$participation->setEndDate(new \DateTimeImmutable('now'));
$participation = $accompanyingPeriod->closeParticipationFor($person);
break;
default:
throw new BadRequestException("This method is not supported");
@ -56,12 +88,76 @@ class AccompanyingCourseApiController extends ApiController
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors);
return $this->json($errors, 422);
}
$this->getDoctrine()->getManager()->flush();
return $this->json($participation);
return $this->json($participation, 200, [], ['groups' => [ 'read' ]]);
}
public function resourceApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('resource', $id, $request, $_format, 'resource', Resource::class);
}
public function scopeApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('scope', $id, $request, $_format, 'scope', Scope::class, [ 'groups' => [ 'read' ] ]);
}
public function commentApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('comment', $id, $request, $_format, 'comment', Comment::class);
}
public function socialIssueApi($id, Request $request, string $_format): Response
{
return $this->addRemoveSomething('socialissue', $id, $request, $_format, 'socialIssue', SocialIssue::class, [ 'groups' => [ 'read' ] ]);
}
public function requestorApi($id, Request $request, string $_format): Response
{
/** @var AccompanyingPeriod $accompanyingPeriod */
$action = 'requestor';
$accompanyingPeriod = $this->getEntity($action, $id, $request);
// a requestor may be a person or a thirdParty
$this->checkACL($action, $request, $_format, $accompanyingPeriod);
$this->onPostCheckACL($action, $request, $_format, $accompanyingPeriod);
if (Request::METHOD_DELETE === $request->getMethod()) {
$accompanyingPeriod->setRequestor(NULL);
} elseif (Request::METHOD_POST === $request->getMethod()) {
$requestor = null;
$exceptions = [];
foreach ([Person::class, ThirdParty::class] as $class) {
try {
$requestor = $this->getSerializer()
->deserialize($request->getContent(), $class, $_format, []);
} catch (RuntimeException $e) {
$exceptions[] = $e;
}
}
if ($requestor === null) {
throw new BadRequestException('Could not find any person or requestor', 0, $exceptions[0]);
}
$accompanyingPeriod->setRequestor($requestor);
} else {
throw new BadRequestException('method not supported');
}
$errors = $this->validator->validate($accompanyingPeriod);
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors, 422);
}
$this->getDoctrine()->getManager()->flush();
return $this->json($accompanyingPeriod->getRequestor(), 200, [], ['groups' => [ 'read']]);
}
protected function onPostCheckACL(string $action, Request $request, string $_format, $entity): ?Response

View File

@ -6,6 +6,7 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Privacy\AccompanyingPeriodPrivacyEvent;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
@ -42,6 +43,41 @@ class AccompanyingCourseController extends Controller
$this->dispatcher = $dispatcher;
$this->validator = $validator;
}
/**
* @Route("/{_locale}/person/parcours/new", name="chill_person_accompanying_course_new")
*/
public function newAction(Request $request): Response
{
$period = new AccompanyingPeriod();
$em = $this->getDoctrine()->getManager();
if ($request->query->has('person_id')) {
$personIds = $request->query->get('person_id');
if (FALSE === \is_array($personIds)) {
throw new BadRequestException("person_id parameter should be an array");
}
foreach ($personIds as $personId) {
$person = $em->getRepository(Person::class)->find($personId);
if (NULL !== $person) {
$period->addPerson($person);
}
}
}
$this->denyAccessUnlessGranted(AccompanyingPeriodVoter::SEE, $period);
$em->persist($period);
$em->flush();
return $this->redirectToRoute('chill_person_accompanying_course_show', [
'accompanying_period_id' => $period->getId()
]);
}
/**
* Homepage of Accompanying Course section
*
@ -86,78 +122,4 @@ class AccompanyingCourseController extends Controller
]);
}
/**
* Get API Data for showing endpoint
*
* @Route(
* "/{_locale}/person/api/1.0/accompanying-course/{accompanying_period_id}/show.{_format}",
* name="chill_person_accompanying_course_api_show"
* )
* @ParamConverter("accompanyingCourse", options={"id": "accompanying_period_id"})
*/
public function showAPI(AccompanyingPeriod $accompanyingCourse, $_format): Response
{
// TODO check ACL on AccompanyingPeriod
$this->dispatcher->dispatch(
AccompanyingPeriodPrivacyEvent::ACCOMPANYING_PERIOD_PRIVACY_EVENT,
new AccompanyingPeriodPrivacyEvent($accompanyingCourse, [
'action' => 'showApi'
])
);
switch ($_format) {
case 'json':
return $this->json($accompanyingCourse);
default:
throw new BadRequestException('Unsupported format');
}
}
/**
* Get API Data for showing endpoint
*
* @Route(
* "/{_locale}/person/api/1.0/accompanying-course/{accompanying_period_id}/participation.{_format}",
* name="chill_person_accompanying_course_api_add_participation",
* methods={"POST","DELETE"},
* format="json",
* requirements={
* "_format": "json",
* }
* )
* @ParamConverter("accompanyingCourse", options={"id": "accompanying_period_id"})
*/
public function participationAPI(Request $request, AccompanyingPeriod $accompanyingCourse, $_format): Response
{
switch ($_format) {
case 'json':
$person = $this->serializer->deserialize($request->getContent(), Person::class, $_format, [
]);
break;
default:
throw new BadRequestException('Unsupported format');
}
if (NULL === $person) {
throw new BadRequestException('person id not found');
}
// TODO add acl
$participation = ($request->getMethod() === 'POST') ?
$accompanyingCourse->addPerson($person) : $accompanyingCourse->removePerson($person);
$errors = $this->validator->validate($accompanyingCourse);
if ($errors->count() > 0) {
// only format accepted
return $this->json($errors);
}
$this->getDoctrine()->getManager()->flush();
return $this->json($participation);
}
}

View File

@ -1,30 +0,0 @@
<?php
/*
* Copyright (C) 2015-2021 Champs-Libres Coopérative <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
class ApiPersonController extends Controller
{
public function viewAction($id, $_format)
{
}
}

View File

@ -0,0 +1,50 @@
<?php
/*
* Copyright (C) 2015-2021 Champs-Libres Coopérative <info@champs-libres.coop>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
namespace Chill\PersonBundle\Controller;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\MainBundle\Security\Authorization\AuthorizationHelper;
use Symfony\Component\Security\Core\Role\Role;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Symfony\Component\HttpFoundation\Request;
class PersonApiController extends ApiController
{
private AuthorizationHelper $authorizationHelper;
/**
* @param AuthorizationHelper $authorizationHelper
*/
public function __construct(AuthorizationHelper $authorizationHelper)
{
$this->authorizationHelper = $authorizationHelper;
}
protected function createEntity(string $action, Request $request): object
{
$person = parent::createEntity($action, $request);
// TODO temporary hack to allow creation of person with fake center
$centers = $this->authorizationHelper->getReachableCenters($this->getUser(),
new Role(PersonVoter::CREATE));
$person->setCenter($centers[0]);
return $person;
}
}

View File

@ -1,5 +1,4 @@
<?php
/*
* Copyright (C) 2014-2016 Julien Fastré <julien.fastre@champs-libres.coop>
*
@ -166,6 +165,7 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
$this->prependHomepageWidget($container);
$this->prependDoctrineDQL($container);
$this->prependCruds($container);
$this->prependWorkflows($container);
//add person_fields parameter as global
$chillPersonConfig = $container->getExtensionConfig($this->getAlias());
@ -195,6 +195,39 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
));
}
protected function prependWorkflows(ContainerBuilder $container)
{
$container->prependExtensionConfig('framework', [
'workflows' => [
'accompanying_period_lifecycle' => [
'type' => 'state_machine',
'audit_trail' => [
'enabled' => true
],
'marking_store' => [
'type' => 'method',
'property' => 'step',
],
'supports' => [
'Chill\PersonBundle\Entity\AccompanyingPeriod'
],
'initial_marking' => 'DRAFT',
'places' => [
'DRAFT',
'CONFIRMED',
],
'transitions' => [
'confirm' => [
'from' => 'DRAFT',
'to' => 'CONFIRMED'
],
],
],
]
]);
}
/**
* Add a widget "add a person" on the homepage, automatically
*
@ -321,8 +354,15 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
'controller' => \Chill\PersonBundle\Controller\AccompanyingCourseApiController::class,
'actions' => [
'_entity' => [
'roles' => [
Request::METHOD_GET => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
'roles' => [
Request::METHOD_GET => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_PATCH => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_PUT => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
],
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_PUT => true,
Request::METHOD_PATCH => true,
]
],
'participation' => [
@ -336,8 +376,79 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
]
],
'resource' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_DELETE => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
],
'comment' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_DELETE => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
],
'requestor' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_DELETE => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
],
'scope' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_DELETE => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
],
'socialissue' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_DELETE => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'controller_action' => 'socialIssueApi',
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
Request::METHOD_DELETE=> \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE
]
],
'confirm' => [
'methods' => [
Request::METHOD_POST => true,
Request::METHOD_GET => false,
Request::METHOD_HEAD => false,
],
'roles' => [
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter::SEE,
]
],
]
],
[
@ -360,7 +471,49 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac
]
],
]
]
],
[
'class' => \Chill\PersonBundle\Entity\SocialWork\SocialIssue::class,
'name' => 'social_work_social_issue',
'base_path' => '/api/1.0/person/social-work/social-issue',
'base_role' => 'ROLE_USER',
'actions' => [
'_index' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
],
],
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true
]
],
]
],
[
'class' => \Chill\PersonBundle\Entity\Person::class,
'name' => 'person',
'base_path' => '/api/1.0/person/person',
'base_role' => \Chill\PersonBundle\Security\Authorization\PersonVoter::SEE,
'controller' => \Chill\PersonBundle\Controller\PersonApiController::class,
'actions' => [
'_entity' => [
'methods' => [
Request::METHOD_GET => true,
Request::METHOD_HEAD => true,
Request::METHOD_POST=> true,
],
'roles' => [
Request::METHOD_GET => \Chill\PersonBundle\Security\Authorization\PersonVoter::SEE,
Request::METHOD_HEAD => \Chill\PersonBundle\Security\Authorization\PersonVoter::SEE,
Request::METHOD_POST => \Chill\PersonBundle\Security\Authorization\PersonVoter::CREATE,
]
],
]
],
]
]);
}

View File

@ -22,25 +22,34 @@
namespace Chill\PersonBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Entity\Scope;
use Chill\PersonBundle\Entity\AccompanyingPeriod\ClosingMotive;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Origin;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Chill\PersonBundle\Entity\SocialWork\SocialIssue;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use DateTimeInterface;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Entity\User;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* AccompanyingPeriod Class
*
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period")
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period"=AccompanyingPeriod::class
* })
*/
class AccompanyingPeriod
class AccompanyingPeriod implements TrackCreationInterface, TrackUpdateInterface
{
/**
* Mark an accompanying period as "occasional"
@ -80,6 +89,7 @@ class AccompanyingPeriod
* @ORM\Id
* @ORM\Column(name="id", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
* @Groups({"read"})
*/
private $id;
@ -87,6 +97,7 @@ class AccompanyingPeriod
* @var \DateTime
*
* @ORM\Column(type="date")
* @Groups({"read", "write"})
*/
private $openingDate;
@ -94,6 +105,7 @@ class AccompanyingPeriod
* @var \DateTime
*
* @ORM\Column(type="date", nullable=true)
* @Groups({"read", "write"})
*/
private $closingDate = null;
@ -101,6 +113,7 @@ class AccompanyingPeriod
* @var string
*
* @ORM\Column(type="text")
* @Groups({"read", "write"})
*/
private $remark = '';
@ -108,17 +121,28 @@ class AccompanyingPeriod
* @var Collection
*
* @ORM\OneToMany(targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod\Comment",
* mappedBy="accompanyingPeriod"
* mappedBy="accompanyingPeriod",
* cascade={"persist", "remove"},
* orphanRemoval=true
* )
*/
private $comments;
/**
* @ORM\ManyToOne(
* targetEntity=Comment::class
* )
* @Groups({"read"})
*/
private ?Comment $initialComment = null;
/**
* @var Collection
*
* @ORM\OneToMany(targetEntity=AccompanyingPeriodParticipation::class,
* mappedBy="accompanyingPeriod",
* cascade={"persist", "refresh", "remove", "merge", "detach"})
* @Groups({"read"})
*/
private $participations;
@ -128,36 +152,42 @@ class AccompanyingPeriod
* @ORM\ManyToOne(
* targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod\ClosingMotive")
* @ORM\JoinColumn(nullable=true)
* @Groups({"read", "write"})
*/
private $closingMotive = null;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
* @Groups({"read", "write"})
*/
private $user;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=true)
* @Groups({"read"})
*/
private $createdBy;
/**
* @var string
* @ORM\Column(type="string", length=32, nullable=true)
* @Groups({"read"})
*/
private $step = self::STEP_DRAFT;
/**
* @ORM\ManyToOne(targetEntity=Origin::class)
* @ORM\JoinColumn(nullable=true)
* @Groups({"read", "write"})
*/
private $origin;
/**
* @var string
* @ORM\Column(type="string", nullable=true)
* @Groups({"read", "write"})
*/
private $intensity;
@ -172,6 +202,7 @@ class AccompanyingPeriod
* joinColumns={@ORM\JoinColumn(name="accompanying_period_id", referencedColumnName="id")},
* inverseJoinColumns={@ORM\JoinColumn(name="scope_id", referencedColumnName="id")}
* )
* @Groups({"read"})
*/
private $scopes;
@ -189,19 +220,22 @@ class AccompanyingPeriod
/**
* @var bool
* @ORM\Column(type="boolean")
* @ORM\Column(type="boolean", options={"default": false} )
* @Groups({"read", "write"})
*/
private $requestorAnonymous = false;
/**
* @var bool
* @ORM\Column(type="boolean")
* @ORM\Column(type="boolean", options={"default": false} )
* @Groups({"read", "write"})
*/
private $emergency = false;
/**
* @var bool
* @ORM\Column(type="boolean")
* @ORM\Column(type="boolean", options={"default": false} )
* @Groups({"read", "write"})
*/
private $confidential = false;
@ -210,21 +244,54 @@ class AccompanyingPeriod
*
* @ORM\OneToMany(
* targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod\Resource",
* mappedBy="accompanyingPeriod"
* mappedBy="accompanyingPeriod",
* cascade={"persist", "remove"},
* orphanRemoval=true
* )
* @Groups({"read"})
*/
private $resources;
/**
* @ORM\ManyToMany(
* targetEntity=SocialIssue::class
* )
* @ORM\JoinTable(
* name="chill_person_accompanying_period_social_issues"
* )
* @Groups({"read"})
*/
private Collection $socialIssues;
/**
* @ORM\Column(type="datetime", nullable=true, options={"default": NULL})
*/
private \DateTimeInterface $createdAt;
/**
* @ORM\ManyToOne(
* targetEntity=User::class
* )
*/
private User $updatedBy;
/**
* @ORM\Column(type="datetime", nullable=true, options={"default": NULL})
*/
private \DateTimeInterface $updatedAt;
/**
* AccompanyingPeriod constructor.
*
* @param \DateTime $dateOpening
* @uses AccompanyingPeriod::setClosingDate()
*/
public function __construct(\DateTime $dateOpening) {
$this->setOpeningDate($dateOpening);
public function __construct(\DateTime $dateOpening = null) {
$this->setOpeningDate($dateOpening ?? new \DateTime('now'));
$this->participations = new ArrayCollection();
$this->scopes = new ArrayCollection();
$this->socialIssues = new ArrayCollection();
$this->comments = new ArrayCollection();
}
/**
@ -314,23 +381,55 @@ class AccompanyingPeriod
return $this->remark;
}
/**
* @Groups({"read"})
*/
public function getComments(): Collection
{
return $this->comments;
return $this->comments->filter(function (Comment $c) {
return $c !== $this->initialComment;
});
}
public function addComment(Comment $comment): self
{
$this->comments[] = $comment;
$comment->setAccompanyingPeriod($this);
return $this;
}
public function removeComment(Comment $comment): void
{
$comment->setAccompanyingPeriod(null);
$this->comments->removeElement($comment);
}
/**
* @Groups({"write"})
*/
public function setInitialComment(?Comment $comment = null): self
{
if (NULL !== $this->initialComment) {
$this->removeComment($this->initialComment);
}
if ($comment instanceof Comment) {
$this->addComment($comment);
}
$this->initialComment = $comment;
return $this;
}
/**
* @Groups({"read"})
*/
public function getInitialComment(): ?Comment
{
return $this->initialComment;
}
/**
* Get Participations Collection
*/
@ -382,9 +481,9 @@ class AccompanyingPeriod
}
/**
* Add Person
* Open a new participation for a person
*/
public function addPerson(Person $person = null): AccompanyingPeriodParticipation
public function createParticipationFor(Person $person): AccompanyingPeriodParticipation
{
$participation = new AccompanyingPeriodParticipation($this, $person);
$this->participations[] = $participation;
@ -392,10 +491,24 @@ class AccompanyingPeriod
return $participation;
}
public function addPerson(Person $person = null): self
{
if (NULL !== $person) {
$this->createParticipationFor($person);
}
return $this;
}
/**
* Remove Person
* Close a participation for a person
*
* Search for the person's participation and set the end date at
* 'now'.
*
* @return void
*/
public function removePerson(Person $person): ?AccompanyingPeriodParticipation
public function closeParticipationFor($person): ?AccompanyingPeriodParticipation
{
$participation = $this->getOpenParticipationContainsPerson($person);
@ -405,6 +518,17 @@ class AccompanyingPeriod
return $participation;
}
/**
* Remove Person
*/
public function removePerson(Person $person): self
{
$this->closeParticipationFor($person);
return $this;
}
public function getClosingMotive(): ?ClosingMotive
@ -514,9 +638,9 @@ class AccompanyingPeriod
return $this->requestorPerson;
}
public function setRequestorPerson(Person $requestorPerson): self
private function setRequestorPerson(Person $requestorPerson = null): self
{
$this->requestorPerson = ($this->requestorThirdParty === null) ? $requestorPerson : null;
$this->requestorPerson = $requestorPerson;
return $this;
}
@ -526,21 +650,53 @@ class AccompanyingPeriod
return $this->requestorThirdParty;
}
public function setRequestorThirdParty(ThirdParty $requestorThirdParty): self
private function setRequestorThirdParty(ThirdParty $requestorThirdParty = null): self
{
$this->requestorThirdParty = ($this->requestorPerson === null) ? $requestorThirdParty : null;
$this->requestorThirdParty = $requestorThirdParty;
return $this;
}
/**
* @return Person|ThirdParty
* @Groups({"read"})
*/
public function getRequestor()
{
return $this->requestorPerson ?? $this->requestorThirdParty;
}
/**
* Set a requestor
*
* The requestor is either an instance of ThirdParty, or an
* instance of Person
*
* @param $requestor Person|ThirdParty
* @return self
* @throw UnexpectedValueException if the requestor is not a Person or ThirdParty
* @Groups({"write"})
*/
public function setRequestor($requestor): self
{
if ($requestor instanceof Person) {
$this->setRequestorThirdParty(NULL);
$this->setRequestorPerson($requestor);
} elseif ($requestor instanceof ThirdParty) {
$this->setRequestorThirdParty($requestor);
$this->setRequestorPerson(NULL);
} elseif (NULL === $requestor) {
$this->setRequestorPerson(NULL);
$this->setRequestorThirdParty(NULL);
} else {
throw new \UnexpectedValueException("requestor is not an instance of Person or ThirdParty");
}
return $this;
}
public function isRequestorAnonymous(): bool
{
return $this->requestorAnonymous;
@ -637,6 +793,7 @@ class AccompanyingPeriod
public function addResource(Resource $resource): self
{
$resource->setAccompanyingPeriod($this);
$this->resources[] = $resource;
return $this;
@ -644,9 +801,27 @@ class AccompanyingPeriod
public function removeResource(Resource $resource): void
{
$resource->setAccompanyingPeriod(null);
$this->resources->removeElement($resource);
}
public function getSocialIssues(): Collection
{
return $this->socialIssues;
}
public function addSocialIssue(SocialIssue $socialIssue): self
{
$this->socialIssues[] = $socialIssue;
return $this;
}
public function removeSocialIssue(SocialIssue $socialIssue): void
{
$this->socialIssues->removeElement($socialIssue);
}
/**
* Get a list of all persons which are participating to this course
*
@ -662,4 +837,25 @@ class AccompanyingPeriod
}
);
}
public function setCreatedAt(\DateTimeInterface $datetime): self
{
$this->createdAt = $datetime;
return $this;
}
public function setUpdatedBy(User $user): self
{
$this->updatedBy = $user;
return $this;
}
public function setUpdatedAt(\DateTimeInterface $datetime): self
{
$this->updatedAt = $datetime;
return $this;
}
}

View File

@ -25,17 +25,25 @@ namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
/**
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period_comment")
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period_comment"=Comment::class
* })
*/
class Comment
class Comment implements TrackCreationInterface, TrackUpdateInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({"read"})
*/
private $id;
@ -43,34 +51,39 @@ class Comment
* @ORM\ManyToOne(
* targetEntity="Chill\PersonBundle\Entity\AccompanyingPeriod",
* inversedBy="comments")
* @ORM\JoinColumn(nullable=false)
* @ORM\JoinColumn(nullable=false, onDelete="CASCADE")
*/
private $accompanyingPeriod;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
* @Groups({"read"})
*/
private $creator;
/**
* @ORM\Column(type="datetime")
* @Groups({"read"})
*/
private $createdAt;
/**
* @ORM\Column(type="datetime")
* @Groups({"read"})
*/
private $updatedAt;
/**
* @ORM\ManyToOne(targetEntity=User::class)
* @ORM\JoinColumn(nullable=false)
* @Groups({"read"})
*/
private $updatedBy;
/**
* @ORM\Column(type="text")
* @Groups({"read", "write"})
*/
private $content;
@ -103,6 +116,11 @@ class Comment
return $this;
}
public function setCreatedBy(User $user): self
{
return $this->setCreator($user);
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
@ -132,7 +150,7 @@ class Comment
return $this->updatedBy;
}
public function setUpdatedBy(?User $updatedBy): self
public function setUpdatedBy(User $updatedBy): self
{
$this->updatedBy = $updatedBy;

View File

@ -23,6 +23,7 @@
namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity
@ -34,16 +35,19 @@ class Origin
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({"read"})
*/
private $id;
/**
* @ORM\Column(type="json")
* @Groups({"read"})
*/
private $label;
/**
* @ORM\Column(type="date_immutable", nullable=true)
* @Groups({"read"})
*/
private $noActiveAfter;

View File

@ -25,12 +25,18 @@ namespace Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Repository\AccompanyingPeriod\ResourceRepository;
use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
use Symfony\Component\Serializer\Annotation\Groups;
/**
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period_resource")
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period_resource"=Resource::class
* })
*/
class Resource
{
@ -38,6 +44,7 @@ class Resource
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({"read"})
*/
private $id;
@ -90,7 +97,7 @@ class Resource
return $this->thirdParty;
}
public function setThirdParty(?ThirdParty $thirdParty): self
private function setThirdParty(?ThirdParty $thirdParty): self
{
$this->thirdParty = $thirdParty;
@ -102,7 +109,7 @@ class Resource
return $this->person;
}
public function setPerson(?Person $person): self
private function setPerson(?Person $person): self
{
$this->person = $person;
@ -120,9 +127,35 @@ class Resource
return $this;
}
/**
*
* @param $resource Person|ThirdParty
*/
public function setResource($resource): self
{
if ($resource instanceof ThirdParty) {
$this->setThirdParty($resource);
$this->setPerson(NULL);
} elseif ($resource instanceof Person) {
$this->setPerson($resource);
$this->setThirdParty(NULL);
} elseif (NULL === $resource) {
$this->setPerson(NULL);
$this->setThirdParty(NULL);
} else {
throw new \UnexpectedValueException(sprintf("the resource ".
"should be an instance of %s or %s", Person::class,
ThirdParty::class));
}
return $this;
}
/**
* @return Person|ThirdParty
* @return ThirdParty|Person
* @Groups({"read", "write"})
*/
public function getResource()
{

View File

@ -25,6 +25,8 @@ namespace Chill\PersonBundle\Entity;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* AccompanyingPeriodParticipation Class
@ -32,6 +34,9 @@ use Doctrine\ORM\Mapping as ORM;
* @package Chill\PersonBundle\Entity
* @ORM\Entity
* @ORM\Table(name="chill_person_accompanying_period_participation")
* @DiscriminatorMap(typeProperty="type", mapping={
* "accompanying_period_participation"=AccompanyingPeriodParticipation::class
* })
*/
class AccompanyingPeriodParticipation
{
@ -39,12 +44,14 @@ class AccompanyingPeriodParticipation
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
* @Groups({"read"})
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity=Person::class, inversedBy="accompanyingPeriodParticipations")
* @ORM\JoinColumn(name="person_id", referencedColumnName="id", nullable=false)
* @Groups({"read"})
*/
private $person;
@ -56,11 +63,13 @@ class AccompanyingPeriodParticipation
/**
* @ORM\Column(type="date", nullable=false)
* @Groups({"read"})
*/
private $startDate;
/**
* @ORM\Column(type="date", nullable=true)
* @Groups({"read"})
*/
private $endDate = null;

View File

@ -34,6 +34,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* Person Class
@ -45,6 +46,9 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* columns={"firstName", "lastName"}
* )})
* @ORM\HasLifecycleCallbacks()
* @DiscriminatorMap(typeProperty="type", mapping={
* "person"=Person::class
* })
*/
class Person implements HasCenterInterface
{

View File

@ -4,10 +4,15 @@ namespace Chill\PersonBundle\Entity\SocialWork;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\DiscriminatorMap;
/**
* @ORM\Entity
* @ORM\Table(name="chill_person_social_issue")
* @DiscriminatorMap(typeProperty="type", mapping={
* "social_issue"=SocialIssue::class
* })
*/
class SocialIssue
{
@ -35,6 +40,7 @@ class SocialIssue
/**
* @ORM\Column(type="json")
* @Groups({"read"})
*/
private $title = [];
@ -59,6 +65,11 @@ class SocialIssue
return $this->parent;
}
public function hasParent(): bool
{
return $this->parent !== null;
}
public function setParent(?self $parent): self
{
$this->parent = $parent;

View File

@ -3,6 +3,7 @@
namespace Chill\PersonBundle\Menu;
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Knp\Menu\MenuItem;
use Symfony\Contracts\Translation\TranslatorInterface;
@ -32,24 +33,31 @@ class AccompanyingCourseMenuBuilder implements LocalMenuBuilderInterface
public function buildMenu($menuId, MenuItem $menu, array $parameters): void
{
$period = $parameters['accompanyingCourse'];
$menu->addChild($this->translator->trans('Resume Accompanying Course'), [
'route' => 'chill_person_accompanying_course_index',
'routeParameters' => [
'accompanying_period_id' => $parameters['accompanyingCourse']->getId()
'accompanying_period_id' => $period->getId()
]])
->setExtras(['order' => 10]);
$menu->addChild($this->translator->trans('Edit Accompanying Course'), [
'route' => 'chill_person_accompanying_course_show',
'routeParameters' => [
'accompanying_period_id' => $parameters['accompanyingCourse']->getId()
'accompanying_period_id' => $period->getId()
]])
->setExtras(['order' => 20]);
if (AccompanyingPeriod::STEP_DRAFT === $period->getStep()) {
// no more menu items if the period is draft
return;
}
$menu->addChild($this->translator->trans('Accompanying Course Details'), [
'route' => 'chill_person_accompanying_course_history',
'routeParameters' => [
'accompanying_period_id' => $parameters['accompanyingCourse']->getId()
'accompanying_period_id' => $period->getId()
]])
->setExtras(['order' => 30]);
}

View File

@ -71,6 +71,14 @@ class SectionMenuBuilder implements LocalMenuBuilderInterface
'icons' => [ 'plus' ]
]);
}
$menu->addChild($this->translator->trans('Create an accompanying course'), [
'route' => 'chill_person_accompanying_course_new'
])
->setExtras([
'order' => 11,
'icons' => [ 'plus' ]
]);
}
/**

View File

@ -27,46 +27,41 @@ use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
/**
* Class ClosingMotiveRepository
* Entity repository for closing motives
*
* @package Chill\PersonBundle\Repository
*/
final class ClosingMotiveRepository
{
private EntityRepository $repository;
private EntityManagerInterface $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
$this->repository = $entityManager->getRepository(ClosingMotive::class);
}
/**
* @param bool $onlyLeaf
* @return mixed
*/
public function getActiveClosingMotive(bool $onlyLeaf = true)
{
$rsm = new ResultSetMappingBuilder($this->repository->getEntityManager());
$rsm = new ResultSetMappingBuilder($this->entityManager);
$rsm->addRootEntityFromClassMetadata($this->repository->getClassName(), 'cm');
$sql = "SELECT ".(string) $rsm."
$sql = "SELECT " . (string) $rsm . "
FROM chill_person_accompanying_period_closingmotive AS cm
WHERE
active IS TRUE ";
if ($onlyLeaf) {
$sql .= "AND cm.id NOT IN (
SELECT DISTINCT parent_id FROM chill_person_accompanying_period_closingmotive WHERE parent_id IS NOT NULL
)";
}
$sql .= " ORDER BY cm.ordering ASC";
return $this
->repository
->getEntityManager()
->entityManager
->createNativeQuery($sql, $rsm)
->getResult();
}

View File

@ -26,12 +26,6 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod\Comment;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Comment|null find($id, $lockMode = null, $lockVersion = null)
* @method Comment|null findOneBy(array $criteria, array $orderBy = null)
* @method Comment[] findAll()
* @method Comment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class CommentRepository
{
private EntityRepository $repository;

View File

@ -26,12 +26,6 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod\Origin;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Origin|null find($id, $lockMode = null, $lockVersion = null)
* @method Origin|null findOneBy(array $criteria, array $orderBy = null)
* @method Origin[] findAll()
* @method Origin[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class OriginRepository
{
private EntityRepository $repository;

View File

@ -23,15 +23,9 @@
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\Resource;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* @method Resource|null find($id, $lockMode = null, $lockVersion = null)
* @method Resource|null findOneBy(array $criteria, array $orderBy = null)
* @method Resource[] findAll()
* @method Resource[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class ResourceRepository
{
private EntityRepository $repository;
@ -40,4 +34,9 @@ final class ResourceRepository
{
$this->repository = $entityManager->getRepository(Resource::class);
}
public function find($id, $lockMode = null, $lockVersion = null): ?Resource
{
return $this->repository->find($id, $lockMode, $lockVersion);
}
}

View File

@ -26,12 +26,6 @@ use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method AccompanyingPeriodParticipation|null find($id, $lockMode = null, $lockVersion = null)
* @method AccompanyingPeriodParticipation|null findOneBy(array $criteria, array $orderBy = null)
* @method AccompanyingPeriodParticipation[] findAll()
* @method AccompanyingPeriodParticipation[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class AccompanyingPeriodParticipationRepository
{
private EntityRepository $repository;

View File

@ -26,12 +26,6 @@ use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method AccompanyingPeriod|null find($id, $lockMode = null, $lockVersion = null)
* @method AccompanyingPeriod|null findOneBy(array $criteria, array $orderBy = null)
* @method AccompanyingPeriod[] findAll()
* @method AccompanyingPeriod[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class AccompanyingPeriodRepository
{
private EntityRepository $repository;

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\Household\HouseholdMembers;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method HouseholdMembers|null find($id, $lockMode = null, $lockVersion = null)
* @method HouseholdMembers|null findOneBy(array $criteria, array $orderBy = null)
* @method HouseholdMembers[] findAll()
* @method HouseholdMembers[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class HouseholdMembersRepository
{
private EntityRepository $repository;
@ -20,33 +14,4 @@ final class HouseholdMembersRepository
{
$this->repository = $entityManager->getRepository(HouseholdMembers::class);
}
// /**
// * @return HouseholdMembers[] Returns an array of HouseholdMembers objects
// */
/*
public function findByExampleField($value)
{
return $this->createQueryBuilder('h')
->andWhere('h.exampleField = :val')
->setParameter('val', $value)
->orderBy('h.id', 'ASC')
->setMaxResults(10)
->getQuery()
->getResult()
;
}
*/
/*
public function findOneBySomeField($value): ?HouseholdMembers
{
return $this->createQueryBuilder('h')
->andWhere('h.exampleField = :val')
->setParameter('val', $value)
->getQuery()
->getOneOrNullResult()
;
}
*/
}

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\Household\Household;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Household|null find($id, $lockMode = null, $lockVersion = null)
* @method Household|null findOneBy(array $criteria, array $orderBy = null)
* @method Household[] findAll()
* @method Household[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class HouseholdRepository
{
private EntityRepository $repository;
@ -20,33 +14,4 @@ final class HouseholdRepository
{
$this->repository = $entityManager->getRepository(Household::class);
}
// /**
// * @return Household[] Returns an array of Household objects
// */
/*
public function findByExampleField($value)
{
return $this->createQueryBuilder('h')
->andWhere('h.exampleField = :val')
->setParameter('val', $value)
->orderBy('h.id', 'ASC')
->setMaxResults(10)
->getQuery()
->getResult()
;
}
*/
/*
public function findOneBySomeField($value): ?Household
{
return $this->createQueryBuilder('h')
->andWhere('h.exampleField = :val')
->setParameter('val', $value)
->getQuery()
->getOneOrNullResult()
;
}
*/
}

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\PersonAltName;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* PersonAltNameRepository
*
* This class was generated by the Doctrine ORM. Add your own custom
* repository methods below.
*/
final class PersonAltNameRepository
{
private EntityRepository $repository;

View File

@ -7,11 +7,6 @@ use Chill\PersonBundle\Entity\PersonNotDuplicate;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* Class PersonNotDuplicateRepository
*
* @package Chill\PersonBundle\Repository
*/
final class PersonNotDuplicateRepository
{
private EntityRepository $repository;
@ -21,12 +16,7 @@ final class PersonNotDuplicateRepository
$this->repository = $entityManager->getRepository(PersonNotDuplicate::class);
}
/**
* @param \Chill\PersonBundle\Entity\Person $person
*
* @return array
*/
public function findNotDuplicatePerson(Person $person)
public function findNotDuplicatePerson(Person $person): array
{
$qb = $this->repository->createQueryBuilder('pnd');
$qb->select('pnd')
@ -36,6 +26,7 @@ final class PersonNotDuplicateRepository
$result = $qb->getQuery()->getResult();
$persons = [];
foreach ($result as $row) {
if ($row->getPerson1() === $person) {
$persons[] = $row->getPerson2();

View File

@ -32,105 +32,88 @@ final class PersonRepository
$this->repository = $entityManager->getRepository(Person::class);
}
public function find($id, $lockMode = null, $lockVersion = null)
public function find($id, $lockMode = null, $lockVersion = null): ?Person
{
return $this->repository->find($id, $lockMode, $lockVersion);
}
/**
* @param string $phonenumber
* @param $centers
* @param $firstResult
* @param $maxResults
* @param array $only
* @return mixed
* @throws \Exception
*/
public function findByPhone(
string $phonenumber,
$centers,
string $phonenumber,
$centers,
$firstResult,
$maxResults,
array $only = ['mobile', 'phone']
) {
$qb = $this->repository->createQueryBuilder('p');
$qb->select('p');
$this->addByCenters($qb, $centers);
$this->addPhoneNumber($qb, $phonenumber, $only);
$qb->setFirstResult($firstResult)
->setMaxResults($maxResults)
;
return $qb->getQuery()->getResult();
}
/**
* @param string $phonenumber
* @param $centers
* @param array $only
* @return int
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function countByPhone(
string $phonenumber,
$centers,
string $phonenumber,
$centers,
array $only = ['mobile', 'phone']
): int
{
): int {
$qb = $this->repository->createQueryBuilder('p');
$qb->select('COUNT(p)');
$this->addByCenters($qb, $centers);
$this->addPhoneNumber($qb, $phonenumber, $only);
return $qb->getQuery()->getSingleScalarResult();
}
/**
* @param QueryBuilder $qb
* @param string $phonenumber
* @param array $only
* @throws \Exception
*/
protected function addPhoneNumber(QueryBuilder $qb, string $phonenumber, array $only)
protected function addPhoneNumber(QueryBuilder $qb, string $phonenumber, array $only): void
{
if (count($only) === 0) {
throw new \Exception("No array field to search");
}
$phonenumber = $this->parsePhoneNumber($phonenumber);
$orX = $qb->expr()->orX();
if (\in_array('mobile', $only)) {
$orX->add($qb->expr()->like("REPLACE(p.mobilenumber, ' ', '')", ':phonenumber'));
}
if (\in_array('phone', $only)) {
$orX->add($qb->expr()->like("REPLACE(p.phonenumber, ' ', '')", ':phonenumber'));
}
$qb->andWhere($orX);
$qb->setParameter('phonenumber', '%'.$phonenumber.'%');
}
/**
* @param $phonenumber
* @return string
*/
protected function parsePhoneNumber($phonenumber): string
protected function parsePhoneNumber(string $phonenumber): string
{
return \str_replace(' ', '', $phonenumber);
}
/**
* @param QueryBuilder $qb
* @param array $centers
*/
protected function addByCenters(QueryBuilder $qb, array $centers)
protected function addByCenters(QueryBuilder $qb, array $centers): void
{
if (count($centers) > 0) {
$qb->andWhere($qb->expr()->in('p.center', ':centers'));

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\SocialWork\Evaluation;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Evaluation|null find($id, $lockMode = null, $lockVersion = null)
* @method Evaluation|null findOneBy(array $criteria, array $orderBy = null)
* @method Evaluation[] findAll()
* @method Evaluation[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class EvaluationRepository
{
private EntityRepository $repository;

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\SocialWork\Goal;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Goal|null find($id, $lockMode = null, $lockVersion = null)
* @method Goal|null findOneBy(array $criteria, array $orderBy = null)
* @method Goal[] findAll()
* @method Goal[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class GoalRepository
{
private EntityRepository $repository;

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\SocialWork\Result;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method Result|null find($id, $lockMode = null, $lockVersion = null)
* @method Result|null findOneBy(array $criteria, array $orderBy = null)
* @method Result[] findAll()
* @method Result[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class ResultRepository
{
private EntityRepository $repository;

View File

@ -6,12 +6,6 @@ use Chill\PersonBundle\Entity\SocialWork\SocialAction;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method SocialAction|null find($id, $lockMode = null, $lockVersion = null)
* @method SocialAction|null findOneBy(array $criteria, array $orderBy = null)
* @method SocialAction[] findAll()
* @method SocialAction[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class SocialActionRepository
{
private EntityRepository $repository;

View File

@ -7,12 +7,6 @@ use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
/**
* @method SocialIssue|null find($id, $lockMode = null, $lockVersion = null)
* @method SocialIssue|null findOneBy(array $criteria, array $orderBy = null)
* @method SocialIssue[] findAll()
* @method SocialIssue[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
final class SocialIssueRepository
{
private EntityRepository $repository;

View File

@ -1,25 +1,106 @@
<template>
<accompanying-course></accompanying-course>
<banner></banner>
<sticky-nav></sticky-nav>
<h1 v-if="accompanyingCourse.step === 'DRAFT'">{{ $t('course.title.draft') }}</h1>
<h1 v-else>{{ $t('course.title.active') }}</h1>
<persons-associated></persons-associated>
<requestor></requestor>
<social-issue></social-issue>
<referrer></referrer>
<resources></resources>
<comment v-if="accompanyingCourse.step === 'DRAFT'"></comment>
<confirm v-if="accompanyingCourse.step === 'DRAFT'"></confirm>
</template>
<script>
import { mapState } from 'vuex'
import AccompanyingCourse from './components/AccompanyingCourse.vue';
import Banner from './components/Banner.vue';
import StickyNav from './components/StickyNav.vue';
import PersonsAssociated from './components/PersonsAssociated.vue';
import Requestor from './components/Requestor.vue';
import SocialIssue from './components/SocialIssue.vue';
import Referrer from './components/Referrer.vue';
import Resources from './components/Resources.vue';
import Comment from './components/Comment.vue';
import Confirm from './components/Confirm.vue';
export default {
name: 'App',
components: {
AccompanyingCourse,
Banner,
StickyNav,
PersonsAssociated,
Requestor
Requestor,
SocialIssue,
Referrer,
Resources,
Comment,
Confirm,
},
computed: mapState([
'accompanyingCourse'
])
};
</script>
<style lang="scss">
div#accompanying-course {
h1 {
margin: 1.5em 0;
}
div.vue-component {
h2 {
margin-left: 0.7em;
position: relative;
&:before {
position: absolute;
/*
content: "\f192"; //circle-dot
content: "\f1dd"; //paragraph
content: "\f292"; //hashtag
content: "\f069"; //asterisk
*/
content: "\f142"; //ellipsis-v
font-family: "ForkAwesome";
color: #718596ab;
left: -22px;
top: 4px;
}
a[name^="section"] {
position: absolute;
top: -3.5em; // ref. stickNav
}
}
padding: 0.8em 0em;
margin: 2em 0;
border: 1px dotted #718596ab;
border-radius: 5px;
border-left: 1px dotted #718596ab;
border-right: 1px dotted #718596ab;
/* debug components
position: relative;
&:before {
content: "vuejs component";
position: absolute;
left: 1.5em;
top: -0.9em;
background-color: white;
color: grey;
padding: 0 0.3em;
}
*/
dd {
margin-left: 1em;
}
& > div {
margin: 1em 3em 0;
}
table {
}
}
}
</style>

View File

@ -1,18 +1,11 @@
const
locale = 'fr',
format = 'json'
, accompanying_period_id = window.accompanyingCourseId //tmp
;
/*
* Endpoint chill_person_accompanying_course_api_show
* method GET, get AccompanyingCourse Object
* Endpoint v.2 chill_api_single_accompanying_course__entity
* method GET/HEAD, get AccompanyingCourse Instance
*
* @accompanying_period_id___ integer
* @TODO var is not used but necessary in method signature
* @id integer - id of accompanyingCourse
*/
let getAccompanyingCourse = (accompanying_period_id___) => { //tmp
const url = `/${locale}/person/api/1.0/accompanying-course/${accompanying_period_id}/show.${format}`;
const getAccompanyingCourse = (id) => {
const url = `/api/1.0/person/accompanying-course/${id}.json`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
@ -21,21 +14,150 @@ let getAccompanyingCourse = (accompanying_period_id___) => { //tmp
};
/*
* Endpoint chill_person_accompanying_course_api_add_participation,
* Endpoint v.2 chill_api_single_accompanying_course__entity
* method PATCH, patch AccompanyingCourse Instance
*
* @id integer - id of accompanyingCourse
* @body Object - dictionary with changes to post
*/
const patchAccompanyingCourse = (id, body) => {
console.log('body', body);
const url = `/api/1.0/person/accompanying-course/${id}.json`;
return fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(body)
})
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/*
* Endpoint to change 'DRAFT' step to 'CONFIRMED'
*/
const confirmAccompanyingCourse = (id) => {
const url = `/api/1.0/person/accompanying-course/${id}/confirm.json`
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json;charset=utf-8'}
})
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/*
* Endpoint
*/
const getSocialIssues = () => {
const url = `/api/1.0/person/social-work/social-issue.json`;
return fetch(url)
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/*
* Endpoint v.2 chill_api_single_accompanying_course_participation,
* method POST/DELETE, add/close a participation to the accompanyingCourse
*
* @accompanying_period_id integer - id of accompanyingCourse
* @person_id integer - id of person
* @method string - POST or DELETE
* @id integer - id of accompanyingCourse
* @payload integer - id of person
* @method string - POST or DELETE
*/
let postParticipation = (accompanying_period_id, person_id, method) => {
const url = `/${locale}/person/api/1.0/accompanying-course/${accompanying_period_id}/participation.${format}`
const postParticipation = (id, payload, method) => {
const body = { type: payload.type, id: payload.id };
const url = `/api/1.0/person/accompanying-course/${id}/participation.json`;
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify({id: person_id})
body: JSON.stringify(body)
})
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/*
* Endpoint v.2 chill_api_single_accompanying_course_requestor,
* method POST/DELETE, add/close a requestor to the accompanyingCourse
*
* @id integer - id of accompanyingCourse
* @payload object of type person|thirdparty
* @method string - POST or DELETE
*/
const postRequestor = (id, payload, method) => {
//console.log('payload', payload);
const body = (payload)? { type: payload.type, id: payload.id } : {};
console.log('body', body);
const url = `/api/1.0/person/accompanying-course/${id}/requestor.json`;
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(body)
})
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/*
* Endpoint v.2 chill_api_single_accompanying_course_resource,
* method POST/DELETE, add/remove a resource to the accompanyingCourse
*
* @id integer - id of accompanyingCourse
* @payload object of type person|thirdparty
* @method string - POST or DELETE
*/
const postResource = (id, payload, method) => {
//console.log('payload', payload);
const body = { type: "accompanying_period_resource" };
switch (method) {
case 'DELETE':
body['id'] = payload.id;
break;
default:
body['resource'] = { type: payload.type, id: payload.id };
}
console.log('body', body);
const url = `/api/1.0/person/accompanying-course/${id}/resource.json`;
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(body)
})
.then(response => {
if (response.ok) { return response.json(); }
throw Error('Error with request resource response');
});
};
/*
* Endpoint to Add/remove SocialIssue
*/
const postSocialIssue = (id, body, method) => {
//console.log('api body and method', body, method);
const url = `/api/1.0/person/accompanying-course/${id}/socialissue.json`;
return fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify(body)
})
.then(response => {
if (response.ok) { return response.json(); }
@ -44,6 +166,12 @@ let postParticipation = (accompanying_period_id, person_id, method) => {
};
export {
getAccompanyingCourse,
postParticipation
getAccompanyingCourse,
patchAccompanyingCourse,
confirmAccompanyingCourse,
getSocialIssues,
postParticipation,
postRequestor,
postResource,
postSocialIssue
};

View File

@ -1,28 +0,0 @@
<template>
<div class="vue-component">
<h3>{{ $t('course.title') }}</h3>
<dl>
<dt>{{ $t('course.id') }}</dt>
<dd>{{ accompanyingCourse.id }}</dd>
<dt>{{ $t('course.opening_date') }}</dt>
<dd>{{ $d(accompanyingCourse.openingDate.datetime, 'short') }}</dd>
<dt>{{ $t('course.closing_date') }}</dt>
<dd>{{ $d(accompanyingCourse.closingDate.datetime, 'short') }}</dd>
<dt>{{ $t('course.remark') }}</dt>
<dd>{{ accompanyingCourse.remark }}</dd>
<dt>{{ $t('course.closing_motive') }}</dt>
<dd>{{ accompanyingCourse.closing_motive }}</dd>
</dl>
</div>
</template>
<script>
export default {
name: 'AccompanyingCourse',
computed: {
accompanyingCourse() {
return this.$store.state.accompanyingCourse
}
}
}
</script>

View File

@ -0,0 +1,92 @@
<template>
<div class="vue-component" style="display: none;">
<dl>
<dt>{{ $t('course.id') }}</dt>
<dd>{{ accompanyingCourse.id }}</dd>
</dl>
<dl v-if="accompanyingCourse.closingDate">
<dt>{{ $t('course.closing_date') }}</dt>
<dd>{{ $d(accompanyingCourse.closingDate.datetime, 'short') }}</dd>
<dt>{{ $t('course.closing_motive') }}</dt>
<dd v-if="accompanyingCourse.closingMotive">{{ accompanyingCourse.closingMotive.name.fr }}</dd>
</dl>
</div>
<teleport to="#header-accompanying_course-name #banner-flags">
<toggle-flags></toggle-flags>
</teleport>
<teleport to="#header-accompanying_course-name #banner-status">
<div v-if="accompanyingCourse.step === 'DRAFT'">
<span class="badge badge-secondary">
{{ $t('course.step.draft') }}
</span>
</div>
<div v-else>
<div>
<span class="badge badge-primary">
{{ $t('course.step.active') }}
</span>
</div>
<div>
<span>
<i>{{ $t('course.open_at') }}{{ $d(accompanyingCourse.openingDate.datetime, 'text') }}</i>
</span>
<br>
<span v-if="accompanyingCourse.user">
{{ $t('course.by') }}<b>{{ accompanyingCourse.user.username }}</b>
</span>
</div>
</div>
</teleport>
<teleport to="#header-accompanying_course-details #banner-social-issues">
<div class="grid-12">
<social-issue
v-for="issue in accompanyingCourse.socialIssues"
v-bind:key="issue.id"
v-bind:issue="issue">
</social-issue>
</div>
</teleport>
</template>
<script>
import ToggleFlags from './Banner/ToggleFlags';
import SocialIssue from './Banner/SocialIssue.vue';
export default {
name: 'Banner',
components: {
ToggleFlags,
SocialIssue
},
computed: {
accompanyingCourse() {
return this.$store.state.accompanyingCourse
}
}
}
</script>
<style lang="scss">
div#banner-flags,
div#banner-status {
margin: 1.5em 0;
div {
text-align: right;
margin-bottom: 0.8em;
}
.badge {
text-transform: uppercase;
}
}
div#banner-status {
span.badge {
font-size: 90%;
}
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<span class="badge badge-secondary">{{ issue.text }}</span>
</template>
<script>
export default {
name: "SocialIssues",
props: ['issue']
}
</script>
<style lang="scss" scoped>
span.badge {
font-size: 95%;
text-transform: capitalize !important;
font-weight: 500 !important;
margin-bottom: 5px;
margin-right: 1em;
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div>
<a @click="toggleIntensity" class="flag-toggle">
{{ $t('course.occasional') }}
<i class="fa" :class="{ 'fa-toggle-on': isRegular, 'fa-toggle-on fa-flip-horizontal': !isRegular }"></i>
{{ $t('course.regular') }}
</a>
</div>
<div>
<button class="badge badge-pill" :class="{ 'badge-primary': isEmergency, 'badge-secondary': !isEmergency }" @click="toggleEmergency">
{{ $t('course.emergency') }}
</button>
<button class="badge badge-pill" :class="{ 'badge-primary': isConfidential, 'badge-secondary': !isConfidential }" @click="toggleConfidential">
{{ $t('course.confidential') }}
</button>
</div>
</template>
<script>
import { mapState } from 'vuex';
export default {
name: "ToggleFlags",
computed: {
...mapState({
intensity: state => state.accompanyingCourse.intensity,
emergency: state => state.accompanyingCourse.emergency,
confidential: state => state.accompanyingCourse.confidential,
}),
isRegular() {
return (this.intensity === 'regular') ? true : false;
},
isEmergency() {
return (this.emergency) ? true : false;
},
isConfidential() {
return (this.confidential) ? true : false;
}
},
methods: {
toggleIntensity() {
let value;
switch (this.intensity) {
case "occasional":
value = "regular";
break;
case "regular":
value = "occasional";
break;
default:
//temporaire (modif backend)
value = "occasional";
}
this.$store.dispatch('toggleIntensity', value);
},
toggleEmergency() {
this.$store.dispatch('toggleEmergency', (!this.isEmergency));
},
toggleConfidential() {
this.$store.dispatch('toggleConfidential', (!this.isConfidential));
}
}
}
</script>
<style lang="scss" scoped>
a.flag-toggle {
color: white;
padding: 0 10px;
cursor: pointer;
&:hover {
color: white;
text-decoration: underline;
border-radius: 20px;
}
i {
margin: auto 0.4em;
}
}
button.badge {
margin-left: 0.8em;
&.badge-secondary {
opacity: 0.5;
&:hover {
opacity: 0.7;
}
}
}
</style>

View File

@ -0,0 +1,99 @@
<template>
<div class="vue-component">
<h2><a name="section-60"></a>{{ $t('comment.title') }}</h2>
<!--div class="error flash_message" v-if="errors.length > 0">
{{ errors[0] }}
TODO fix errors flashbag for app component
</div-->
<div>
<form @submit.prevent="submitform">
<label for="content">{{ $t('comment.label') }}</label>
<div v-if="initialComment">
{{ $t('comment.created_by', [
initialComment.creator.text,
$d(initialComment.createdAt.datetime, 'long')
]) }}
</div>
<textarea
name="content"
v-bind:placeholder="$t('comment.content')"
rows="8"
cols="80"
ckeditor="ckeditor"
v-model="content">
</textarea>
<ul class="record_actions">
<li>
<button type="submit" class="sc-button bt-save">{{ $t('action.save') }}</button>
</li>
<li v-if="initialComment !== null">
<a class="sc-button bt-delete"
@click="removeComment">
{{ $t('action.delete') }}
</a>
</li>
</ul>
</form>
</div>
</div>
</template>
<script>
export default {
name: "Comment",
data() {
return {
formdata: {
type: "accompanying_period_comment",
content: ''
}
}
},
computed: {
initialComment() {
return this.$store.state.accompanyingCourse.initialComment;
},
content: {
set(value) {
this.formdata.content = value;
},
get() {
return (this.initialComment)? this.initialComment.content : null;
}
},
errors() {
return this.$store.state.errorMsg;
}
},
methods: {
submitform() {
console.log('submit');
this.$store.dispatch('postFirstComment', this.formdata);
},
removeComment() {
console.log('remove');
this.$store.dispatch('postFirstComment', null);
}
}
}
/*
* TODO
* - [x] delete button in ul record_actions, but not in form
* - [ ] display updatedAt => initialComment fetch PATCH content changes MUST NOT change object id !!
* - [ ] ckeditor integration
*/
</script>
<style lang="scss" scoped>
div.vue-component > div {
//margin: 1em;
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<div class="vue-component">
<h2><a name="section-70"></a>
{{ $t('confirm.title') }}
</h2>
<div>
<p>
{{ $t('confirm.text_draft') }}
<span class="badge badge-secondary">{{ $t('course.step.draft') }}</span>
</p>
<p>
{{ $t('confirm.text_active') }}
<span class="badge badge-primary">{{ $t('course.step.active') }}</span>
</p>
<ul class="record_actions">
<li>
<button class="sc-button bt-save" @click="modal.showModal = true">
{{ $t('confirm.ok') }}
</button>
</li>
</ul>
</div>
<teleport to="body">
<modal v-if="modal.showModal" :modalDialogClass="modal.modalDialogClass" @close="modal.showModal = false">
<template v-slot:header>
<h2 class="modal-title">{{ $t('confirm.sure') }}</h2>
</template>
<template v-slot:body>
<p>{{ $t('confirm.sure_description') }}</p>
</template>
<template v-slot:footer>
<button class="sc-button red" @click="confirmCourse">
{{ $t('confirm.ok') }}
</button>
</template>
</modal>
</teleport>
</div>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal';
export default {
name: "Confirm",
components: {
Modal,
},
data() {
return {
modal: {
showModal: false,
modalDialogClass: "modal-dialog-centered modal-md"
}
}
},
computed: {
accompanyingCourse() {
return this.$store.state.accompanyingCourse
}
},
methods: {
confirmCourse() {
console.log('@@ CLICK confirmCourse');
this.$store.dispatch('confirmAccompanyingCourse');
console.log('confirm last');
}
}
}
</script>
<style lang="scss" scoped>
div.vue-component > div {
//margin: 1em;
}
</style>

View File

@ -1,8 +1,12 @@
<template>
<div class="vue-component">
<h3>{{ $t('persons_associated.title')}}</h3>
<label>{{ $tc('persons_associated.counter', counter) }}</label>
<table class="rounded">
<h2><a name="section-10"></a>{{ $t('persons_associated.title')}}</h2>
<div>
<label>{{ $tc('persons_associated.counter', counter) }}</label>
</div>
<table class="rounded" v-if="participations.length > 0">
<thead>
<tr>
<th class="chill-orange">{{ $t('persons_associated.firstname') }}</th>
@ -12,55 +16,76 @@
<th class="chill-orange">{{ $t('action.actions') }}</th>
</tr>
</thead>
<tbody>
<person-item
v-for="participation in participations"
v-bind:participation="participation"
v-bind:key="participation.id"
<tbody>
<person-item
v-for="participation in participations"
v-bind:participation="participation"
v-bind:key="participation.id"
@remove="removeParticipation"
@close="closeParticipation">
</person-item>
</tbody>
</table>
<add-persons></add-persons>
<ul class="record_actions">
<!--li>
<button class="sc-button orange" @click="savePersons">
{{ $t('action.save') }}
</button>
</li-->
</ul>
<div>
<add-persons
buttonTitle="persons_associated.add_persons"
modalTitle="add_persons.title"
v-bind:key="addPersons.key"
v-bind:options="addPersons.options"
@addNewPersons="addNewPersons"
ref="addPersons"> <!-- to cast child method -->
</add-persons>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import PersonItem from "./PersonItem.vue"
import PersonItem from "./PersonsAssociated/PersonItem.vue"
import AddPersons from 'ChillPersonAssets/vuejs/_components/AddPersons.vue'
export default {
name: 'PersonsAssociated',
components: {
components: {
PersonItem,
AddPersons
},
data() {
return {
addPersons: {
key: 'persons_associated',
options: {
type: ['person'],
priority: null,
uniq: false,
}
}
}
},
computed: mapState({
participations: state => state.accompanyingCourse.participations,
counter: state => state.accompanyingCourse.participations.length
}),
methods: {
removeParticipation(item) {
this.$store.dispatch('removeParticipation', item)
console.log('@@ CLICK remove participation: item', item);
this.$store.dispatch('removeParticipation', item);
},
closeParticipation(item) {
console.log('@@ CLICK close participation: item', item);
this.$store.dispatch('closeParticipation', item)
this.$store.dispatch('closeParticipation', item);
},
/*
savePersons() {
console.log('[wip] saving persons');
addNewPersons({ selected, modal }) {
console.log('@@@ CLICK button addNewPersons', selected);
selected.forEach(function(item) {
this.$store.dispatch('addParticipation', item);
}, this
);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
}
*/
}
}
</script>

View File

@ -29,10 +29,12 @@
</button>
</li-->
<li>
<button class="sc-button bt-remove"
<button v-if="!participation.endDate"
class="sc-button bt-remove"
:title="$t('action.remove')"
@click.prevent="$emit('close', participation)">
</button>
<button v-else class="sc-button bt-remove disabled"></button>
</li>
</ul>
</td>

View File

@ -0,0 +1,77 @@
<template>
<div class="vue-component">
<h2><a name="section-40"></a>{{ $t('referrer.title') }}</h2>
<div class="my-4">
<label for="" class="">
{{ $t('referrer.label') }}
</label>
<VueMultiselect
track-by="id"
label="text"
:multiple="false"
:searchable="true"
:placeholder="$t('referrer.placeholder')"
@update:model-value="updateReferrer"
:model-value="value"
:options="options">
</VueMultiselect>
<ul class="record_actions">
<li>
<button
class="sc-button bt-create"
type="button"
name="button"
@click="assignMe">
{{ $t('referrer.assign_me') }}
</button>
</li>
</ul>
</div>
</div>
</template>
<script>
import VueMultiselect from 'vue-multiselect';
//import { getUsers } from '../api';
import { mapState } from 'vuex';
export default {
name: "Referrer",
components: { VueMultiselect },
data() {
return {
options: []
}
},
computed: {
...mapState({
value: state => state.accompanyingCourse.user,
}),
},
mounted() {
this.getOptions();
},
methods: {
getOptions() {
//getUsers().then(response => new Promise((resolve, reject) => {
// console.log(response);
// resolve();
//})).catch(er => this.$store.commit('catchError'), error));
},
updateReferrer(value) {
//this.$store.dispatch('updateReferrer', this.transformValue(value));
},
transformValue(value) {
let payload = value;
return { payload, body, method };
},
assignMe() {
console.log('assign me');
}
}
}
</script>

View File

@ -1,85 +1,121 @@
<template>
<div class="vue-component">
<h3>{{ $t('requestor.title') }}</h3>
{{ accompanyingCourse.id }}
{{ accompanyingCourse.remark }}<br><br>
<!-- TESTS AREA -->
<ul class="record_actions">
<li>
<button class="sc-button bt-create" @click="modal1.showModal = true">
{{ $t('action.show_modal') }}
</button>
</li>
<li>
<button class="sc-button bt-create" @click="modal2.showModal = true">
Ouvrir une seconde modale
</button>
</li>
</ul>
<h2><a name="section-20"></a>{{ $t('requestor.title') }}</h2>
<teleport to="body">
<modal v-if="modal1.showModal" :modalDialogClass="modal1.modalDialogClass" @close="modal1.showModal = false">
<template v-slot:header>
<h3 class="modal-title">Le titre de ma modale</h3>
</template>
<template v-slot:body>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar porta, enim ex posuere lacus, in pulvinar lectus magna in odio. Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget viverra. Morbi dictum placerat suscipit. </p>
<p>Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id enim ut sem pretium interdum consectetur eu quam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam posuere erat eget augue finibus luctus. Maecenas auctor, tortor non luctus ultrices, neque neque porttitor ex, nec lacinia lorem ligula et elit. Sed tempor nulla vitae lorem sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit dignissim.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar porta, enim ex posuere lacus, in pulvinar lectus magna in odio. Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget viverra. Morbi dictum placerat suscipit. </p>
<p>Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id enim ut sem pretium interdum consectetur eu quam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam posuere erat eget augue finibus luctus. Maecenas auctor, tortor non luctus ultrices, neque neque porttitor ex, nec lacinia lorem ligula et elit. Sed tempor nulla vitae lorem sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit dignissim.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar porta, enim ex posuere lacus, in pulvinar lectus magna in odio. Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget viverra. Morbi dictum placerat suscipit. </p>
<p>Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id enim ut sem pretium interdum consectetur eu quam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam posuere erat eget augue finibus luctus. Maecenas auctor, tortor non luctus ultrices, neque neque porttitor ex, nec lacinia lorem ligula et elit. Sed tempor nulla vitae lorem sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit dignissim.</p>
</template>
<template v-slot:footer>
<button class="sc-button green" @click="modal1.showModal = false; modal2.showModal = true">
{{ $t('action.next')}}</button>
</template>
</modal>
</teleport>
<teleport to="body">
<modal v-if="modal2.showModal" :modalDialogClass="modal2.modalDialogClass" @close="modal2.showModal = false">
<template v-slot:header>
<h3 class="modal-title">Une autre modale</h3>
</template>
<template v-slot:body>
<p>modal 2</p>
</template>
<template v-slot:footer>
<button class="sc-button green" @click="modal2.showModal = false">
{{ $t('action.save')}}</button>
</template>
</modal>
</teleport>
<!-- END TESTS -->
<div v-if="accompanyingCourse.requestor">
<label>
<input type="checkbox" v-model="isAnonymous" :value="value" />
{{ $t('requestor.is_anonymous') }}
</label>
<dt>{{ $t('requestor.type') }}</dt>
<dd>{{ accompanyingCourse.requestor.type }}</dd>
<dt>{{ $t('requestor.text') }}</dt>
<dd>{{ accompanyingCourse.requestor.text }}</dd>
<dt>{{ $t('requestor.is_anonymous') }}</dt>
<dd>{{ accompanyingCourse.requestorAnonymous }}</dd>
<div v-if="accompanyingCourse.requestor.type === 'person'">
<dt>{{ $t('requestor.person_id') }}</dt>
<dd>{{ accompanyingCourse.requestor.person_id }}</dd>
<dt>{{ $t('requestor.birthdate') }}</dt>
<dd>{{ $d(accompanyingCourse.requestor.birthdate.datetime, 'short') }}</dd>
<dt>{{ $t('requestor.center') }}</dt>
<dd>{{ accompanyingCourse.requestor.center.name }}</dd>
<dt>{{ $t('requestor.firstName') }}</dt>
<dd>{{ accompanyingCourse.requestor.firstName }}</dd>
<dt>{{ $t('requestor.lastName') }}</dt>
<dd>{{ accompanyingCourse.requestor.lastName }}</dd>
<dt>{{ $t('requestor.phonenumber') }}</dt>
<dd>{{ accompanyingCourse.requestor.phonenumber }}</dd>
<dt>{{ $t('requestor.mobilenumber') }}</dt>
<dd>{{ accompanyingCourse.requestor.mobilenumber }}</dd>
<dt>{{ $t('requestor.altNames') }}</dt>
<dd>{{ accompanyingCourse.requestor.altNames }}</dd>
</div>
<div v-if="accompanyingCourse.requestor.type === 'thirdparty'">
<dt>{{ $t('requestor.person_id') }}</dt>
<dd>{{ accompanyingCourse.requestor.thirdparty_id }}</dd>
<dt>{{ $t('requestor.address') }}</dt>
<dd>{{ accompanyingCourse.requestor.address.text }}</dd>
<dt>{{ $t('requestor.location') }}</dt>
<dd>{{ accompanyingCourse.requestor.address.postcode.name }}</dd>
</div>
<ul class="record_actions">
<li>
<button class="sc-button bt-remove"
:title="$t('action.remove')"
@click="removeRequestor">
{{ $t('action.remove') }}
</button>
</li>
</ul>
</div>
<div v-else>
<label>{{ $t('requestor.counter') }}</label>
</div>
<div>
<add-persons v-if="accompanyingCourse.requestor === null"
buttonTitle="requestor.add_requestor"
modalTitle="requestor.add_requestor"
v-bind:key="addPersons.key"
v-bind:options="addPersons.options"
@addNewPersons="addNewPersons"
ref="addPersons"> <!-- to cast child method -->
</add-persons>
</div>
</div>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal'
import AddPersons from 'ChillPersonAssets/vuejs/_components/AddPersons.vue'
export default {
name: 'Requestor',
components: {
Modal,
AddPersons,
},
data() {
return {
modal1: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl" // modal-lg modal-md modal-sm
},
modal2: {
showModal: false,
modalDialogClass: "modal-dialog-centered modal-sm" // modal-lg modal-md modal-sm
addPersons: {
key: 'requestor',
options: {
type: ['person', 'thirdparty'],
priority: null,
uniq: true,
}
}
}
},
computed: {
accompanyingCourse() {
return this.$store.state.accompanyingCourse
},
isAnonymous: {
set(value) {
console.log('requestorIsAnonymous value',value);
this.$store.dispatch('requestorIsAnonymous', value);
},
get() {
return this.$store.state.accompanyingCourse.requestorAnonymous;
}
}
},
methods: {
removeRequestor() {
console.log('@@ CLICK remove requestor: item');
this.$store.dispatch('removeRequestor');
},
addNewPersons({ selected, modal }) {
console.log('@@@ CLICK button addNewPersons', selected);
this.$store.dispatch('addRequestor', selected.shift());
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
}
}
}

View File

@ -0,0 +1,85 @@
<template>
<div class="vue-component">
<h2><a name="section-50"></a>{{ $t('resources.title')}}</h2>
<div>
<label>{{ $tc('resources.counter', counter) }}</label>
</div>
<table class="rounded" v-if="resources.length > 0">
<thead>
<tr>
<th class="chill-orange">{{ $t('resources.text') }}</th>
<th class="chill-orange">{{ $t('resources.description') }}</th>
<th class="chill-orange">{{ $t('action.actions') }}</th>
</tr>
</thead>
<tbody>
<resource-item
v-for="resource in resources"
v-bind:resource="resource"
v-bind:key="resource.id"
@remove="removeResource">
</resource-item>
</tbody>
</table>
<div>
<add-persons
buttonTitle="resources.add_resources"
modalTitle="resources.add_resources"
v-bind:key="addPersons.key"
v-bind:options="addPersons.options"
@addNewPersons="addNewPersons"
ref="addPersons"> <!-- to cast child method -->
</add-persons>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex';
import AddPersons from 'ChillPersonAssets/vuejs/_components/AddPersons.vue';
import ResourceItem from './Resources/ResourceItem.vue';
export default {
name: 'Resources',
components: {
AddPersons,
ResourceItem
},
data() {
return {
addPersons: {
key: 'resources',
options: {
type: ['person', 'thirdparty'],
priority: null,
uniq: false,
}
}
}
},
computed: mapState({
resources: state => state.accompanyingCourse.resources,
counter: state => state.accompanyingCourse.resources.length
}),
methods: {
removeResource(item) {
console.log('@@ CLICK remove resource: item', item);
this.$store.dispatch('removeResource', item);
},
addNewPersons({ selected, modal }) {
console.log('@@@ CLICK button addNewPersons', selected);
selected.forEach(function(item) {
this.$store.dispatch('addResource', item);
}, this
);
this.$refs.addPersons.resetSearch(); // to cast child method
modal.showModal = false;
}
}
}
</script>

View File

@ -0,0 +1,68 @@
<template>
<tr>
<td>
<span class="badge badge-pill badge-secondary"
v-bind:title="resource.resource.id">
<span v-if="resource.resource.type === 'person'" >{{ $t('item.type_person') }}</span>
<span v-if="resource.resource.type === 'thirdparty'" >{{ $t('item.type_thirdparty') }}</span>
</span>
{{ resource.resource.text }}
</td>
<td v-if="resource.resource.type === 'person'">
{{ $tc('person.born') }}{{ $d(resource.resource.birthdate.datetime, 'short') }}
</td>
<td v-else-if="resource.resource.type === 'thirdparty'">
{{ resource.resource.address.text }}<br>
{{ resource.resource.address.postcode.name }}
</td>
<td>
<ul class="record_actions">
<li>
<a class="sc-button bt-show" target="_blank"
:href="url.show"
:title="$t('action.show')">
</a>
</li>
<li>
<a class="sc-button bt-update" target="_blank"
:href="url.edit"
:title="$t('action.edit')">
</a>
</li>
<li>
<button
class="sc-button bt-remove"
:title="$t('action.remove')"
@click.prevent="$emit('remove', resource)">
</button>
</li>
</ul>
</td>
</tr>
</template>
<script>
export default {
name: 'ResourceItem',
props: ['resource'],
emits: ['remove'],
computed: {
type() {
return this.resource.resource.type;
},
url() {
return (this.type === 'person') ? {
show: `/fr/person/${this.resource.resource.id}/general`,
edit: `/fr/person/${this.resource.resource.id}/general/edit`
} : {
show: `/fr/thirdparty/thirdparty/${this.resource.resource.id}/show`,
edit: `/fr/thirdparty/thirdparty/${this.resource.resource.id}/update`
}
}
}
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<div class="vue-component">
<h2><a name="section-30"></a>{{ $t('social_issue.title') }}</h2>
<div class="my-4">
<!--label for="field">{{ $t('social_issue.label') }}</label
-->
<VueMultiselect
name="field"
:close-on-select="false"
:allow-empty="true"
:show-labels="false"
track-by="id"
label="text"
:multiple="true"
:searchable="false"
:placeholder="$t('social_issue.label')"
@update:model-value="updateSocialIssues"
:model-value="value"
:options="options">
</VueMultiselect>
</div>
</div>
</template>
<script>
import VueMultiselect from 'vue-multiselect';
import { getSocialIssues } from '../api';
import { mapState } from 'vuex';
export default {
name: "SocialIssue",
components: { VueMultiselect },
data() {
return {
options: []
}
},
computed: {
...mapState({
value: state => state.accompanyingCourse.socialIssues,
}),
},
mounted() {
this.getOptions();
},
methods: {
getOptions() {
getSocialIssues().then(response => new Promise((resolve, reject) => {
//console.log('get socialIssues', response.results);
this.options = response.results;
resolve();
})).catch(error => this.$store.commit('catchError', error));
},
updateSocialIssues(value) {
this.$store.dispatch('updateSocialIssues', this.transformValue(value));
},
transformValue(updated) {
let stored = this.value;
let added = updated.filter(x => stored.indexOf(x) === -1).shift();
let removed = stored.filter(x => updated.indexOf(x) === -1).shift();
let method = (typeof removed === 'undefined') ? 'POST' : 'DELETE';
let changed = (typeof removed === 'undefined') ? added : removed;
let body = { type: "social_issue", id: changed.id };
//console.log('body', body);
//console.log('@@@', method, changed.text);
let payload = updated;
return { payload, body, method };
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
<style lang="scss">
span.multiselect__tag {
background: #e2793d;
}
</style>

View File

@ -0,0 +1,191 @@
<template>
<teleport to="#content_conainter .container.content .container">
<div id="navmap">
<nav>
<a class="top" href="#top">
<i class="fa fa-fw fa-square"></i>
<span>{{ $t('nav.top') }}</span>
</a>
<item
v-for="item of items"
:key="item.key"
:item="item"
:step="step">
</item>
</nav>
</div>
</teleport>
</template>
<script>
import Item from './StickyNav/Item.vue';
export default {
name: "StickyNav",
components: {
Item
},
data() {
return {
header: document.querySelector("header.navigation.container"),
bannerName: document.querySelector("#header-accompanying_course-name"),
bannerDetails: document.querySelector("#header-accompanying_course-details"),
container: null,
heightSum: null,
stickyNav: null,
limit: 25,
anchors: null,
items: [],
}
},
computed: {
accompanyingCourse() {
return this.$store.state.accompanyingCourse;
},
step() {
return this.accompanyingCourse.step;
},
top() {
return parseInt(window.getComputedStyle(this.stickyNav).getPropertyValue('top').slice(0, -2));
},
},
mounted() {
this.ready();
window.addEventListener('scroll', this.handleScroll);
},
destroyed() {
window.removeEventListener('scroll', this.handleScroll);
},
methods: {
ready() {
// load datas DOM when mounted ready
this.container = document.querySelector("#content_conainter .container.content .container");
this.stickyNav = document.querySelector('#navmap');
this.anchors = document.querySelectorAll("h2 a[name^='section']");
this.initItemsMap();
// TODO resizeObserver not supports IE !
// Listen when elements change size, then recalculate heightSum and initItemsMap
const resizeObserver = new ResizeObserver(entries => {
this.refreshPos();
});
resizeObserver.observe(this.header);
resizeObserver.observe(this.bannerName);
resizeObserver.observe(this.bannerDetails);
resizeObserver.observe(this.container);
},
initItemsMap() {
this.anchors.forEach(anchor => {
this.items.push({
pos: null,
active: false,
key: parseInt(anchor.name.slice(8).slice(0, -1)),
name: '#' + anchor.name
})
});
},
refreshPos() {
//console.log('refreshPos');
this.heightSum = this.header.offsetHeight + this.bannerName.offsetHeight + this.bannerDetails.offsetHeight;
this.anchors.forEach((anchor, i) => {
this.items[i].pos = this.findPos(anchor)['y'];
});
},
findPos(element) {
let posX = 0, posY = 0;
do {
posX += element.offsetLeft;
posY += element.offsetTop;
element = element.offsetParent;
}
while( element != null );
let pos = [];
pos['x'] = posX;
pos['y'] = posY;
return pos;
},
handleScroll(event) {
let pos = this.findPos(this.stickyNav);
let top = this.heightSum + this.top - window.scrollY;
//console.log(window.scrollY);
if (top > this.limit) {
this.stickyNav.style.position = 'absolute';
this.stickyNav.style.left = '-60px';
} else {
this.stickyNav.style.position = 'fixed';
this.stickyNav.style.left = pos['x'] + 'px';
}
this.switchActive();
},
switchActive() {
this.items.forEach((item, i) => {
let next = (this.items[i+1]) ? this.items[i+1].pos : '100000';
item.active =
(window.scrollY >= item.pos & window.scrollY < next) ? true : false;
}, this);
// last item never switch active because scroll reach bottom of page
if (document.body.scrollHeight == window.scrollY + window.innerHeight) {
this.items[this.items.length-1].active = true;
this.items[this.items.length-2].active = false;
} else {
this.items[this.items.length-1].active = false;
}
}
}
}
</script>
<style lang="scss">
div#navmap {
position: absolute;
top: 30px;
left: -60px; //-10%;
nav {
font-size: small;
a {
display: block;
box-sizing: border-box;
margin-bottom: -3px;
color: #71859669;
&.top {
color: #718596;
}
span {
display: none;
}
&:hover,
&.active {
span {
display: inline;
padding-left: 8px;
}
}
&:hover {
color: #718596b5;
}
&.active {
color: #e2793d; //orange
//color: #eec84a; //jaune
}
}
}
}
@media only screen and (max-width: 768px) {
div.sticky-section {
display: none;
}
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<a
v-if="item.key <= 5"
:href="item.name"
:class="{ 'active': isActive }"
>
<i class="fa fa-fw fa-square"></i>
<span>{{ item.key }}</span>
</a>
<a
v-else-if="step === 'DRAFT'"
:href="item.name"
:class="{ 'active': isActive }"
>
<i class="fa fa-fw fa-square"></i>
<span>{{ item.key }}</span>
</a>
</template>
<script>
export default {
name: "Item",
props: ['item', 'step'],
computed: {
isActive() {
return this.item.active;
}
}
}
</script>

View File

@ -0,0 +1,81 @@
<template>
<div class="vue-component">
<h2></a>Tests</h2>
<!-- Modal -->
<ul class="record_actions">
<li>
<button class="sc-button bt-create" @click="modal1.showModal = true">
{{ $t('action.show_modal') }}
</button>
</li>
<li>
<button class="sc-button bt-create" @click="modal2.showModal = true">
Ouvrir une seconde modale
</button>
</li>
</ul>
<teleport to="body">
<modal v-if="modal1.showModal" :modalDialogClass="modal1.modalDialogClass" @close="modal1.showModal = false">
<template v-slot:header>
<h2 class="modal-title">Le titre de ma modale</h2>
</template>
<template v-slot:body>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar porta, enim ex posuere lacus, in pulvinar lectus magna in odio. Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget viverra. Morbi dictum placerat suscipit. </p>
<p>Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id enim ut sem pretium interdum consectetur eu quam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam posuere erat eget augue finibus luctus. Maecenas auctor, tortor non luctus ultrices, neque neque porttitor ex, nec lacinia lorem ligula et elit. Sed tempor nulla vitae lorem sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit dignissim.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar porta, enim ex posuere lacus, in pulvinar lectus magna in odio. Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget viverra. Morbi dictum placerat suscipit. </p>
<p>Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id enim ut sem pretium interdum consectetur eu quam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam posuere erat eget augue finibus luctus. Maecenas auctor, tortor non luctus ultrices, neque neque porttitor ex, nec lacinia lorem ligula et elit. Sed tempor nulla vitae lorem sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit dignissim.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar porta, enim ex posuere lacus, in pulvinar lectus magna in odio. Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget viverra. Morbi dictum placerat suscipit. </p>
<p>Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id enim ut sem pretium interdum consectetur eu quam. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Etiam posuere erat eget augue finibus luctus. Maecenas auctor, tortor non luctus ultrices, neque neque porttitor ex, nec lacinia lorem ligula et elit. Sed tempor nulla vitae lorem sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit dignissim.</p>
</template>
<template v-slot:footer>
<button class="sc-button green" @click="modal1.showModal = false; modal2.showModal = true">
{{ $t('action.next')}}</button>
</template>
</modal>
</teleport>
<teleport to="body">
<modal v-if="modal2.showModal" :modalDialogClass="modal2.modalDialogClass" @close="modal2.showModal = false">
<template v-slot:header>
<h2 class="modal-title">Une autre modale</h2>
</template>
<template v-slot:body>
<p>modal 2</p>
</template>
<template v-slot:footer>
<button class="sc-button green" @click="modal2.showModal = false">
{{ $t('action.save')}}</button>
</template>
</modal>
</teleport>
<!-- END Modal -->
</div>
</template>
<script>
import Modal from 'ChillMainAssets/vuejs/_components/Modal'
export default {
name: 'Test',
components: {
Modal,
},
data() {
return {
modal1: {
showModal: false,
modalDialogClass: "modal-dialog-scrollable modal-xl" // modal-lg modal-md modal-sm
},
modal2: {
showModal: false,
modalDialogClass: "modal-dialog-centered modal-sm" // modal-lg modal-md modal-sm
}
}
},
computed: {
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More