Upmerge 1.12 into 1.13

This commit is contained in:
Jacob Tobiasz 2023-08-29 09:07:42 +02:00
commit 7df78676c4
No known key found for this signature in database
GPG key ID: 6434250CB3525233
44 changed files with 713 additions and 75 deletions

View file

@ -1,18 +0,0 @@
@managing_countries
Feature: Province unique code validation
In order to uniquely identify provinces
As an Administrator
I want to be prevented from adding two provinces with the same code
Background:
Given the store has country "United Kingdom"
And this country has the "Northern Ireland" province with "GB-NIR" code
And I am logged in as an administrator
@ui @javascript @api
Scenario: Trying to add a new province with taken code
When I want to edit this country
And I add the "Scotland" province with "GB-NIR" code
And I try to save my changes
Then I should be notified that province code must be unique
And province with name "Scotland" should not be added in this country

View file

@ -0,0 +1,41 @@
@managing_countries
Feature: Province unique fields validation
In order to uniquely identify provinces
As an Administrator
I want to be prevented from adding two provinces with the same code or name
Background:
Given the store has country "United Kingdom"
And this country has the "Northern Ireland" province with "GB-NIR" code
And I am logged in as an administrator
@ui @javascript @api
Scenario: Trying to add a new province with a taken code
When I want to add a new country
And I choose "Gibraltar"
And I add the "Scotland" province with "GB-NIR" code
And I try to add it
Then I should be notified that province code must be unique
@ui @javascript @api
Scenario: Trying to add a new province with a taken name
When I want to edit this country
And I add the "Northern Ireland" province with "GB-NI" code
And I try to save changes
Then I should be notified that province name must be unique
@ui @javascript @api
Scenario: Trying to add new provinces with duplicated codes
When I want to edit this country
And I add the "Scotland" province with "GB-SCO" code
And I add the "Not Scotland" province with "GB-SCO" code
And I try to save changes
Then I should be notified that all province codes and names within this country need to be unique
@ui @javascript @api
Scenario: Trying to add new provinces with duplicated names
When I want to edit this country
And I add the "Scotland" province with "GB-SC" code
And I add the "Scotland" province with "GB-SCO" code
And I try to save changes
Then I should be notified that all province codes and names within this country need to be unique

View file

@ -7,12 +7,21 @@ Feature: Adding product review as a customer
Background:
Given the store operates on a single channel in "United States"
And the store has a product "Necronomicon"
And I am a logged in customer
@ui @api
Scenario: Adding product reviews as a logged in customer
Given I am a logged in customer
When I want to review product "Necronomicon"
And I leave a comment "Great book for every advanced sorcerer.", titled "Scary but astonishing"
And I rate it with 5 points
And I add it
Then I should be notified that my review is waiting for the acceptation
@ui
Scenario: Adding product reviews as a logged in customer with remember me option
Given I am a logged in customer by using remember me option
When I want to review product "Necronomicon"
And I leave a comment "Great book for every advanced sorcerer.", titled "Scary but astonishing"
And I rate it with 3 points
And I add it
Then I should be notified that my review is waiting for the acceptation

View file

@ -55,7 +55,7 @@ Feature: Adding a new product
Then I should be notified that it has been successfully created
And the product "Dice Brewing" should appear in the store
@ui
@ui @javascript
Scenario: Adding a new configurable product
Given the store has a product option "Bottle size" with a code "bottle_size"
And this product option has the "0.7" option value with code "bottle_size_medium"

View file

@ -69,7 +69,7 @@ Feature: Editing a product
Then I should be notified that it has been successfully edited
And this product name should be "Sobieski Vodka"
@ui @api
@ui @javascript @api
Scenario: Changing options of configurable product without any variant defined
Given the store has a "Marvel's T-Shirt" configurable product
And the store has a product option "T-Shirt size" with a code "t_shirt_size"

View file

