feature #11055 Define countries for a channel (pamil)

This PR was merged into the 1.7-dev branch.

Discussion
----------

| Q               | A
| --------------- | -----
| Branch?         | master
| Bug fix?        | no
| New feature?    | yes
| BC breaks?      | no
| Deprecations?   | no
| Related tickets | fixes #9122 
| License         | MIT

Currently used only in checkout addressing to limit the list of countries. If not defined, fallbacks to the previous behaviour - all enabled countries.

<img width="672" alt="Screenshot 2020-01-22 at 20 10 51" src="https://user-images.githubusercontent.com/1897953/72925676-65ef8f80-3d53-11ea-831c-25fad0973a83.png">



Commits
-------

2e68737703 Define countries for a channel
This commit is contained in:
Łukasz Chruściel 2020-01-23 13:22:17 +01:00 committed by GitHub
commit e8145f02b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 326 additions and 16 deletions

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Sylius\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20200122082429 extends AbstractMigration
{
public function getDescription() : string
{
return '';
}
public function up(Schema $schema) : void
{
// this up() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('CREATE TABLE sylius_channel_countries (channel_id INT NOT NULL, country_id INT NOT NULL, INDEX IDX_D96E51AE72F5A1AA (channel_id), INDEX IDX_D96E51AEF92F3E70 (country_id), PRIMARY KEY(channel_id, country_id)) DEFAULT CHARACTER SET UTF8 COLLATE `UTF8_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE sylius_channel_countries ADD CONSTRAINT FK_D96E51AE72F5A1AA FOREIGN KEY (channel_id) REFERENCES sylius_channel (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE sylius_channel_countries ADD CONSTRAINT FK_D96E51AEF92F3E70 FOREIGN KEY (country_id) REFERENCES sylius_country (id) ON DELETE CASCADE');
}
public function down(Schema $schema) : void
{
// this down() migration is auto-generated, please modify it to your needs
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('DROP TABLE sylius_channel_countries');
}
}

View file

@ -19,6 +19,7 @@ Or pretty much any other channel type you can imagine.
* products,
* currencies,
* locales (language),
* countries,
* themes,
* hostnames,
* taxes,

View file

@ -7,6 +7,7 @@ Feature: Adding a new channel
Background:
Given the store has currency "Euro"
And the store has locale "English (United States)"
And the store operates in "United States" and "Poland"
And I am logged in as an administrator
@ui
@ -31,6 +32,7 @@ Feature: Adding a new channel
And I define its color as "blue"
And I choose "Euro" as the base currency
And I choose "English (United States)" as a default locale
And I choose "United States" and "Poland" as operating countries
And I allow to skip shipping step if only one shipping method is available
And I allow to skip payment step if only one payment method is available
And I add it

View file

@ -0,0 +1,26 @@
@checkout
Feature: Restricting list of countries available for addressing
In order to make choosing countries easier
As a Customer
I want to have only available countries listed
Background:
Given the store operates on a single channel in "United States"
And the store operates in "Poland"
And the store has a product "PHP T-Shirt" priced at "$19.99"
And the store ships everywhere for free
And I am a logged in customer
@ui
Scenario: Having only countries available for current channel listed
Given this channel operates in the "United States" country
When I add product "PHP T-Shirt" to the cart
And I go to the checkout addressing step
Then I should have only "United States" country available to choose from
@ui
Scenario: Having all the countries listed if channel does not define available ones
Given this channel does not define operating countries
When I add product "PHP T-Shirt" to the cart
And I go to the checkout addressing step
Then I should have both "United States" and "Poland" countries available to choose from

View file

@ -229,6 +229,28 @@ final class ChannelContext implements Context
$this->channelManager->flush();
}
/**
* @Given /^(this channel) operates in the ("[^"]+" country)$/
*/
public function channelOperatesInCountry(ChannelInterface $channel, CountryInterface $country): void
{
$channel->addCountry($country);
$this->channelManager->flush();
}
/**
* @Given /^(this channel) does not define operating countries$/
*/
public function channelDoesNotDefineOperatingCountries(ChannelInterface $channel): void
{
foreach ($channel->getCountries() as $country) {
$channel->removeCountry($country);
}
$this->channelManager->flush();
}
/**
* @param bool $state
*/

View file

@ -102,6 +102,14 @@ final class ManagingChannelsContext implements Context
$this->createPage->chooseDefaultLocale($locale);
}
/**
* @When I choose :firstCountry and :secondCountry as operating countries
*/
public function iChooseOperatingCountries(string ...$countries): void
{
$this->createPage->chooseOperatingCountries($countries);
}
/**
* @When I specify menu taxon as :menuTaxon
*/

