From 4fd6d38187da0d5643f74b6c7c9d351e3d74ddd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 11 Jun 2021 15:53:32 +0200 Subject: [PATCH 01/18] create Util for computing intersection --- .../Tests/Util/DateRangeCoveringTest.php | 216 +++++++++++++++++ .../Util/DateRangeCovering.php | 224 ++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php create mode 100644 src/Bundle/ChillMainBundle/Util/DateRangeCovering.php diff --git a/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php b/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php new file mode 100644 index 000000000..c06b4a4f4 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Util/DateRangeCoveringTest.php @@ -0,0 +1,216 @@ +add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-06-01'), 3) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + $this->assertEquals( + new \DateTime('2010-06-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-12-01'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertNotContains(3, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover1WithTwoIntersections() + { + $cover = new DateRangeCovering(1, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-12-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2011-06-01'), 2) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-12-01'), 3) + ->add(new \DateTime('2019-06-01'), new \DateTime('2020-06-01'), 4) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(2, $cover->getIntersections()); + + $intersections = $cover->getIntersections(); + + // sort the intersections to compare them in expected order + \usort($intersections, function($a, $b) { + if ($a[0] === $b[0]) { + return $a[1] <=> $b[1]; + } + + return $a[0] <=> $b[0]; + }); + + // first intersection + $this->assertEquals( + new \DateTime('2010-06-01'), + $intersections[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-12-01'), + $intersections[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($intersections[0][2]); + $this->assertContains(1, $intersections[0][2]); + $this->assertContains(2, $intersections[0][2]); + $this->assertNotContains(3, $intersections[0][2]); + $this->assertNotContains(4, $intersections[0][2]); + + // second intersection + $this->assertEquals( + new \DateTime('2019-06-01'), + $intersections[1][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2019-12-01'), + $intersections[1][1], + "assert date end are the intersection" + ); + $this->assertIsArray($intersections[1][2]); + $this->assertContains(3, $intersections[1][2]); + $this->assertContains(4, $intersections[1][2]); + $this->assertNotContains(1, $intersections[1][2]); + $this->assertNotContains(2, $intersections[1][2]); + } + + public function testCoveringWithMinCover2() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5) + ->compute() + ; + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-06-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-01'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertContains(3, $cover->getIntersections()[0][2]); + $this->assertNotContains(4, $cover->getIntersections()[0][2]); + $this->assertNotContains(5, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover2AndThreePeriodsCovering() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), 4) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 5) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 6) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-04-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-15'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + $this->assertContains(1, $cover->getIntersections()[0][2]); + $this->assertContains(2, $cover->getIntersections()[0][2]); + $this->assertContains(3, $cover->getIntersections()[0][2]); + $this->assertContains(4, $cover->getIntersections()[0][2]); + $this->assertNotContains(5, $cover->getIntersections()[0][2]); + $this->assertNotContains(6, $cover->getIntersections()[0][2]); + } + + public function testCoveringWithMinCover2AndThreePeriodsCoveringWithNullMetadata() + { + $cover = new DateRangeCovering(2, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) + ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) + ->compute() + ; + + $this->assertTrue($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(1, $cover->getIntersections()); + + $this->assertEquals( + new \DateTime('2010-04-01'), + $cover->getIntersections()[0][0], + "assert date start are the intersection" + ); + $this->assertEquals( + new \DateTime('2010-09-15'), + $cover->getIntersections()[0][1], + "assert date end are the intersection" + ); + $this->assertIsArray($cover->getIntersections()[0][2]); + } + + + public function testCoveringWithMinCover3Absent() + { + $cover = new DateRangeCovering(3, new \DateTimeZone('Europe/Brussels')); + $cover + ->add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), 1) + ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), 2) + ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), 3) + ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), 4) + ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), 5) + ->compute() + ; + $this->assertFalse($cover->hasIntersections()); + $this->assertIsArray($cover->getIntersections()); + $this->assertCount(0, $cover->getIntersections()); + } +} diff --git a/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php b/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php new file mode 100644 index 000000000..2b168f18b --- /dev/null +++ b/src/Bundle/ChillMainBundle/Util/DateRangeCovering.php @@ -0,0 +1,224 @@ +add(new \DateTime('2010-01-01'), new \DateTime('2010-10-01'), null) + * ->add(new \DateTime('2010-06-01'), new \DateTime('2010-09-01'), null) + * ->add(new \DateTime('2010-04-01'), new \DateTime('2010-12-01'), null) + * ->add(new \DateTime('2009-01-01'), new \DateTime('2010-09-15'), null) + * ->add(new \DateTime('2019-01-01'), new \DateTime('2019-10-01'), null) + * ->add(new \DateTime('2019-06-01'), new \DateTime('2019-09-01'), null) + * ->compute() + * ; + * $cover->getIntersections(); + * ``` + */ +class DateRangeCovering +{ + private bool $computed = false; + + private array $intersections = []; + + private array $intervals = []; + + private int $minCover; + + private int $uniqueKeyCounter = 0; + + private array $metadatas = []; + + private array $sequence = []; + + private \DateTimeZone $tz; + + /** + * @param int $minCover the minimum of covering required + */ + public function __construct(int $minCover, \DateTimeZone $tz) + { + if ($minCover < 0) { + throw new \LogicException("argument minCover cannot be lower than 0"); + } + + $this->minCover = $minCover; + $this->tz = $tz; + } + + public function add(\DateTimeInterface $start, \DateTimeInterface $end = null, $metadata = null): self + { + if ($this->computed) { + throw new \LogicException("You cannot add intervals to a computed instance"); + } + + $k = $this->uniqueKeyCounter++; + $this->intervals[$k] = [$start, $end]; + $this->metadatas[$k] = $metadata; + + $this->addToSequence($start->getTimestamp(), $k, null); + $this->addToSequence( + NULL === $end ? PHP_INT_MAX : $end->getTimestamp(), null, $k + ); + + return $this; + } + + private function addToSequence($timestamp, int $start = null, int $end = null) + { + if (!\array_key_exists($timestamp, $this->sequence)) { + $this->sequence[$timestamp] = [ 's' => [], 'e' => [] ]; + } + + if (NULL !== $start) { + $this->sequence[$timestamp]['s'][] = $start; + } + if (NULL !== $end) { + $this->sequence[$timestamp]['e'][] = $end; + } + } + + public function compute(): self + { + \ksort($this->sequence); + + $currentPeriod = []; + $currents = []; + $isOpen = false; + $overs = []; + + foreach ($this->sequence as $ts => $moves) { + $currents = \array_merge($currents, $moves['s']); + $currents = \array_diff($currents, $moves['e']); + + if (count($currents) > $this->minCover && !$isOpen) { + $currentPeriod[0] = $ts; + $currentPeriod[2] = $currents; + $isOpen = true; + } elseif ($isOpen && count($currents) <= $this->minCover) { + $currentPeriod[1] = $ts; + $overs[] = $currentPeriod; + $currentPeriod = []; + $isOpen = false; + } elseif ($isOpen) { + $currentPeriod[2] = \array_merge($currentPeriod[2], $currents); + } + } + + // process metadata + foreach ($overs as list($start, $end, $metadata)) { + $this->intersections[] = [ + (new \DateTimeImmutable('@'.$start)) + ->setTimezone($this->tz), + $end === PHP_INT_MAX ? null : (new \DateTimeImmutable('@'.$end)) + ->setTimezone($this->tz), + \array_values( + \array_intersect_key( + $this->metadatas, + \array_flip(\array_unique($metadata)) + ) + ) + ]; + } + + $this->computed = true; + + return $this; + } + + private function process(array $intersections): array + { + $result = []; + $starts = []; + $ends = []; + $metadatas = []; + + while (null !== ($current = \array_pop($intersections))) { + list($cStart, $cEnd, $cMetadata) = $current; + $n = count($cMetadata); + + foreach ($intersections as list($iStart, $iEnd, $iMetadata)) { + $start = max($cStart, $iStart); + $end = min($cEnd, $iEnd); + + if ($start <= $end) { + if (FALSE !== ($key = \array_search($start, $starts))) { + if ($ends[$key] === $end) { + $metadatas[$key] = \array_unique(\array_merge($metadatas[$key], $iMetadata)); + continue; + } + } + $starts[] = $start; + $ends[] = $end; + $metadatas[] = \array_unique(\array_merge($iMetadata, $cMetadata)); + } + } + } + + // recompose results + foreach ($starts as $k => $start) { + $result[] = [$start, $ends[$k], \array_unique($metadatas[$k])]; + } + + return $result; + } + + private function addToIntersections(array $intersections, array $intersection) + { + $foundExisting = false; + list($nStart, $nEnd, $nMetadata) = $intersection; + + \array_walk($intersections, + function(&$i, $key) use ($nStart, $nEnd, $nMetadata, $foundExisting) { + if ($foundExisting) { + return; + }; + if ($i[0] === $nStart && $i[1] === $nEnd) { + $foundExisting = true; + $i[2] = \array_merge($i[2], $nMetadata); + } + } + ); + + if (!$foundExisting) { + $intersections[] = $intersection; + } + + return $intersections; + } + + public function hasIntersections(): bool + { + if (!$this->computed) { + throw new \LogicException(sprintf("You cannot call the method %s before ". + "'process'", __METHOD)); + } + + return count($this->intersections) > 0; + } + + public function getIntersections(): array + { + if (!$this->computed) { + throw new \LogicException(sprintf("You cannot call the method %s before ". + "'process'", __METHOD)); + } + + return $this->intersections; + } + +} From ecc8b929ca656748f5a0cd0ee2643560b9702572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 11 Jun 2021 17:03:48 +0200 Subject: [PATCH 02/18] create max holder validator --- .../Entity/Household/Household.php | 12 +++ .../Household/MaxHolderValidatorTest.php | 76 +++++++++++++++++++ .../Constraints/Household/MaxHolder.php | 13 ++++ .../Household/MaxHolderValidator.php | 43 ++++++++++- 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src/Bundle/ChillPersonBundle/Tests/Validator/Household/MaxHolderValidatorTest.php create mode 100644 src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index db7ef2c2b..fb0c2b110 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -98,6 +98,18 @@ class Household return $this->members; } + public function getMembersHolder(): Collection + { + $criteria = new Criteria(); + $expr = Criteria::expr(); + + $criteria->where( + $expr->eq('holder', true) + ); + + return $this->getMembers()->matching($criteria); + } + public function getCurrentMembers(?\DateTimeImmutable $now = null): Collection { $criteria = new Criteria(); diff --git a/src/Bundle/ChillPersonBundle/Tests/Validator/Household/MaxHolderValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/Validator/Household/MaxHolderValidatorTest.php new file mode 100644 index 000000000..9210926ce --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Validator/Household/MaxHolderValidatorTest.php @@ -0,0 +1,76 @@ +getConstraint(); + + $this->validator->validate($household, $constraint); + + $this->buildViolation('msg') + ->setParameters($parameters) + ->assertRaised() + ; + } + + protected function getConstraint() + { + return new MaxHolder([ + 'message' => 'msg', + 'messageInfinity' => 'msgInfinity' + ]); + } + + public function provideInvalidHousehold() + { + $household = new Household(); + $position = (new Position()) + ->setAllowHolder(true); + $household + ->addMember( + (new HouseholdMember()) + ->setHolder(true) + ->setStartDate(new \DateTimeImmutable('2010-01-01')) + ->setEndDate(new \DateTimeImmutable('2010-12-01')) + ) + ->addMember( + (new HouseholdMember()) + ->setHolder(true) + ->setStartDate(new \DateTimeImmutable('2010-06-01')) + ->setEndDate(new \DateTimeImmutable('2010-07-01')) + ) + ->addMember( + (new HouseholdMember()) + ->setHolder(true) + ->setStartDate(new \DateTimeImmutable('2010-01-01')) + ->setEndDate(new \DateTimeImmutable('2010-12-01')) + ) + ; + + yield [ + $household, + [ + 'start' => '01-06-2010', + 'end' => '01-07-2010' + ] + ]; + } + + protected function createValidator() + { + return new MaxHolderValidator(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php new file mode 100644 index 000000000..2c89b90cf --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php @@ -0,0 +1,13 @@ +getMembersHolder(); + + if ($holders->count() <= self::MAX_HOLDERS) { + return; + } + + $covers = new DateRangeCovering(self::MAX_HOLDERS, + $holders[0]->getStartDate()->getTimezone()); + + foreach ($holders as $key => $member) { + $covers->add($member->getStartDate(), $member->getEndDate(), $key); + } + + $covers->compute(); + + if ($covers->hasIntersections()) { + foreach ($covers->getIntersections() as list($start, $end, $ids)) { + $msg = $end === null ? $constraint->messageEndInfinite : + $constraint->message; + + $this->context->buildViolation($msg) + ->setParameters([ + 'start' => $start->format('d-m-Y'), // TODO fix when MessageParameter works with timezone + 'end' => $end === null ? null : $end->format('d-m-Y') + ]) + ->addViolation(); + } + } + + } } From 45dc8ed6613b820b79ecdc10d9dc5468791e7f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 11 Jun 2021 17:08:35 +0200 Subject: [PATCH 03/18] apply maxHolder constraint on houehold --- src/Bundle/ChillPersonBundle/Entity/Household/Household.php | 2 ++ .../Validator/Constraints/Household/MaxHolder.php | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index fb0c2b110..b9eba8e9c 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -9,6 +9,7 @@ use Doctrine\Common\Collections\Criteria; use Symfony\Component\Serializer\Annotation as Serializer; use Chill\MainBundle\Entity\Address; use Chill\PersonBundle\Entity\Household\HouseholdMember; +use Chill\PersonBundle\Validator\Constraints\Household\MaxHolder; /** * @ORM\Entity @@ -18,6 +19,7 @@ use Chill\PersonBundle\Entity\Household\HouseholdMember; * @Serializer\DiscriminatorMap(typeProperty="type", mapping={ * "household"=Household::class * }) + * @MasHolder() */ class Household { diff --git a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php index 2c89b90cf..6b56fe1ce 100644 --- a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php +++ b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php @@ -4,7 +4,9 @@ namespace Chill\PersonBundle\Validator\Constraints\Household; use Symfony\Component\Validator\Constraint; - +/** + * @Annotation + */ class MaxHolder extends Constraint { public $message = 'household.max_holder_overflowed'; From 807d3674fc92502329041c7b2c40dd8008628b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 11 Jun 2021 17:58:09 +0200 Subject: [PATCH 04/18] implements max holder and validation on UI --- .../Controller/HouseholdMemberController.php | 8 +++- .../Entity/Household/Household.php | 2 +- .../Household/MembersEditor.php | 5 ++- .../vuejs/HouseholdMembersEditor/api.js | 19 --------- .../components/Confirmation.vue | 8 +++- .../HouseholdMembersEditor/store/index.js | 40 ++++++++++++++----- .../Constraints/Household/MaxHolder.php | 4 ++ .../Household/MaxHolderValidator.php | 2 +- 8 files changed, 50 insertions(+), 38 deletions(-) diff --git a/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php b/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php index e78838e22..85e1c86b6 100644 --- a/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php +++ b/src/Bundle/ChillPersonBundle/Controller/HouseholdMemberController.php @@ -50,8 +50,12 @@ class HouseholdMemberController extends ApiController // TODO ACL // - // TODO validation - // + $errors = $editor->validate(); + + if (count($errors) > 0) { + return $this->json($errors, 422); + } + $em = $this->getDoctrine()->getManager(); // if new household, persist it diff --git a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php index 5b186dfc2..349083fcb 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Household/Household.php +++ b/src/Bundle/ChillPersonBundle/Entity/Household/Household.php @@ -19,7 +19,7 @@ use Chill\PersonBundle\Validator\Constraints\Household\MaxHolder; * @Serializer\DiscriminatorMap(typeProperty="type", mapping={ * "household"=Household::class * }) - * @MasHolder() + * @MaxHolder(groups={"memberships"}) */ class Household { diff --git a/src/Bundle/ChillPersonBundle/Household/MembersEditor.php b/src/Bundle/ChillPersonBundle/Household/MembersEditor.php index bc25c725f..37db3626f 100644 --- a/src/Bundle/ChillPersonBundle/Household/MembersEditor.php +++ b/src/Bundle/ChillPersonBundle/Household/MembersEditor.php @@ -21,7 +21,7 @@ class MembersEditor public function __construct(ValidatorInterface $validator, ?Household $household) { - $this->validation = $validator; + $this->validator = $validator; $this->household = $household; } @@ -92,7 +92,7 @@ class MembersEditor public function validate(): ConstraintViolationListInterface { - + return $this->validator->validate($this->getHousehold(), null, [ "memberships" ]); } public function getPersistable(): array @@ -100,6 +100,7 @@ class MembersEditor return $this->persistables; } + public function getHousehold(): ?Household { return $this->household; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js index 8f3d07d63..27318ba0a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/api.js @@ -16,32 +16,13 @@ const householdMove = (payload) => { if (response.ok) { return response.json(); } - throw Error('Error with testing move'); - }); -}; - -const householdMoveTest = (payload) => { - const url = `/api/1.0/person/household/members/move/test.json`; - return fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - .then(response => { if (response.status === 422) { return response.json(); } - if (response.ok) { - // return an empty array if ok - return new Promise((resolve, reject) => resolve({ violations: [] }) ); - } throw Error('Error with testing move'); }); }; export { householdMove, - householdMoveTest }; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue index cc97d7ef9..cc09f3c0b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue @@ -12,6 +12,9 @@
  • {{ $t(msg.m, msg.a) }}
  • +
  • + {{ msg }} +
    • @@ -34,8 +37,9 @@ export default { computed: { ...mapState({ warnings: (state) => state.warnings, - hasNoWarnings: (state) => state.warnings.length === 0, - hasWarnings: (state) => state.warnings.length > 0, + errors: (state) => state.errors, + hasNoWarnings: (state) => state.warnings.length === 0 && state.errors.length === 0, + hasWarnings: (state) => state.warnings.length > 0 || state.errors.length > 0, }), }, methods: { diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js index 32eb5b722..31ee5a7c3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js @@ -26,6 +26,7 @@ const store = createStore({ allowLeaveWithoutHousehold: window.household_members_editor_data.allowLeaveWithoutHousehold, forceLeaveWithoutHousehold: false, warnings: [], + errors: [] }, getters: { isHouseholdNew(state) { @@ -162,6 +163,9 @@ const store = createStore({ setWarnings(state, warnings) { state.warnings = warnings; }, + setErrors(state, errors) { + state.errors = errors; + }, }, actions: { addConcerned({ commit, dispatch }, person) { @@ -216,19 +220,33 @@ const store = createStore({ commit('setWarnings', warnings); }, - confirm({ getters, state }) { + confirm({ getters, state, commit }) { let payload = getters.buildPayload, + errors = [], person_id, - household_id; - householdMove(payload).then(household => { - if (household === null) { - person_id = getters.persons[0].id; - window.location.replace(`/fr/person/${person_id}/general`); - } else { - household_id = household.id; - // nothing to do anymore here, bye-bye ! - window.location.replace(`/fr/person/household/${household_id}/members`); - } + household_id, + error + ; + + householdMove(payload).then(household => { + if (household === null) { + person_id = getters.persons[0].id; + window.location.replace(`/fr/person/${person_id}/general`); + } else { + if (household.type === 'household') { + household_id = household.id; + // nothing to do anymore here, bye-bye ! + window.location.replace(`/fr/person/household/${household_id}/members`); + } else { + // we assume the answer was 422... + error = household; + for (let e in error.violations) { + errors.push('TODO'); + } + + commit('setErrors', errors); + } + } }); }, } diff --git a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php index 6b56fe1ce..62ddc2420 100644 --- a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php +++ b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolder.php @@ -12,4 +12,8 @@ class MaxHolder extends Constraint public $message = 'household.max_holder_overflowed'; public $messageInfinity = 'household.max_holder_overflowed_infinity'; + public function getTargets() + { + return self::CLASS_CONSTRAINT; + } } diff --git a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php index 3d945c757..6a8571c38 100644 --- a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php +++ b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php @@ -31,7 +31,7 @@ class MaxHolderValidator extends ConstraintValidator if ($covers->hasIntersections()) { foreach ($covers->getIntersections() as list($start, $end, $ids)) { - $msg = $end === null ? $constraint->messageEndInfinite : + $msg = $end === null ? $constraint->messageInfinity : $constraint->message; $this->context->buildViolation($msg) From 1df759e970f8ad6466ad77797085c542062ea6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 14 Jun 2021 10:19:18 +0200 Subject: [PATCH 05/18] fix JS error "chill is not defined" This happens since js is loaded using "defer" tag --- src/Bundle/ChillMainBundle/Resources/views/layout.html.twig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 0381233a4..8af7aa1e2 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -164,9 +164,11 @@ {{ encore_entry_script_tags('ckeditor5') }} {% endif %} {% block js%}{% endblock %} From c40019da8fb7ec9d755d1d14697ebb0f3cab0c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 14 Jun 2021 11:34:05 +0200 Subject: [PATCH 06/18] add alert for not-in-household and actions in page accompanyingCourse/summary * alert for people not in household for each accompanying course; * style for action in alert * form to members editor --- .../public/scss/_alert-first-child.scss | 10 +++++ .../public/scss/_alert-with-actions.scss | 29 ++++++++++++++ .../Resources/public/scss/chillmain.scss | 4 ++ .../Resources/views/layout.html.twig | 8 ++-- .../AccompanyingCourseController.php | 13 ++++++- .../HouseholdMembersEditor/store/index.js | 13 +++++-- .../views/AccompanyingCourse/index.html.twig | 39 +++++++++++++++++++ .../Household/MaxHolderValidator.php | 4 +- .../translations/messages+intl-icu.fr.yaml | 2 + .../translations/messages.fr.yml | 2 + .../translations/validators.fr.yml | 5 +++ 11 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss create mode 100644 src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss new file mode 100644 index 000000000..9a089c8a7 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-first-child.scss @@ -0,0 +1,10 @@ +/* + * when an alert is the first child of the page, with a banner, we do not want the alert to be merged with the banner + */ +div.container.content { + & > div { + div.alert:nth-child(2) { + margin-top: 1rem; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss new file mode 100644 index 000000000..0779ca7b3 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/_alert-with-actions.scss @@ -0,0 +1,29 @@ + +div.alert.alert-with-actions { + display: flex; + flex-direction: row; + + ul.record_actions { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + + li:nth-child(1n+2) { + margin-top: 0.5rem; + } + + li { + margin-right: 0; + } + } + + @media screen and (max-width: 1050px) { + flex-direction: column; + + ul.record_actions { + margin-top: 1rem; + text-align: center; + } + } +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss b/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss index 30c7f561d..bd53ad0cb 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/scss/chillmain.scss @@ -7,6 +7,10 @@ */ +@import 'alert-first-child'; +@import 'alert-with-actions'; + + /* [hack] /!\ Contourne le positionnement problématique du div#content_conainter suivant, * car sa position: relative le place au-dessus du bandeau et les liens sont incliquables */ div.subheader { diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 8af7aa1e2..13fe8de26 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -169,7 +169,7 @@ $('.select2').select2({allowClear: true}); chill.categoryLinkParentChildSelect(); }); - - {% block js%}{% endblock %} - - + + {% block js%}{% endblock %} + + diff --git a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php index e5b0cdda7..c33fc636e 100644 --- a/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php +++ b/src/Bundle/ChillPersonBundle/Controller/AccompanyingCourseController.php @@ -86,8 +86,19 @@ class AccompanyingCourseController extends Controller */ public function indexAction(AccompanyingPeriod $accompanyingCourse): Response { + // compute some warnings + // get persons without household + $withoutHousehold = []; + foreach ($accompanyingCourse->getParticipations() as + $p) { + if (FALSE === $p->getPerson()->isSharingHousehold()) { + $withoutHousehold[] = $p->getPerson(); + } + } + return $this->render('@ChillPerson/AccompanyingCourse/index.html.twig', [ - 'accompanyingCourse' => $accompanyingCourse + 'accompanyingCourse' => $accompanyingCourse, + 'withoutHousehold' => $withoutHousehold ]); } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js index 31ee5a7c3..bb5e79004 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/store/index.js @@ -162,6 +162,8 @@ const store = createStore({ }, setWarnings(state, warnings) { state.warnings = warnings; + // reset errors, which should come from servers + state.errors.splice(0, state.errors.length); }, setErrors(state, errors) { state.errors = errors; @@ -176,8 +178,9 @@ const store = createStore({ commit('markPosition', { person_id, position_id }); dispatch('computeWarnings'); }, - toggleHolder({ commit }, conc) { + toggleHolder({ commit, dispatch }, conc) { commit('toggleHolder', conc); + dispatch('computeWarnings'); }, removePosition({ commit, dispatch }, conc) { commit('removePosition', conc); @@ -195,8 +198,9 @@ const store = createStore({ commit('forceLeaveWithoutHousehold'); dispatch('computeWarnings'); }, - setStartDate({ commit }, date) { + setStartDate({ commit, dispatch }, date) { commit('setStartDate', date); + dispatch('computeWarnings'); }, setComment({ commit }, payload) { commit('setComment', payload); @@ -240,8 +244,9 @@ const store = createStore({ } else { // we assume the answer was 422... error = household; - for (let e in error.violations) { - errors.push('TODO'); + for (let i in error.violations) { + let e = error.violations[i]; + errors.push(e.title); } commit('setErrors', errors); diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig index 4fef55ceb..4c3ad8b56 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourse/index.html.twig @@ -18,6 +18,45 @@ {% endif %} + {% if withoutHousehold|length > 0 %} +
      +
      + {{ 'Some peoples does not belong to any household currently. Add them to an household soon'|trans }} +
      +
        +
      • + +
      • +
      +
      + +
      +
      + +

      {{ 'household.Select people to move'|trans }}

      +
        + {% for p in withoutHousehold %} +
      • + + {{ p|chill_entity_render_box }} +
      • + {% endfor %} +
      + +
        +
      • + +
      • +
      +
      +
      + + {% endif %} +

      {{ 'Associated peoples'|trans }}

      {% for p in accompanyingCourse.participations %} diff --git a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php index 6a8571c38..41d7a5557 100644 --- a/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php +++ b/src/Bundle/ChillPersonBundle/Validator/Constraints/Household/MaxHolderValidator.php @@ -36,8 +36,8 @@ class MaxHolderValidator extends ConstraintValidator $this->context->buildViolation($msg) ->setParameters([ - 'start' => $start->format('d-m-Y'), // TODO fix when MessageParameter works with timezone - 'end' => $end === null ? null : $end->format('d-m-Y') + '{{ start }}' => $start->format('d-m-Y'), // TODO fix when MessageParameter works with timezone + '{{ end }}' => $end === null ? null : $end->format('d-m-Y') ]) ->addViolation(); } diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index 75021c90e..ff8e45cd2 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -8,6 +8,8 @@ Born the date: >- household: Household: Ménage Household members: Membres du ménage + Household editor: Modifier l'appartenance + Select people to move: Choisir les usagers Show future or past memberships: >- {length, plural, one {Montrer une ancienne appartenance} diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index df1ef996f..936d4f3bc 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -178,6 +178,8 @@ Edit & activate accompanying course: Modifier et valider See accompanying periods: Voir les périodes d'accompagnement See accompanying period: Voir cette période d'accompagnement Referrer: Référent +Some peoples does not belong to any household currently. Add them to an household soon: Certaines personnes n'appartiennent à aucun ménage actuellement. Renseignez leur appartenance à un ménage dès que possible. +Add to household now: Ajouter à un ménage # pickAPersonType Pick a person: Choisir une personne diff --git a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml index 9f2030f02..930494d58 100644 --- a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml @@ -33,3 +33,8 @@ You should select an option: Une option doit être choisie. # aggregator by age The date should not be empty: La date ne doit pas être vide + +# household +household: + max_holder_overflowed_infinity: Il ne peut pas y avoir plus de deux titulaires simultanément. Or, avec cette modification, ce nombre sera dépassé à partir du {{ start }}. + max_holder_overflowed: Il ne peut y avoir plus de deux titulaires simultanément. Or, avec cette modification, ce nombre sera dépassé entre le {{ start }} et le {{ end }}. From 51399b21b9907dd554c45f979441b9b8829a7032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Mon, 14 Jun 2021 11:37:20 +0200 Subject: [PATCH 07/18] fix class for Person component --- .../Resources/public/vuejs/_components/Person/Person.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Person/Person.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Person/Person.vue index 274d9ee88..886a00683 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Person/Person.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Person/Person.vue @@ -1,6 +1,6 @@