@ -34,6 +34,16 @@ Feature: Editing taxon's slug
And I add it
Then this taxon slug should be "medieval-weapons/siege-engines"
@ui @javascript
Scenario: Creating a child taxon with autogenerated slug by setting the parent
Given the store has "Medieval weapons" taxonomy
When I want to create a new taxon
And I specify its code as "PIKES"
And I name it "Pikes" in "English (United States)"
And I set its parent taxon to "Medieval weapons"
And I add it
Then this taxon slug should be "medieval-weapons/pikes"
@ui
Scenario: Creating a child taxon with a custom slug
Given the store has "Medieval weapons" taxonomy
@ -51,13 +61,32 @@ Feature: Editing taxon's slug
Then the slug field should not be editable
@ui @javascript
Scenario: Prevent from editing a slug while changing a taxon name
Scenario: Prevent from editing the slug while changing a taxon name
Given the store has "Medieval weapons" taxonomy
When I want to modify the "Medieval weapons" taxon
And I rename it to "Renaissance weapons" in "English (United States)"
And I save my changes
Then the slug of the "Renaissance weapons" taxon should still be "medieval-weapons"
@ui @javascript
Scenario: Prevent from editing the slug while setting the taxon's parent
Given the store has "Medieval weapons" taxonomy
And the store has "Pikes" taxonomy
When I want to modify the "Pikes" taxon
And I set its parent taxon to "Medieval weapons"
And I save my changes
Then the slug of the "Pikes" taxon should still be "pikes"
@ui @javascript
Scenario: Prevent from editing the slug while changing the taxon's parent
Given the store has "Medieval weapons" taxonomy
Given the store has "Renaissance weapons" taxonomy
And the "Medieval weapons" taxon has child taxon "Pikes"
When I want to modify the "Pikes" taxon
And I change its parent taxon to "Renaissance weapons"
And I save my changes
Then the slug of the "Pikes" taxon should still be "medieval-weapons/pikes"
@ui @javascript
Scenario: Automatically changing a root taxon's slug while editing a taxon's name
Given the store has "Medieval weapons" taxonomy
@ -87,6 +116,29 @@ Feature: Editing taxon's slug
And I save my changes
Then the slug of the "Renaissance weapons" taxon should be "renaissance"
@ui @javascript
Scenario: Automatically changing a child taxon's slug when changing the parent
Given the store has "Renaissance weapons" taxonomy
And the store has "Medieval weapons" taxonomy
And the "Medieval weapons" taxon has child taxon "Pikes"
When I want to modify the "Pikes" taxon
And I enable slug modification
And I change its parent taxon to "Renaissance weapons"
And I save my changes
Then the slug of the "Pikes" taxon should be "renaissance-weapons/pikes"
@ui @javascript
Scenario: Manually changing a child taxon's slug when changing the parent
Given the store has "Renaissance weapons" taxonomy
And the store has "Medieval weapons" taxonomy
And the "Medieval weapons" taxon has child taxon "Pikes"
When I want to modify the "Pikes" taxon
And I enable slug modification
And I change its parent taxon to "Renaissance weapons"
And I set its slug to "pikes" in "English (United States)"
And I save my changes
Then the slug of the "Pikes" taxon should be "pikes"
@ui @javascript
Scenario: Manually changing a child taxon's slug while editing a taxon's name
Given the store has "Medieval weapons" taxonomy

View file

@ -70,6 +70,11 @@ parameters:
count: 1
path: src/Sylius/Bundle/AddressingBundle/Validator/Constraints/ProvinceAddressConstraintValidator.php
-
message: "#^Method Sylius\\\\Bundle\\\\AddressingBundle\\\\Validator\\\\Constraints\\\\UniqueProvinceCollectionValidator\\:\\:validate\\(\\) has parameter \\$value with no type specified\\.$#"
count: 1
path: src/Sylius/Bundle/AddressingBundle/Validator/Constraints/UniqueProvinceCollectionValidator.php
-
message: "#^Method Sylius\\\\Bundle\\\\AddressingBundle\\\\Validator\\\\Constraints\\\\ZoneCannotContainItselfValidator\\:\\:validate\\(\\) has parameter \\$value with no type specified\\.$#"
count: 1

View file

@ -52,6 +52,7 @@ final class ManagingCountriesContext implements Context
/**
* @When I add it
* @When I try to add it
*/
public function iAddIt(): void
{
@ -292,13 +293,24 @@ final class ManagingCountriesContext implements Context
}
/**
* @Then I should be notified that province code must be unique
* @Then /^I should be notified that province (code|name) must be unique$/
*/
public function iShouldBeNotifiedThatProvinceCodeMustBeUnique(): void
public function iShouldBeNotifiedThatProvinceCodeMustBeUnique(string $field): void
{
Assert::same(
Assert::regex(
$this->responseChecker->getError($this->client->getLastResponse()),
'provinces[1].code: Province code must be unique.',
sprintf('/provinces\[[\d+]\]\.%1$s: Province %1$s must be unique\./', $field),
);
}
/**
* @Then I should be notified that all province codes and names within this country need to be unique
*/
public function iShouldBeNotifiedThatAllProvinceCodesAndNamesWithinThisCountryNeedToBeUnique(): void
{
Assert::contains(
$this->responseChecker->getError($this->client->getLastResponse()),
'provinces: All provinces within this country need to have unique codes and names.',
);
}

View file

@ -67,4 +67,17 @@ final class ShopSecurityContext implements Context
$this->sharedStorage->set('user', $user);
}
/**
* @Given I am a logged in customer by using remember me option
*/
public function iAmLoggedInCustomerByUsingRememberMeOption(): void
{
$userData = ['email' => 'sylius@example.com', 'password' => 'sylius', 'enabled' => true];
$user = $this->userFactory->create($userData);
$this->userRepository->add($user);
$this->securityService->logInWithRememberMe($user);
}
}

View file

@ -120,7 +120,7 @@ final class TaxonomyContext implements Context
public function theTaxonHasChildrenTaxonAnd(TaxonInterface $taxon, string ...$taxonsNames): void
{
foreach ($taxonsNames as $taxonName) {
$taxon->addChild($this->createTaxon($taxonName));
$taxon->addChild($this->createChildTaxon($taxonName, $taxon));
}
$this->objectManager->persist($taxon);
@ -158,6 +158,15 @@ final class TaxonomyContext implements Context
return $taxon;
}
private function createChildTaxon(string $name, TaxonInterface $parent): TaxonInterface
{
$child = $this->createTaxon($name);
$child->setParent($parent);
$child->setSlug($this->taxonSlugGenerator->generate($child));
return $child;
}
/**
* @return TaxonInterface
*/