View file

@ -56,9 +56,10 @@ final class CheckoutAddressingContext implements Context
/**
* @Given I am at the checkout addressing step
* @When I go to the checkout addressing step
* @When I go back to addressing step of the checkout
*/
public function iAmAtTheCheckoutAddressingStep()
public function iAmAtTheCheckoutAddressingStep(): void
{
$this->addressPage->open();
}
@ -385,6 +386,23 @@ final class CheckoutAddressingContext implements Context
$this->assertElementValidationMessage($type, $secondElement, sprintf('Please enter %s.', $secondElement));
}
/**
* @Then I should have only :firstCountry country available to choose from
* @Then I should have both :firstCountry and :secondCountry countries available to choose from
*/
public function shouldHaveCountriesToChooseFrom(string ...$countries): void
{
$availableShippingCountries = $this->addressPage->getAvailableShippingCountries();
$availableBillingCountries = $this->addressPage->getAvailableBillingCountries();
sort($countries);
sort($availableShippingCountries);
sort($availableBillingCountries);
Assert::same($availableShippingCountries, $countries);
Assert::same($availableBillingCountries, $countries);
}
/**
* @return AddressInterface
*/

View file

@ -65,6 +65,13 @@ class CreatePage extends BaseCreatePage implements CreatePageInterface
}
}
public function chooseOperatingCountries(array $countries): void
{
foreach ($countries as $country) {
$this->getElement('countries')->selectOption($country, true);
}
}
public function chooseBaseCurrency(?string $currency): void
{
if (null !== $currency) {
@ -104,6 +111,7 @@ class CreatePage extends BaseCreatePage implements CreatePageInterface
{
return array_merge(parent::getDefinedElements(), [
'code' => '#sylius_channel_code',
'countries' => '#sylius_channel_countries',
'currencies' => '#sylius_channel_currencies',
'base_currency' => '#sylius_channel_baseCurrency',
'default_locale' => '#sylius_channel_defaultLocale',

View file

@ -41,6 +41,9 @@ interface CreatePageInterface extends BaseCreatePageInterface
public function chooseDefaultLocale(?string $locale): void;
/** @param string[] $countries */
public function chooseOperatingCountries(array $countries): void;
public function chooseBaseCurrency(?string $currency): void;
public function chooseTaxCalculationStrategy(string $taxCalculationStrategy): void;

View file

@ -250,6 +250,16 @@ class AddressPage extends SymfonyPage implements AddressPageInterface
return $this->getPreFilledAddress(self::TYPE_BILLING);
}
public function getAvailableShippingCountries(): array
{
return $this->getOptionsFromSelect($this->getElement('shipping_country'));
}
public function getAvailableBillingCountries(): array
{
return $this->getOptionsFromSelect($this->getElement('billing_country'));
}
protected function getDefinedElements(): array
{
return array_merge(parent::getDefinedElements(), [
@ -283,6 +293,18 @@ class AddressPage extends SymfonyPage implements AddressPageInterface
]);
}
/**
* @return string[]
*/
private function getOptionsFromSelect(NodeElement $element): array
{
return array_map(
/** @return string[] */
static function (NodeElement $element): string { return $element->getText(); },
$element->findAll('css', 'option[value!=""]')
);
}
private function getPreFilledAddress(string $type): AddressInterface
{
$this->assertAddressType($type);

View file

@ -65,4 +65,10 @@ interface AddressPageInterface extends SymfonyPageInterface
public function getPreFilledShippingAddress(): AddressInterface;
public function getPreFilledBillingAddress(): AddressInterface;
/** @return string[] */
public function getAvailableShippingCountries(): array;
/** @return string[] */
public function getAvailableBillingCountries(): array;
}

View file

@ -15,8 +15,10 @@ namespace Sylius\Bundle\AddressingBundle\Form\Type;
use Sylius\Component\Addressing\Model\CountryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Bridge\Doctrine\Form\DataTransformer\CollectionToArrayTransformer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -30,6 +32,13 @@ final class CountryChoiceType extends AbstractType
$this->countryRepository = $countryRepository;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
if ($options['multiple']) {
$builder->addModelTransformer(new CollectionToArrayTransformer());
}
}
/**
* {@inheritdoc}
*/
@ -45,14 +54,6 @@ final class CountryChoiceType extends AbstractType
$countries = $this->countryRepository->findBy(['enabled' => $options['enabled']]);
}
if ($options['choice_filter']) {
$countries = array_filter($countries, $options['choice_filter']);
}
usort($countries, function (CountryInterface $a, CountryInterface $b): int {
return $a->getName() <=> $b->getName();
});
return $countries;
},
'choice_value' => 'code',
@ -63,6 +64,17 @@ final class CountryChoiceType extends AbstractType
'placeholder' => 'sylius.form.country.select',
])
->setAllowedTypes('choice_filter', ['null', 'callable'])
->setNormalizer('choices', static function (Options $options, array $countries): array {
if ($options['choice_filter']) {
$countries = array_filter($countries, $options['choice_filter']);
}
usort($countries, static function (CountryInterface $firstCountry, CountryInterface $secondCountry): int {
return $firstCountry->getName() <=> $secondCountry->getName();
});
return $countries;
})
;
}

