feat: exclusion groups for additional services
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unchecks conflicting additional services as soon as one is selected, so the user sees the
|
||||||
|
* result immediately instead of waiting for the debounced htmx refresh. The server resolves
|
||||||
|
* the same conflicts again and stays the source of truth.
|
||||||
|
*/
|
||||||
|
export default class extends Controller {
|
||||||
|
|
||||||
|
static targets = [ 'input' ]
|
||||||
|
|
||||||
|
select(event) {
|
||||||
|
const input = event.target
|
||||||
|
|
||||||
|
if (!input.checked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = input.dataset.exclusionGroup
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const exclusive = '1' === input.dataset.exclusive
|
||||||
|
|
||||||
|
this.inputTargets
|
||||||
|
.filter(other => other !== input)
|
||||||
|
.filter(other => other.dataset.exclusionGroup === group)
|
||||||
|
.filter(other => exclusive || '1' === other.dataset.exclusive)
|
||||||
|
.forEach(other => { other.checked = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260804120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add exclusion group and exclusive flag to additional services';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// The default only backfills existing rows; it is dropped again so the column matches the mapping.
|
||||||
|
$this->addSql('ALTER TABLE additional_service ADD exclusion_group VARCHAR(128) DEFAULT NULL, ADD is_exclusive TINYINT(1) DEFAULT 0 NOT NULL');
|
||||||
|
$this->addSql('ALTER TABLE additional_service ALTER COLUMN is_exclusive DROP DEFAULT');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE additional_service DROP exclusion_group, DROP is_exclusive');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ class Step2Controller extends AbstractAccommodationController
|
|||||||
$form = $this->createForm(AccommodationStep2Type::class, $dto, [
|
$form = $this->createForm(AccommodationStep2Type::class, $dto, [
|
||||||
'board_service_choices' => $this->buildServiceChoices($services['boardServices']),
|
'board_service_choices' => $this->buildServiceChoices($services['boardServices']),
|
||||||
'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']),
|
'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']),
|
||||||
|
'additional_services' => $services['additionalServices'],
|
||||||
'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(),
|
'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(),
|
||||||
'validation_groups' => $validate ? ['step_2'] : false,
|
'validation_groups' => $validate ? ['step_2'] : false,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
|||||||
#[ORM\Column(length: 128, nullable: true)]
|
#[ORM\Column(length: 128, nullable: true)]
|
||||||
private ?string $selectionGroup = null;
|
private ?string $selectionGroup = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 128, nullable: true)]
|
||||||
|
private ?string $exclusionGroup = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
private bool $isExclusive = false;
|
||||||
|
|
||||||
public function __clone()
|
public function __clone()
|
||||||
{
|
{
|
||||||
$this->id = null;
|
$this->id = null;
|
||||||
@@ -158,4 +164,28 @@ class AdditionalService implements BlameableEntityInterface, TimestampableEntity
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getExclusionGroup(): ?string
|
||||||
|
{
|
||||||
|
return $this->exclusionGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setExclusionGroup(?string $exclusionGroup): self
|
||||||
|
{
|
||||||
|
$this->exclusionGroup = $exclusionGroup;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExclusive(): bool
|
||||||
|
{
|
||||||
|
return $this->isExclusive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIsExclusive(bool $isExclusive): self
|
||||||
|
{
|
||||||
|
$this->isExclusive = $isExclusive;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
|
use App\Service\AdditionalServiceExclusionResolver;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
@@ -18,6 +20,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
*/
|
*/
|
||||||
class AccommodationStep2Type extends AbstractType
|
class AccommodationStep2Type extends AbstractType
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdditionalServiceExclusionResolver $exclusionResolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
@@ -51,7 +58,9 @@ class AccommodationStep2Type extends AbstractType
|
|||||||
])
|
])
|
||||||
;
|
;
|
||||||
|
|
||||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
|
$availableServices = $options['additional_services'];
|
||||||
|
|
||||||
|
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($availableServices): void {
|
||||||
$data = $event->getData();
|
$data = $event->getData();
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
@@ -64,6 +73,14 @@ class AccommodationStep2Type extends AbstractType
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$dto = $event->getForm()->getData();
|
||||||
|
|
||||||
|
$data['selectedAdditionalServiceIds'] = $this->exclusionResolver->resolve(
|
||||||
|
$availableServices,
|
||||||
|
array_map('intval', (array) ($data['selectedAdditionalServiceIds'] ?? [])),
|
||||||
|
$dto instanceof AccommodationBookingDto ? $dto->selectedAdditionalServiceIds : [],
|
||||||
|
);
|
||||||
|
|
||||||
$event->setData($data);
|
$event->setData($data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,8 +92,10 @@ class AccommodationStep2Type extends AbstractType
|
|||||||
'validation_groups' => ['step_2'],
|
'validation_groups' => ['step_2'],
|
||||||
'board_service_choices' => [],
|
'board_service_choices' => [],
|
||||||
'additional_service_choices' => [],
|
'additional_service_choices' => [],
|
||||||
|
'additional_services' => [],
|
||||||
'max_adolescent_age' => 0,
|
'max_adolescent_age' => 0,
|
||||||
]);
|
]);
|
||||||
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
$resolver->setAllowedTypes('max_adolescent_age', 'int');
|
||||||
|
$resolver->setAllowedTypes('additional_services', AdditionalService::class . '[]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Form\Admin\Groups;
|
|||||||
use App\Entity\Groups\AdditionalService;
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum;
|
use App\Enum\Groups\AdditionalServiceType as AdditionalServiceTypeEnum;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||||
@@ -46,6 +47,15 @@ class AdditionalServiceType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'disabled' => true,
|
'disabled' => true,
|
||||||
])
|
])
|
||||||
|
->add('exclusionGroup', TextType::class, [
|
||||||
|
'label' => 'Ausschlussgruppe',
|
||||||
|
'required' => false,
|
||||||
|
'help' => 'Leistungen mit derselben Ausschlussgruppe können sich gegenseitig abwählen.',
|
||||||
|
])
|
||||||
|
->add('isExclusive', CheckboxType::class, [
|
||||||
|
'label' => 'Schließt andere Leistungen dieser Gruppe aus',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
->add('dateFrom', DateType::class, [
|
->add('dateFrom', DateType::class, [
|
||||||
'label' => 'Datum von',
|
'label' => 'Datum von',
|
||||||
'html5' => true,
|
'html5' => true,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AdditionalService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves mutually exclusive additional services.
|
||||||
|
*
|
||||||
|
* Services sharing an exclusion group interact as soon as at least one of the two involved
|
||||||
|
* services is flagged as exclusive: selecting the exclusive one clears all other members of
|
||||||
|
* the group, selecting any other member clears the exclusive ones. Members that are not
|
||||||
|
* exclusive can be combined freely.
|
||||||
|
*
|
||||||
|
* Conflicts are resolved in favour of the newest selection, determined by diffing the
|
||||||
|
* submitted ids against the previously stored ones.
|
||||||
|
*/
|
||||||
|
class AdditionalServiceExclusionResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param AdditionalService[] $availableServices
|
||||||
|
* @param array<array-key, int> $submittedIds keys are preserved, grouped radios submit under their group name
|
||||||
|
* @param array<array-key, int> $previousIds
|
||||||
|
*
|
||||||
|
* @return array<array-key, int>
|
||||||
|
*/
|
||||||
|
public function resolve(array $availableServices, array $submittedIds, array $previousIds): array
|
||||||
|
{
|
||||||
|
$rules = $this->buildRules($availableServices);
|
||||||
|
|
||||||
|
if ([] === $rules) {
|
||||||
|
return $submittedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$added = array_values(array_diff($submittedIds, $previousIds));
|
||||||
|
|
||||||
|
foreach ($added as $addedId) {
|
||||||
|
if (!isset($rules[$addedId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$submittedIds = array_filter(
|
||||||
|
$submittedIds,
|
||||||
|
fn(int $id) => !$this->conflicts($rules, $addedId, $id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $submittedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{group: string, exclusive: bool}> $rules
|
||||||
|
*/
|
||||||
|
private function conflicts(array $rules, int $a, int $b): bool
|
||||||
|
{
|
||||||
|
if ($a === $b || !isset($rules[$b])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rules[$a]['group'] !== $rules[$b]['group']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules[$a]['exclusive'] || $rules[$b]['exclusive'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AdditionalService[] $availableServices
|
||||||
|
*
|
||||||
|
* @return array<int, array{group: string, exclusive: bool}>
|
||||||
|
*/
|
||||||
|
private function buildRules(array $availableServices): array
|
||||||
|
{
|
||||||
|
$rules = [];
|
||||||
|
|
||||||
|
foreach ($availableServices as $service) {
|
||||||
|
$group = $service->getExclusionGroup();
|
||||||
|
$id = $service->getId();
|
||||||
|
|
||||||
|
if (null === $id || null === $group || '' === $group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules[$id] = ['group' => $group, 'exclusive' => $service->isExclusive()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{{ form_row(form.price) }}
|
{{ form_row(form.price) }}
|
||||||
{{ form_row(form.selectionGroup) }}
|
{{ form_row(form.selectionGroup) }}
|
||||||
|
{{ form_row(form.exclusionGroup) }}
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
{{ form_row(form.isExclusive) }}
|
||||||
|
</div>
|
||||||
{{ form_row(form.dateFrom) }}
|
{{ form_row(form.dateFrom) }}
|
||||||
{{ form_row(form.dateTo) }}
|
{{ form_row(form.dateTo) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,6 +148,10 @@
|
|||||||
|
|
||||||
{% if ctx.ungroupedAdditionalServices is not empty or ctx.groupedAdditionalServices is not empty %}
|
{% if ctx.ungroupedAdditionalServices is not empty or ctx.groupedAdditionalServices is not empty %}
|
||||||
|
|
||||||
|
{# Wraps every fieldset so mutually exclusive services can be unchecked across fieldsets.
|
||||||
|
Purely for instant feedback — the server resolves the same conflicts on submit. #}
|
||||||
|
<div {{ stimulus_controller('service-exclusion') }} {{ stimulus_action('service-exclusion', 'select', 'change') }}>
|
||||||
|
|
||||||
{# Ungrouped → checkboxes #}
|
{# Ungrouped → checkboxes #}
|
||||||
{% if ctx.ungroupedAdditionalServices is not empty %}
|
{% if ctx.ungroupedAdditionalServices is not empty %}
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
@@ -179,6 +183,11 @@
|
|||||||
name="{{ form.selectedAdditionalServiceIds.vars.full_name }}"
|
name="{{ form.selectedAdditionalServiceIds.vars.full_name }}"
|
||||||
value="{{ service.id }}"
|
value="{{ service.id }}"
|
||||||
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
||||||
|
{% if service.exclusionGroup %}
|
||||||
|
{{ stimulus_target('service-exclusion', 'input') }}
|
||||||
|
data-exclusion-group="{{ service.exclusionGroup }}"
|
||||||
|
data-exclusive="{{ service.isExclusive ? '1' : '0' }}"
|
||||||
|
{% endif %}
|
||||||
class="form-checkbox">
|
class="form-checkbox">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -217,6 +226,11 @@
|
|||||||
name="{{ selectedAdditionalServiceBaseName }}[{{ groupName|e('html_attr') }}]"
|
name="{{ selectedAdditionalServiceBaseName }}[{{ groupName|e('html_attr') }}]"
|
||||||
value="{{ service.id }}"
|
value="{{ service.id }}"
|
||||||
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
{% if service.id in dto.selectedAdditionalServiceIds %}checked{% endif %}
|
||||||
|
{% if service.exclusionGroup %}
|
||||||
|
data-service-exclusion-target="input"
|
||||||
|
data-exclusion-group="{{ service.exclusionGroup }}"
|
||||||
|
data-exclusive="{{ service.isExclusive ? '1' : '0' }}"
|
||||||
|
{% endif %}
|
||||||
class="form-radio">
|
class="form-radio">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -226,6 +240,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Form;
|
namespace App\Tests\Form;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AdditionalService;
|
||||||
use App\Form\AccommodationStep2Type;
|
use App\Form\AccommodationStep2Type;
|
||||||
use App\Form\Model\AccommodationBookingDto;
|
use App\Form\Model\AccommodationBookingDto;
|
||||||
|
use App\Service\AdditionalServiceExclusionResolver;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\Form\Forms;
|
use Symfony\Component\Form\Forms;
|
||||||
|
|
||||||
class AccommodationStep2TypeTest extends TestCase
|
class AccommodationStep2TypeTest extends TestCase
|
||||||
{
|
{
|
||||||
public function testChildrenLabelIsBuiltFromMaxAdolescentAgeOption(): void
|
public function testChildrenLabelIsBuiltFromMaxAdolescentAgeOption(): void
|
||||||
{
|
{
|
||||||
$form = Forms::createFormFactoryBuilder()
|
$form = $this->createForm(new AccommodationBookingDto(), [
|
||||||
->getFormFactory()
|
|
||||||
->create(AccommodationStep2Type::class, new AccommodationBookingDto(), [
|
|
||||||
'max_adolescent_age' => 15,
|
'max_adolescent_age' => 15,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -28,9 +29,7 @@ class AccommodationStep2TypeTest extends TestCase
|
|||||||
public function testBlankOptionalChildCountersAreNormalizedToZero(): void
|
public function testBlankOptionalChildCountersAreNormalizedToZero(): void
|
||||||
{
|
{
|
||||||
$dto = new AccommodationBookingDto();
|
$dto = new AccommodationBookingDto();
|
||||||
$form = Forms::createFormFactoryBuilder()
|
$form = $this->createForm($dto);
|
||||||
->getFormFactory()
|
|
||||||
->create(AccommodationStep2Type::class, $dto);
|
|
||||||
|
|
||||||
$form->submit([
|
$form->submit([
|
||||||
'paxCount' => '2',
|
'paxCount' => '2',
|
||||||
@@ -48,9 +47,7 @@ class AccommodationStep2TypeTest extends TestCase
|
|||||||
public function testGroupedAdditionalServiceValuesCanSubmitUnderSelectionGroupKeys(): void
|
public function testGroupedAdditionalServiceValuesCanSubmitUnderSelectionGroupKeys(): void
|
||||||
{
|
{
|
||||||
$dto = new AccommodationBookingDto();
|
$dto = new AccommodationBookingDto();
|
||||||
$form = Forms::createFormFactoryBuilder()
|
$form = $this->createForm($dto, [
|
||||||
->getFormFactory()
|
|
||||||
->create(AccommodationStep2Type::class, $dto, [
|
|
||||||
'additional_service_choices' => [
|
'additional_service_choices' => [
|
||||||
'Ski service' => 101,
|
'Ski service' => 101,
|
||||||
'Board service' => 202,
|
'Board service' => 202,
|
||||||
@@ -71,4 +68,87 @@ class AccommodationStep2TypeTest extends TestCase
|
|||||||
self::assertTrue($form->isSynchronized());
|
self::assertTrue($form->isSynchronized());
|
||||||
self::assertSame([101, 202], array_values($dto->selectedAdditionalServiceIds));
|
self::assertSame([101, 202], array_values($dto->selectedAdditionalServiceIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testNewlySelectedExclusiveServiceClearsItsConflicts(): void
|
||||||
|
{
|
||||||
|
$dto = new AccommodationBookingDto();
|
||||||
|
$dto->selectedAdditionalServiceIds = [11, 12];
|
||||||
|
|
||||||
|
$form = $this->createForm($dto, [
|
||||||
|
'additional_service_choices' => [
|
||||||
|
'Keller' => 11,
|
||||||
|
'1. OG' => 12,
|
||||||
|
'Komplett' => 13,
|
||||||
|
],
|
||||||
|
'additional_services' => [
|
||||||
|
$this->service(11, 'Reinigung', false),
|
||||||
|
$this->service(12, 'Reinigung', false),
|
||||||
|
$this->service(13, 'Reinigung', true),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$form->submit([
|
||||||
|
'paxCount' => '2',
|
||||||
|
'minorsCount' => '0',
|
||||||
|
'childrenCount' => '0',
|
||||||
|
'selectedBoardServiceId' => '',
|
||||||
|
'selectedAdditionalServiceIds' => ['11', '12', '13'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($form->isSynchronized());
|
||||||
|
self::assertSame([13], array_values($dto->selectedAdditionalServiceIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewlySelectedRegularServiceClearsTheExclusiveOne(): void
|
||||||
|
{
|
||||||
|
$dto = new AccommodationBookingDto();
|
||||||
|
$dto->selectedAdditionalServiceIds = [13];
|
||||||
|
|
||||||
|
$form = $this->createForm($dto, [
|
||||||
|
'additional_service_choices' => [
|
||||||
|
'Keller' => 11,
|
||||||
|
'Komplett' => 13,
|
||||||
|
],
|
||||||
|
'additional_services' => [
|
||||||
|
$this->service(11, 'Reinigung', false),
|
||||||
|
$this->service(13, 'Reinigung', true),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$form->submit([
|
||||||
|
'paxCount' => '2',
|
||||||
|
'minorsCount' => '0',
|
||||||
|
'childrenCount' => '0',
|
||||||
|
'selectedBoardServiceId' => '',
|
||||||
|
'selectedAdditionalServiceIds' => ['13', '11'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::assertTrue($form->isSynchronized());
|
||||||
|
self::assertSame([11], array_values($dto->selectedAdditionalServiceIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
*
|
||||||
|
* @return FormInterface<AccommodationBookingDto>
|
||||||
|
*/
|
||||||
|
private function createForm(AccommodationBookingDto $dto, array $options = []): FormInterface
|
||||||
|
{
|
||||||
|
return Forms::createFormFactoryBuilder()
|
||||||
|
->addType(new AccommodationStep2Type(new AdditionalServiceExclusionResolver()))
|
||||||
|
->getFormFactory()
|
||||||
|
->create(AccommodationStep2Type::class, $dto, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(int $id, ?string $exclusionGroup, bool $isExclusive): AdditionalService
|
||||||
|
{
|
||||||
|
$service = new AdditionalService();
|
||||||
|
$service->setExclusionGroup($exclusionGroup);
|
||||||
|
$service->setIsExclusive($isExclusive);
|
||||||
|
|
||||||
|
$property = new \ReflectionProperty(AdditionalService::class, 'id');
|
||||||
|
$property->setValue($service, $id);
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service;
|
||||||
|
|
||||||
|
use App\Entity\Groups\AdditionalService;
|
||||||
|
use App\Service\AdditionalServiceExclusionResolver;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class AdditionalServiceExclusionResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
private const int BASEMENT = 1;
|
||||||
|
private const int FIRST_FLOOR = 2;
|
||||||
|
private const int SECOND_FLOOR = 3;
|
||||||
|
private const int FULL = 4;
|
||||||
|
private const int BREAKFAST = 5;
|
||||||
|
|
||||||
|
public function testSelectingTheExclusiveServiceClearsTheOtherGroupMembers(): void
|
||||||
|
{
|
||||||
|
$resolved = $this->resolve(
|
||||||
|
[self::BASEMENT, self::FIRST_FLOOR, self::FULL],
|
||||||
|
[self::BASEMENT, self::FIRST_FLOOR],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([self::FULL], array_values($resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSelectingANonExclusiveServiceClearsOnlyTheExclusiveOne(): void
|
||||||
|
{
|
||||||
|
$resolved = $this->resolve(
|
||||||
|
[self::FULL, self::BASEMENT],
|
||||||
|
[self::FULL],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([self::BASEMENT], array_values($resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonExclusiveGroupMembersCanBeCombined(): void
|
||||||
|
{
|
||||||
|
$resolved = $this->resolve(
|
||||||
|
[self::BASEMENT, self::FIRST_FLOOR, self::SECOND_FLOOR],
|
||||||
|
[self::BASEMENT],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[self::BASEMENT, self::FIRST_FLOOR, self::SECOND_FLOOR],
|
||||||
|
array_values($resolved),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicesWithoutAnExclusionGroupAreNeverCleared(): void
|
||||||
|
{
|
||||||
|
$resolved = $this->resolve(
|
||||||
|
[self::BREAKFAST, self::BASEMENT, self::FULL],
|
||||||
|
[self::BREAKFAST, self::BASEMENT],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([self::BREAKFAST, self::FULL], array_values($resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnchangedSelectionIsLeftAlone(): void
|
||||||
|
{
|
||||||
|
$resolved = $this->resolve(
|
||||||
|
[self::BASEMENT, self::FULL],
|
||||||
|
[self::BASEMENT, self::FULL],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame([self::BASEMENT, self::FULL], array_values($resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSubmittedArrayKeysArePreserved(): void
|
||||||
|
{
|
||||||
|
$resolver = new AdditionalServiceExclusionResolver();
|
||||||
|
|
||||||
|
$resolved = $resolver->resolve(
|
||||||
|
$this->services(),
|
||||||
|
['Reinigung' => self::FULL, 'Verpflegung' => self::BREAKFAST, 0 => self::BASEMENT],
|
||||||
|
[self::BASEMENT],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['Reinigung' => self::FULL, 'Verpflegung' => self::BREAKFAST],
|
||||||
|
$resolved,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $submittedIds
|
||||||
|
* @param list<int> $previousIds
|
||||||
|
*
|
||||||
|
* @return array<array-key, int>
|
||||||
|
*/
|
||||||
|
private function resolve(array $submittedIds, array $previousIds): array
|
||||||
|
{
|
||||||
|
return (new AdditionalServiceExclusionResolver())->resolve(
|
||||||
|
$this->services(),
|
||||||
|
$submittedIds,
|
||||||
|
$previousIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return AdditionalService[]
|
||||||
|
*/
|
||||||
|
private function services(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->service(self::BASEMENT, 'Reinigung', false),
|
||||||
|
$this->service(self::FIRST_FLOOR, 'Reinigung', false),
|
||||||
|
$this->service(self::SECOND_FLOOR, 'Reinigung', false),
|
||||||
|
$this->service(self::FULL, 'Reinigung', true),
|
||||||
|
$this->service(self::BREAKFAST, null, false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function service(int $id, ?string $exclusionGroup, bool $isExclusive): AdditionalService
|
||||||
|
{
|
||||||
|
$service = new AdditionalService();
|
||||||
|
$service->setExclusionGroup($exclusionGroup);
|
||||||
|
$service->setIsExclusive($isExclusive);
|
||||||
|
|
||||||
|
$property = new \ReflectionProperty(AdditionalService::class, 'id');
|
||||||
|
$property->setValue($service, $id);
|
||||||
|
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user