View file

@ -74,6 +74,7 @@ final class ManagingCountriesContext implements Context
/**
* @When I add it
* @When I try to add it
*/
public function iAddIt()
{
@ -265,11 +266,22 @@ final class ManagingCountriesContext implements Context
}
/**
* @Then I should be notified that province code must be unique
* @Then /^I should be notified that province (code|name) must be unique$/
*/
public function iShouldBeNotifiedThatProvinceCodeMustBeUnique(): void
public function iShouldBeNotifiedThatProvinceCodeMustBeUnique(string $field): void
{
Assert::same($this->updatePage->getValidationMessage('code'), 'Province code must be unique.');
Assert::same($this->updatePage->getValidationMessage($field), sprintf('Province %s must be unique.', $field));
}
/**
* @Then I should be notified that all province codes and names within this country need to be unique
*/
public function iShouldBeNotifiedThatAllProvinceCodesAndNamesWithinThisCountryNeedToBeUnique(): void
{
Assert::inArray(
'All provinces within this country need to have unique codes and names.',
$this->updatePage->getFormValidationErrors(),
);
}
/**

View file

@ -207,7 +207,7 @@ final class ManagingTaxonsContext implements Context
{
$this->updatePage->open(['id' => $taxon->getId()]);
Assert::true($this->updatePage->hasResourceValues(['slug' => $slug]));
Assert::same($this->updatePage->getSlug(), $slug);
}
/**

View file

@ -105,6 +105,13 @@ class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
$provinceForm->fillField('Code', $code);
}
public function getFormValidationErrors(): array
{
$errors = $this->getElement('form')->findAll('css', '.sylius-validation-error:not(.pointing)');
return array_map(fn (NodeElement $element) => $element->getText(), $errors);
}
public function getValidationMessage(string $element): string
{
$provinceForm = $this->getLastProvinceElement();
@ -127,6 +134,7 @@ class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
return array_merge(parent::getDefinedElements(), [
'code' => '#sylius_country_code',
'enabled' => '#sylius_country_enabled',
'form' => 'form',
'provinces' => '#sylius_country_provinces',
]);
}

View file

@ -38,4 +38,7 @@ interface UpdatePageInterface extends BaseUpdatePageInterface
public function removeProvinceName(string $provinceName): void;
public function specifyProvinceCode(string $provinceCode): void;
/** @return array<array-key, string> */
public function getFormValidationErrors(): array;
}

View file

@ -43,9 +43,9 @@ class CreateConfigurableProductPage extends BaseCreatePage implements CreateConf
public function hasMainTaxonWithName(string $taxonName): bool
{
$this->openTaxonBookmarks();
Assert::notNull($this->getDocument()->find('css', '.search > .text'));
$mainTaxonElement = $this->getElement('main_taxon')->getParent();
return $taxonName === $this->getDocument()->find('css', '.search > .text')->getText();
return $taxonName === $mainTaxonElement->find('css', '.search > .text')->getText();
}
public function selectMainTaxon(TaxonInterface $taxon): void
@ -59,7 +59,9 @@ class CreateConfigurableProductPage extends BaseCreatePage implements CreateConf
public function selectOption(string $optionName): void
{
$this->getDocument()->selectFieldOption('Options', $optionName);
$option = $this->getElement('options_choice')->getParent();
AutocompleteHelper::chooseValue($this->getSession(), $option, $optionName);
}
public function attachImage(string $path, ?string $type = null): void
@ -85,6 +87,7 @@ class CreateConfigurableProductPage extends BaseCreatePage implements CreateConf
'images' => '#sylius_product_images',
'main_taxon' => '#sylius_product_mainTaxon',
'name' => '#sylius_product_translations_en_US_name',
'options_choice' => '#sylius_product_options',
'search' => '.ui.fluid.search.selection.dropdown',
'search_item_selected' => 'div.menu > div.item.selected',
'slug' => '#sylius_product_translations_en_US_slug',

View file

@ -133,8 +133,9 @@ class CreateSimpleProductPage extends BaseCreatePage implements CreateSimpleProd
public function hasMainTaxonWithName(string $taxonName): bool
{
$this->openTaxonBookmarks();
$mainTaxonElement = $this->getElement('main_taxon')->getParent();
return $taxonName === $this->getDocument()->find('css', '.search > .text')->getText();
return $taxonName === $mainTaxonElement->find('css', '.search > .text')->getText();
}
public function checkProductTaxon(TaxonInterface $taxon): void

View file