View file

@ -30,6 +30,9 @@
{{ form_row(form.contactEmail) }}
{{ form_row(form.description, {'attr': {'rows' : '3'}}) }}
</div>
<div class="ui attached segment">
{{ form_row(form.countries) }}
</div>
<div class="ui hidden divider"></div>
<h4 class="ui top attached large header">{{ 'sylius.ui.money'|trans }}</h4>
<div class="ui attached segment">

View file

@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Form\Extension;
use Sylius\Bundle\AddressingBundle\Form\Type\AddressType;
use Sylius\Component\Core\Model\ChannelInterface;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class AddressTypeExtension extends AbstractTypeExtension
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefault('channel', null)
->setAllowedTypes('channel', ['null', ChannelInterface::class])
;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/** @var ChannelInterface|null $channel */
$channel = $options['channel'];
if ($channel === null || $channel->getCountries()->count() === 0) {
return;
}
$oldCountryCodeField = $builder->get('countryCode');
$countryCodeField = $builder->create(
$oldCountryCodeField->getName(),
get_class($oldCountryCodeField->getType()->getInnerType()),
array_replace($oldCountryCodeField->getOptions(), ['choices' => $channel->getCountries()->toArray()])
);
$builder->add($countryCodeField);
}
public static function getExtendedTypes(): array
{
return [AddressType::class];
}
}

View file