@ -16,6 +16,7 @@ namespace Sylius\Behat\Page\Admin\Product;
use Behat\Mink\Element\NodeElement;
use Sylius\Behat\Behaviour\ChecksCodeImmutability;
use Sylius\Behat\Page\Admin\Crud\UpdatePage as BaseUpdatePage;
use Sylius\Behat\Service\AutocompleteHelper;
use Sylius\Component\Taxonomy\Model\TaxonInterface;
use Webmozart\Assert\Assert;
@ -35,7 +36,9 @@ class UpdateConfigurableProductPage extends BaseUpdatePage implements UpdateConf
public function isProductOptionChosen(string $option): bool
{
return $this->getElement('options')->find('named', ['option', $option])->hasAttribute('selected');
$optionElement = $this->getElement('options')->getParent();
return AutocompleteHelper::isValueVisible($this->getSession(), $optionElement, $option);
}
public function isProductOptionsDisabled(): bool
@ -46,9 +49,9 @@ class UpdateConfigurableProductPage extends BaseUpdatePage implements UpdateConf
public function hasMainTaxonWithName(string $taxonName): bool
{
$this->openTaxonBookmarks();
Assert::notNull($this->getDocument()->find('css', '.search > .text'));
$mainTaxonElement = $this->getElement('main_taxon')->getParent();
return $taxonName === $this->getDocument()->find('css', '.search > .text')->getText();
return $taxonName === $mainTaxonElement->find('css', '.search > .text')->getText();
}
public function selectMainTaxon(TaxonInterface $taxon): void
@ -184,6 +187,7 @@ class UpdateConfigurableProductPage extends BaseUpdatePage implements UpdateConf
'channels' => '#sylius_product_channels',
'code' => '#sylius_product_code',
'images' => '#sylius_product_images',
'main_taxon' => '#sylius_product_mainTaxon',
'name' => '#sylius_product_translations_en_US_name',
'options' => '#sylius_product_options',
'price' => '#sylius_product_variant_price',

View file

@ -177,8 +177,9 @@ class UpdateSimpleProductPage extends BaseUpdatePage implements UpdateSimpleProd
public function hasMainTaxonWithName(string $taxonName): bool
{
$this->openTaxonBookmarks();
$mainTaxonElement = $this->getElement('main_taxon')->getParent();
return $taxonName === $this->getDocument()->find('css', '.search > .text')->getText();
return $taxonName === $mainTaxonElement->find('css', '.search > .text')->getText();
}
public function isTaxonChosen(string $taxonName): bool

View file

@ -19,6 +19,7 @@ use Sylius\Behat\Behaviour\ChecksCodeImmutability;
use Sylius\Behat\Page\Admin\Crud\UpdatePage as BaseUpdatePage;
use Sylius\Behat\Service\AutocompleteHelper;
use Sylius\Behat\Service\DriverHelper;
use Sylius\Behat\Service\JQueryHelper;
use Sylius\Behat\Service\SlugGenerationHelper;
use Sylius\Component\Core\Model\TaxonInterface;
use Webmozart\Assert\Assert;
@ -32,6 +33,7 @@ class UpdatePage extends BaseUpdatePage implements UpdatePageInterface
public function chooseParent(TaxonInterface $taxon): void
{
AutocompleteHelper::chooseValue($this->getSession(), $this->getElement('parent')->getParent(), $taxon->getName());
JQueryHelper::waitForAsynchronousActionsToFinish($this->getSession());
}
public function describeItAs(string $description, string $languageCode): void

View file

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* 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\Behat\Service;
use Sylius\Component\User\Model\UserInterface;
interface RememberMeAwareSecurityServiceInterface extends SecurityServiceInterface
{
public function logInWithRememberMe(UserInterface $user): void;
}

View file

@ -19,11 +19,12 @@ use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionFactoryInterface;
use Symfony\Component\Security\Core\Authentication\Token\RememberMeToken;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;
final class SecurityService implements SecurityServiceInterface
final class SecurityService implements RememberMeAwareSecurityServiceInterface
{
private string $sessionTokenVariable;
@ -48,6 +49,13 @@ final class SecurityService implements SecurityServiceInterface
$this->setToken($token);
}
public function logInWithRememberMe(UserInterface $user): void
{
$token = new RememberMeToken($user, $this->firewallContextName, 'secret');
$this->setToken($token);
}
public function logOut(): void
{
try {

View file

@ -62,5 +62,9 @@
<service id="sylius.validator.zone_cannot_contain_itself" class="Sylius\Bundle\AddressingBundle\Validator\Constraints\ZoneCannotContainItselfValidator">
<tag name="validator.constraint_validator" alias="sylius_zone_cannot_contain_itself_validator" />
</service>
<service id="sylius.validator.unique_province_collection" class="Sylius\Bundle\AddressingBundle\Validator\Constraints\UniqueProvinceCollectionValidator">
<tag name="validator.constraint_validator" alias="sylius_unique_province_collection_validator" />
</service>
</services>
</container>

View file

@ -36,6 +36,9 @@
</constraint>
</property>
<property name="provinces">
<constraint name="Sylius\Bundle\AddressingBundle\Validator\Constraints\UniqueProvinceCollection">
<option name="groups">sylius</option>
</constraint>
<constraint name="Valid" />
</property>
</class>

View file

@ -31,6 +31,7 @@ sylius:
not_blank: Please enter country ISO code.
regex: Country code can only be comprised of letters, numbers, dashes and underscores.
unique: Country ISO code must be unique.
unique_provinces: All provinces within this country need to have unique codes and names.
province:
code:
min_length: Province code must be at least 5 characters long|Province code must be at least 5 characters long.

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Sylius\Bundle\AddressingBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
final class UniqueProvinceCollection extends Constraint
{
public string $message = 'sylius.country.unique_provinces';
public function validatedBy(): string
{
return 'sylius_unique_province_collection_validator';
}
}

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Sylius\Bundle\AddressingBundle\Validator\Constraints;
use Doctrine\Common\Collections\Collection;
use Sylius\Component\Addressing\Model\ProvinceInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
final class UniqueProvinceCollectionValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
/** @var Collection<array-key, ProvinceInterface> $value */
Assert::allIsInstanceOf($value, ProvinceInterface::class);
/** @var UniqueProvinceCollection $constraint */
Assert::isInstanceOf($constraint, UniqueProvinceCollection::class);
if ($value->isEmpty()) {
return;
}
$provincesWithAnyRequiredData = $value->filter(
fn(ProvinceInterface $province): bool => null !== $province->getCode() || null !== $province->getName()
);
$codes = [];
$names = [];
foreach ($provincesWithAnyRequiredData as $province) {
$code = $province->getCode();
$name = $province->getName();
if (isset($code) && in_array($code, $codes) || isset($name) && in_array($name, $names)) {
$this->context->addViolation($constraint->message);
return;
}
$codes[] = $code;
$names[] = $name;
}
}
}