@ -13,6 +13,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Form\Extension;
use Sylius\Bundle\AddressingBundle\Form\Type\CountryChoiceType;
use Sylius\Bundle\AddressingBundle\Form\Type\ZoneChoiceType;
use Sylius\Bundle\ChannelBundle\Form\Type\ChannelType;
use Sylius\Bundle\CoreBundle\Form\EventSubscriber\AddBaseCurrencySubscriber;
@ -54,6 +55,11 @@ final class ChannelTypeExtension extends AbstractTypeExtension
'required' => false,
'multiple' => true,
])
->add('countries', CountryChoiceType::class, [
'label' => 'sylius.form.channel.countries',
'required' => false,
'multiple' => true,
])
->add('defaultTaxZone', ZoneChoiceType::class, [
'required' => false,
'label' => 'sylius.form.channel.tax_zone_default',

View file

@ -17,6 +17,7 @@ use Sylius\Bundle\AddressingBundle\Form\Type\AddressType as SyliusAddressType;
use Sylius\Bundle\CoreBundle\Form\Type\Customer\CustomerGuestType;
use Sylius\Bundle\ResourceBundle\Form\Type\AbstractResourceType;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Customer\Model\CustomerAwareInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
@ -34,13 +35,6 @@ final class AddressType extends AbstractResourceType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('shippingAddress', SyliusAddressType::class, [
'shippable' => true,
'constraints' => [new Valid()],
])
->add('billingAddress', SyliusAddressType::class, [
'constraints' => [new Valid()],
])
->add('differentBillingAddress', CheckboxType::class, [
'mapped' => false,
'required' => false,
@ -51,6 +45,27 @@ final class AddressType extends AbstractResourceType
'required' => false,
'label' => 'sylius.form.checkout.addressing.different_shipping_address',
])
->addEventListener(FormEvents::PRE_SET_DATA, static function (FormEvent $event): void {
$form = $event->getForm();
$order = $event->getData();
/** @var OrderInterface $order */
Assert::isInstanceOf($order, OrderInterface::class);
$channel = $order->getChannel();
$form
->add('shippingAddress', SyliusAddressType::class, [
'shippable' => true,
'constraints' => [new Valid()],
'channel' => $channel,
])
->add('billingAddress', SyliusAddressType::class, [
'constraints' => [new Valid()],
'channel' => $channel,
])
;
})
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
$form = $event->getForm();
$resource = $event->getData();

View file

@ -56,6 +56,16 @@
</inverse-join-columns>
</join-table>
</many-to-many>
<many-to-many field="countries" target-entity="Sylius\Component\Addressing\Model\CountryInterface" fetch="EAGER">
<join-table name="sylius_channel_countries">
<join-columns>
<join-column name="channel_id" nullable="false" on-delete="CASCADE" />
</join-columns>
<inverse-join-columns>
<join-column name="country_id" nullable="false" on-delete="CASCADE" />
</inverse-join-columns>
</join-table>
</many-to-many>
<one-to-one field="shopBillingData" target-entity="Sylius\Component\Core\Model\ShopBillingData">
<join-column name="shop_billing_data_id" on-delete="CASCADE"/>

View file

@ -54,6 +54,9 @@
<services>
<defaults public="true" />
<service id="sylius.form.extension.type.address" class="Sylius\Bundle\CoreBundle\Form\Extension\AddressTypeExtension">
<tag name="form.type_extension" extended-type="Sylius\Bundle\AddressingBundle\Form\Type\AddressType" />
</service>
<service id="sylius.form.extension.type.country" class="Sylius\Bundle\CoreBundle\Form\Extension\CountryTypeExtension">
<argument type="service" id="sylius.repository.country" />
<tag name="form.type_extension" extended-type="Sylius\Bundle\AddressingBundle\Form\Type\CountryType" />

View file

@ -32,6 +32,7 @@ sylius:
street: Street
tax_id: Tax ID
contact_email: Contact email
countries: Countries
currencies: Currencies
currency_base: Base currency
account_verification_required: Is account verification required?

View file

@ -15,6 +15,7 @@ namespace Sylius\Component\Core\Model;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Sylius\Component\Addressing\Model\CountryInterface;
use Sylius\Component\Addressing\Model\ZoneInterface;
use Sylius\Component\Channel\Model\Channel as BaseChannel;
use Sylius\Component\Currency\Model\CurrencyInterface;
@ -48,6 +49,13 @@ class Channel extends BaseChannel implements ChannelInterface
*/
protected $locales;
/**
* @var Collection|CountryInterface[]
*
* @psalm-var Collection<array-key, CountryInterface>
*/
protected $countries;
/** @var string */
protected $themeName;
@ -77,6 +85,8 @@ class Channel extends BaseChannel implements ChannelInterface
$this->currencies = new ArrayCollection();
/** @var ArrayCollection<array-key, LocaleInterface> $this->locales */
$this->locales = new ArrayCollection();
/** @var ArrayCollection<array-key, CountryInterface> $this->countries */
$this->countries = new ArrayCollection();
}
/**
@ -215,6 +225,30 @@ class Channel extends BaseChannel implements ChannelInterface
return $this->locales->contains($locale);
}
public function getCountries(): Collection
{
return $this->countries;
}
public function addCountry(CountryInterface $country): void
{
if (!$this->hasCountry($country)) {
$this->countries->add($country);
}
}
public function removeCountry(CountryInterface $country): void
{
if ($this->hasCountry($country)) {
$this->countries->removeElement($country);
}
}
public function hasCountry(CountryInterface $country): bool
{
return $this->countries->contains($country);
}
/**
* {@inheritdoc}
*/

View file

@ -13,6 +13,8 @@ declare(strict_types=1);
namespace Sylius\Component\Core\Model;
use Doctrine\Common\Collections\Collection;
use Sylius\Component\Addressing\Model\CountryInterface;
use Sylius\Component\Addressing\Model\ZoneInterface;
use Sylius\Component\Channel\Model\ChannelInterface as BaseChannelInterface;
use Sylius\Component\Currency\Model\CurrenciesAwareInterface;
@ -68,4 +70,17 @@ interface ChannelInterface extends
public function getMenuTaxon(): ?TaxonInterface;
public function setMenuTaxon(?TaxonInterface $menuTaxon): void;
/**
* @return Collection|CountryInterface[]
*
* @psalm-return Collection<array-key, CountryInterface>
*/
public function getCountries(): Collection;
public function addCountry(CountryInterface $country): void;
public function removeCountry(CountryInterface $country): void;
public function hasCountry(CountryInterface $country): bool;
}

View file

@ -23,6 +23,7 @@
]
},
"currencies": {},
"countries": {},
"defaultTaxZone": {},
"taxCalculationStrategy": {
"errors": [

View file

@ -15,6 +15,7 @@
"locales": {},
"defaultLocale": {},
"currencies": {},
"countries": {},
"defaultTaxZone": {},
"taxCalculationStrategy": {
"errors": [