View file

@ -0,0 +1,199 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\AddressingBundle\Validator\Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\AddressingBundle\Validator\Constraints\UniqueProvinceCollection;
use Sylius\Component\Addressing\Model\ProvinceInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final class UniqueProvinceCollectionValidatorSpec extends ObjectBehavior
{
function let(ExecutionContextInterface $executionContext): void
{
$this->beConstructedWith();
$this->initialize($executionContext);
}
function it_is_a_constraint_validator(): void
{
$this->shouldImplement(ConstraintValidatorInterface::class);
}
function it_throws_exception_when_value_is_not_a_collection(): void
{
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [
new \stdClass(),
new UniqueProvinceCollection(),
])
;
}
function it_throws_exception_when_collection_contains_something_other_than_provinces(): void
{
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [
new ArrayCollection([new \stdClass()]),
new UniqueProvinceCollection(),
])
;
}
function it_throws_exception_when_constraint_is_not_a_unique_province_collection_codes(
Constraint $constraint,
): void {
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [
new ArrayCollection(),
$constraint,
])
;
}
function it_does_nothing_when_collection_is_empty(ExecutionContextInterface $context): void
{
$context->addViolation(Argument::any())->shouldNotBeCalled();
$this->validate(new ArrayCollection(), new UniqueProvinceCollection());
}
function it_does_nothing_when_all_provinces_have_unique_codes(
ExecutionContextInterface $executionContext,
ProvinceInterface $firstProvince,
ProvinceInterface $secondProvince,
): void {
$firstProvince->getCode()->willReturn('first');
$firstProvince->getName()->willReturn('first');
$secondProvince->getCode()->willReturn('second');
$secondProvince->getName()->willReturn('second');
$executionContext->addViolation(Argument::any())->shouldNotBeCalled();
$this->validate(
new ArrayCollection([
$firstProvince->getWrappedObject(),
$secondProvince->getWrappedObject(),
]),
new UniqueProvinceCollection(),
);
}
function it_checks_uniqueness_with_incomplete_codes(
ExecutionContextInterface $executionContext,
ProvinceInterface $province,
ProvinceInterface $sameProvinceWithCode,
): void {
$constraint = new UniqueProvinceCollection();
$province->getCode()->willReturn(null);
$province->getName()->willReturn('name');
$sameProvinceWithCode->getCode()->willReturn('code');
$sameProvinceWithCode->getName()->willReturn('name');
$executionContext->addViolation($constraint->message)->shouldBeCalled();
$this->validate(
new ArrayCollection([
$province->getWrappedObject(),
$sameProvinceWithCode->getWrappedObject(),
]),
$constraint,
);
}
function it_checks_uniqueness_with_incomplete_names(
ExecutionContextInterface $executionContext,
ProvinceInterface $province,
ProvinceInterface $sameProvinceWithName,
): void {
$constraint = new UniqueProvinceCollection();
$province->getCode()->willReturn('code');
$province->getName()->willReturn(null);
$sameProvinceWithName->getCode()->willReturn('code');
$sameProvinceWithName->getName()->willReturn('name');
$executionContext->addViolation($constraint->message)->shouldBeCalled();
$this->validate(
new ArrayCollection([
$province->getWrappedObject(),
$sameProvinceWithName->getWrappedObject(),
]),
$constraint,
);
}
function it_adds_violation_when_codes_are_duplicated(
ExecutionContextInterface $executionContext,
ProvinceInterface $firstProvince,
ProvinceInterface $secondProvince,
ProvinceInterface $thirdProvince,
): void {
$constraint = new UniqueProvinceCollection();
$firstProvince->getCode()->willReturn('same');
$firstProvince->getName()->willReturn('first');
$secondProvince->getCode()->willReturn('same');
$secondProvince->getName()->willReturn('second');
$thirdProvince->getCode()->willReturn('different');
$thirdProvince->getName()->willReturn('third');
$executionContext->addViolation($constraint->message)->shouldBeCalled();
$this->validate(
new ArrayCollection([
$firstProvince->getWrappedObject(),
$secondProvince->getWrappedObject(),
$thirdProvince->getWrappedObject(),
]),
$constraint,
);
}
function it_adds_violation_when_names_are_duplicated(
ExecutionContextInterface $executionContext,
ProvinceInterface $firstProvince,
ProvinceInterface $secondProvince,
ProvinceInterface $thirdProvince,
): void {
$constraint = new UniqueProvinceCollection();
$firstProvince->getCode()->willReturn('first');
$firstProvince->getName()->willReturn('first');
$secondProvince->getCode()->willReturn('second');
$secondProvince->getName()->willReturn('same');
$thirdProvince->getCode()->willReturn('third');
$thirdProvince->getName()->willReturn('same');
$executionContext->addViolation($constraint->message)->shouldBeCalled();
$this->validate(
new ArrayCollection([
$firstProvince->getWrappedObject(),
$secondProvince->getWrappedObject(),
$thirdProvince->getWrappedObject(),
]),
$constraint,
);
}
}

View file

@ -22,6 +22,10 @@ sylius_admin_ajax_customer_group:
resource: "@SyliusAdminBundle/Resources/config/routing/ajax/customer_group.yml"
prefix: /customer-groups
sylius_admin_ajax_product_option:
resource: "@SyliusAdminBundle/Resources/config/routing/ajax/product_option.yml"
prefix: /product-options
sylius_admin_ajax_render_province_form:
path: /render-province-form
defaults:

View file

@ -0,0 +1,29 @@
sylius_admin_ajax_product_options_by_phrase:
path: /search-phrase
methods: [GET]
defaults:
_controller: sylius.controller.product_option::indexAction
_format: json
_sylius:
serialization_groups: [Autocomplete]
permission: true
repository:
method: findByPhrase
arguments:
phrase: $phrase
locale: expr:service('sylius.context.locale').getLocaleCode()
limit: "!!int %sylius.ajax.product.option_autocomplete_limit%"
sylius_admin_ajax_find_product_options:
path: /options
methods: [GET]
defaults:
_controller: sylius.controller.product_option::indexAction
_format: json
_sylius:
serialization_groups: [Autocomplete]
permission: true
repository:
method: findByCodes
arguments:
codes: $code

View file

@ -24,6 +24,7 @@
<parameters>
<parameter key="sylius.ajax.product.autocomplete_limit">25</parameter>
<parameter key="sylius.ajax.product.variant_autocomplete_limit">25</parameter>
<parameter key="sylius.ajax.product.option_autocomplete_limit">10</parameter>
<parameter key="sylius.ajax.customer_group.autocomplete_limit">25</parameter>
<parameter key="sylius.message.admin_user_create.validation_groups" type="collection">
<parameter>sylius</parameter>

View file

@ -11,26 +11,21 @@ import $ from 'jquery';
const updateSlug = function updateSlug(element) {
const slugInput = element.parents('.content').find('[name*="[slug]"]');
const loadableParent = slugInput.parents('.field.loadable');
if (slugInput.attr('readonly') === 'readonly') {
return;
}
const loadableParent = slugInput.parents('.field.loadable');
const parentTaxonInput = $('#sylius_taxon_parent');
loadableParent.addClass('loading');
let data;
if (slugInput.attr('data-parent') != '' && slugInput.attr('data-parent') != undefined) {
if (parentTaxonInput.length > 0 && parentTaxonInput.val() !== '') {
data = {
name: element.val(),
locale: element.closest('[data-locale]').data('locale'),
parentId: slugInput.attr('data-parent'),
};
} else if ($('#sylius_taxon_parent').length > 0 && $('#sylius_taxon_parent').is(':visible') && $('#sylius_taxon_parent').val() != '') {
data = {
name: element.val(),
locale: element.closest('[data-locale]').data('locale'),
parentId: $('#sylius_taxon_parent').val(),
parentCode: parentTaxonInput.val(),
};
} else {
data = {
@ -51,6 +46,11 @@ const updateSlug = function updateSlug(element) {
slugInput.parents('.field').removeClass('error');
slugInput.parents('.field').find('.sylius-validation-error').remove();
}
},
error() {
slugInput.val('');
},
complete() {
loadableParent.removeClass('loading');
},
});
@ -78,6 +78,14 @@ $.fn.extend({
updateSlug(element);
}, 1000);
});
$('#sylius_taxon_parent').parent().on('change', () => {
const nameInput = $('[data-locale]').find('.content.active [name*="sylius_taxon[translations]"][name*="[name]"]');
if (nameInput.val() === '') {
return;
}
updateSlug($(nameInput));
});
$('.toggle-taxon-slug-modification').on('click', (event) => {
event.preventDefault();

View file

@ -39,7 +39,7 @@
.attribute-row {
flex-wrap: wrap;
margin: 10px 0;
margin: 10px 0 0;
@media (min-width: 1152px) {
display: flex;
@ -50,11 +50,20 @@
align-self: center;
width: 200px;
margin-right: 20px;
padding-top: 5px;
padding-bottom: 5px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&.field.required {
margin: 0 20px 0 0;
> label {
margin: 0 !important;
font-size: 1rem;
}
}
span.language {
font-size: 11px;
}
i {
width: 20px;
@ -85,6 +94,10 @@
min-height: 6em !important;
}
.attribute-action {
margin: auto;
}
.attribute-action > * {
margin: 4px 0 !important;

View file

@ -1,5 +1,5 @@
{{ form_errors(form) }}
<div class="ui segment">
{{ form_errors(form) }}
{{ form_row(form.code) }}
{{ form_row(form.enabled) }}
</div>

View file

@ -17,10 +17,11 @@
<div class="attribute" data-id="{{ code }}">
{% set id = form.vars.label|replace({' ': '_'})|lower %}
<div class="attribute-row">
<div class="attribute-label {% if not localeCode or localeCode == sylius_base_locale %} required field {% endif %}">
<div class="attribute-label{% if not localeCode or localeCode == sylius_base_locale %} required field{% endif %}">
<label>
{% if localeCode %}
{{ flags.fromLocaleCode(localeCode) }}
<span class="language">{{ flags.fromLocaleCode(localeCode) }} {{ localeCode|sylius_locale_name }}</span>
<br>
{% else %}
<i class="globe icon"></i>
{% endif %}

View file

@ -41,13 +41,16 @@
<div class="attribute" data-id="{{ form.vars.data.attribute.code }}">
<div class="attribute-row">
<div class="attribute-label">
{% if form.localeCode.vars.value %}
{{ flags.fromLocaleCode(form.localeCode.vars.value) }}
{% else %}
<i class="globe icon"></i>
{% endif %}
{{ form.vars.value.attribute.translation(form.vars.value.localeCode).name }}
<div class="attribute-label{% if not form.localeCode.vars.value or form.localeCode.vars.value == sylius_base_locale %} required field{% endif %}">
<label>
{% if form.localeCode.vars.value %}
<span class="language">{{ flags.fromLocaleCode(form.localeCode.vars.value) }} {{ form.localeCode.vars.value|sylius_locale_name }}</span>
<br>
{% else %}
<i class="globe icon"></i>
{% endif %}
{{ form.vars.value.attribute.translation(form.vars.value.localeCode).name }}
</label>
</div>
<div class="attribute-input" data-test-product-attribute-value-in-locale="{{ "%s %s"|format(form.vars.data.attribute.name, form.localeCode.vars.value) }}">
<div {{ 'checkbox' in form.children.value.vars.block_prefixes ? 'class="ui toggle checkbox"' : '' }}>

View file

@ -14,7 +14,7 @@
{% if product.simple %}
{{ form_row(form.variant.shippingRequired) }}
{% else %}
{{ form_row(form.options) }}
{{ form_row(form.options, {'remote_url': path('sylius_admin_ajax_product_options_by_phrase'), 'load_edit_url': path('sylius_admin_ajax_find_product_options')}) }}
{{ form_row(form.variantSelectionMethod) }}
{% endif %}

View file

@ -46,4 +46,43 @@ class ProductOptionRepository extends EntityRepository implements ProductOptionR
->getResult()
;
}
public function findByPhrase(string $phrase, string $locale, int $limit = 10): array
{
$subqueryBuilder = $this->createQueryBuilder('sq')
->innerJoin('sq.translations', 'translation', 'WITH', 'translation.name LIKE :name')
->groupBy('sq.id')
->addGroupBy('translation.translatable')
->orderBy('translation.translatable', 'DESC')
;
$queryBuilder = $this->createQueryBuilder('o');
/** @var ProductOptionInterface[] $results */
$results = $queryBuilder
->andWhere($queryBuilder->expr()->in('o', $subqueryBuilder->getDQL()))
->setParameter('name', '%' . $phrase . '%')
->setMaxResults($limit)
->getQuery()
->getResult()
;
foreach ($results as $result) {
$result->setFallbackLocale(array_key_first($result->getTranslations()->toArray()));
}
return $results;
}
public function findByCodes(array $codes = []): array
{
$expr = $this->getEntityManager()->getExpressionBuilder();
return $this->createQueryBuilder('o')
->andWhere($expr->in('o.code', ':codes'))
->setParameter('codes', $codes)
->getQuery()
->getResult()
;
}
}

View file

@ -13,7 +13,7 @@ declare(strict_types=1);
namespace Sylius\Bundle\ProductBundle\Form\EventSubscriber;
use Sylius\Bundle\ProductBundle\Form\Type\ProductOptionChoiceType;
use Sylius\Bundle\ProductBundle\Form\Type\ProductOptionAutocompleteType;
use Sylius\Component\Product\Model\ProductInterface;
use Sylius\Component\Product\Resolver\ProductVariantResolverInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@ -46,7 +46,7 @@ final class ProductOptionFieldSubscriber implements EventSubscriberInterface
/** Options should be disabled for configurable product if it has at least one defined variant */
$disableOptions = (null !== $this->variantResolver->getVariant($product)) && $product->hasVariants();
$form->add('options', ProductOptionChoiceType::class, [
$form->add('options', ProductOptionAutocompleteType::class, [
'required' => false,
'disabled' => $disableOptions,
'multiple' => true,

View file

@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* 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\ProductBundle\Form\Type;
use Sylius\Bundle\ResourceBundle\Form\Type\ResourceAutocompleteChoiceType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class ProductOptionAutocompleteType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choice_value' => 'code',
'choice_name' => 'name',
'resource' => 'sylius.product_option',
]);
}
/**
* @psalm-suppress MissingPropertyType
*/
public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['remote_criteria_type'] = 'contains';
$view->vars['remote_criteria_name'] = 'phrase';
}
public function getParent(): string
{
return ResourceAutocompleteChoiceType::class;
}
public function getBlockPrefix(): string
{
return 'sylius_product_option_autocomplete_choice';
}
}

View file

@ -6,11 +6,11 @@ Sylius\Component\Product\Model\ProductOption:
expose: true
type: integer
xml_attribute: true
groups: [Default, Detailed]
groups: [Default, Detailed, Autocomplete]
code:
expose: true
type: string
groups: [Default, Detailed]
groups: [Default, Detailed, Autocomplete]
position:
expose: true
type: integer
@ -23,3 +23,7 @@ Sylius\Component\Product\Model\ProductOption:
expose: true
type: iterable
groups: [Default, Detailed]
virtual_properties:
getName:
serialized_name: name
groups: [Default, Detailed, Autocomplete]

View file

@ -12,7 +12,7 @@
{{ form_widget(form.comment, sylius_test_form_attribute('comment')|sylius_merge_recursive( {'attr': {'placeholder': 'sylius.ui.write_your_own_review'|trans}})) }}
{{ form_errors(form.comment) }}
</div>
{% if not is_granted("IS_AUTHENTICATED_FULLY") %}
{% if not is_granted("IS_AUTHENTICATED_REMEMBERED") %}
<div class="field">
{{ form_widget(form.author.email, sylius_test_form_attribute('author-email')|sylius_merge_recursive( {'attr': {'placeholder': 'sylius.ui.email'|trans}})) }}
{{ form_errors(form.author.email) }}

View file

@ -17,6 +17,7 @@ use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Sylius\Component\Taxonomy\Generator\TaxonSlugGeneratorInterface;
use Sylius\Component\Taxonomy\Model\TaxonInterface;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -33,8 +34,11 @@ final class TaxonSlugController
public function generateAction(Request $request): Response
{
$name = (string) $request->query->get('name');
if ('' === trim($name)) {
throw new BadRequestException('Cannot generate slug without a name');
}
$locale = (string) $request->query->get('locale');
$parentId = $request->query->get('parentId');
/** @var TaxonInterface $taxon */
$taxon = $this->taxonFactory->createNew();
@ -42,12 +46,25 @@ final class TaxonSlugController
$taxon->setFallbackLocale($locale);
$taxon->setName($name);
if (null !== $parentId) {
$taxon->setParent($this->taxonRepository->find($parentId));
}
$taxon->setParent($this->getParentTaxon($request));
return new JsonResponse([
'slug' => $this->taxonSlugGenerator->generate($taxon, $locale),
]);
}
private function getParentTaxon(Request $request): ?TaxonInterface
{
$parentCode = $request->query->get('parentCode');
if (null !== $parentCode) {
return $this->taxonRepository->findOneBy(['code' => $parentCode]);
}
$parentId = $request->query->get('parentId');
if (null !== $parentId) {
return $this->taxonRepository->find($parentId);
}
return null;
}
}

View file

@ -206,7 +206,7 @@
{%- endblock sylius_translations_row %}
{% block sylius_resource_autocomplete_choice_row %}
<div class="{% if required %}required {% endif %}field{% if (not compound or force_error|default(false)) and not valid %} error{% endif %}">
<div class="{% if required %}required {% endif %}{% if disabled %}disabled {% endif %}field{% if (not compound or force_error|default(false)) and not valid %} error{% endif %}">
{{- form_label(form) -}}
<div
class="sylius-autocomplete ui fluid search selection dropdown {% if multiple %}multiple{% endif %}"

View file

@ -27,7 +27,19 @@ interface ProductOptionRepositoryInterface extends RepositoryInterface
public function createListQueryBuilder(string $locale): QueryBuilder;
/**
* @return array|ProductOptionInterface[]
* @return ProductOptionInterface[]
*/
public function findByName(string $name, string $locale): array;
/**
* @return ProductOptionInterface[]
*/
public function findByPhrase(string $phrase, string $locale, int $limit = 10): array;
/**
* @param string[] $codes
*
* @return ProductOptionInterface[]
*/
public function findByCodes(array $codes = []): array;
}