Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG-2026.2.x.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
## 2026.2.2

### Indexable price rule conditions and pluggable category sorting

Generic extension points for bundles that precompute prices or add their own listing sort options:

- Added `CoreShop\Component\Rule\Condition\IndexableConditionCheckerInterface` (a condition declares the context
dimensions its outcome depends on) and `IndexableConditionValuesInterface` (the ids it explicitly references). The
store, currency, country, zone, customer group, guest, customers, companies, products, categories and timespan
condition checkers implement them.
- Added the product price rule condition `companies` (`CompaniesConditionChecker`, `CompaniesConfigurationType`).
- Added `CoreShop\Bundle\RuleBundle\Collector\ConditionMetaCollector` and `ConditionMetaProviderInterface` (tag
`coreshop.rule.condition_meta_provider`): bundles contribute per condition type metadata that the Studio rule
editor receives as `conditionMeta`. A provider delivering `indexable` gets an "Indexable" / "Not indexable" badge
on the condition card; without a provider nothing is shown.
- Added `CoreShop\Component\Index\Listing\IdSubselectListingInterface`, implemented by the MySQL listing: the ids
matching a listing as an SQL subselect for set based joins with other tables.
- Added `CoreShop\Bundle\FrontendBundle\Listing\CategorySortApplierInterface`; the category page delegates its
sorting to it, bundles decorate it to add sort options. Sort labels come from `coreshop.ui.sort.<key>_<direction>`
with the previous "<Key> ascending" fallback.
- Added `CoreShop\Component\Product\Rule\Fetcher\ValidRulesFetcherInterface::CONTEXT_NO_CACHE`; a price context
carrying it bypasses `MemoryCachedValidRuleFetcher`.
- Fixed the swapped German translations of the name sort options.
- Removed the `enum: string` mapping type from `config/packages/doctrine_mapping_types.yaml`: DBAL 4.4 maps ENUM
natively and the mapping broke every schema introspection (`Column "type" has invalid type`).

### Studio build archives extract on a fresh install

The bundles that ship their Studio build as `Resources/build-dist/build-<id>.zip` now also
Expand Down
1 change: 0 additions & 1 deletion config/packages/doctrine_mapping_types.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,3 @@ doctrine:
default:
mapping_types:
bit: boolean
enum: string
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ export const coreshopProductServiceIds = {
| `discountPrice` | ProductBundle | Discounted fixed price |
| `notDiscountableCustomAttributes` | ProductBundle | Marks products as non-discountable |

## Condition Metadata from Bundles

Bundles can attach metadata to condition types, for example to mark which conditions they support. The backend
collects it from all services tagged `coreshop.rule.condition_meta_provider`
(`CoreShop\Bundle\RuleBundle\Collector\ConditionMetaProviderInterface`) and returns it as `conditionMeta`
(`{ type: { ...meta } }`) in `GET /pimcore-studio/api/coreshop/product_price_rules/get-config` and in the
`coreShopProductSpecificPriceRules` field data. `registerSchemaComponentsFromConfig` / `registerSchemaComponentsFromMaps`
store it on the `ConditionRegistry` (`getMeta(type)`), so nested conditions see it too. The core ships no provider.
If a provider delivers the key `indexable` (boolean) and optionally `dimensions` (string list), the condition card
shows an "Indexable" / "Not indexable" badge with the dimensions in the tooltip.

## Hand-Written React Components (Rare)

If your condition/action needs custom interactive behavior that cannot be expressed as a Symfony FormType, you can still write a React component:
Expand Down
2 changes: 1 addition & 1 deletion src/CoreShop/Behat/Context/Setup/ProductContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function theSiteHasAProductPricedAt(string $productName, int $price = 100
}

/**
* @Given /^the (product "[^"]+") is (:?also) priced at (\d+) for (store "[^"]+")$/
* @Given /^the (product "[^"]+") is (?:also )?priced at (\d+) for (store "[^"]+")$/
* @Given /^the (product) is priced at (\d+) for (store "[^"]+")$/
*/
public function theProductIsPriced(ProductInterface $product, int $price, StoreInterface $store): void
Expand Down
52 changes: 51 additions & 1 deletion src/CoreShop/Behat/Context/Setup/ProductPriceRuleContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Behat\Behat\Context\Context;
use CoreShop\Bundle\CoreBundle\Form\Type\ProductPriceRule\Condition\QuantityConfigurationType;
use CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CategoriesConfigurationType;
use CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CompaniesConfigurationType;
use CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CountriesConfigurationType;
use CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CurrenciesConfigurationType;
use CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CustomerGroupsConfigurationType;
Expand All @@ -34,8 +35,8 @@
use CoreShop\Bundle\ProductBundle\Form\Type\Rule\Action\DiscountPercentConfigurationType;
use CoreShop\Bundle\ProductBundle\Form\Type\Rule\Action\PriceConfigurationType;
use CoreShop\Bundle\ProductBundle\Form\Type\Rule\Condition\ProductPriceNestedConfigurationType;
use CoreShop\Bundle\RuleBundle\Form\Type\Rule\Condition\TimespanConfigurationType;
use CoreShop\Bundle\ResourceBundle\Form\Registry\FormTypeRegistryInterface;
use CoreShop\Bundle\RuleBundle\Form\Type\Rule\Condition\TimespanConfigurationType;
use CoreShop\Bundle\RuleBundle\Form\Type\Rule\EmptyConfigurationFormType;
use CoreShop\Bundle\TestBundle\Service\SharedStorageInterface;
use CoreShop\Component\Address\Model\ZoneInterface;
Expand All @@ -45,6 +46,7 @@
use CoreShop\Component\Core\Model\CustomerInterface;
use CoreShop\Component\Core\Model\ProductInterface;
use CoreShop\Component\Core\Model\StoreInterface;
use CoreShop\Component\Customer\Model\CompanyInterface;
use CoreShop\Component\Customer\Model\CustomerGroupInterface;
use CoreShop\Component\Order\Model\CartPriceRuleInterface;
use CoreShop\Component\Product\Model\ProductPriceRuleInterface;
Expand Down Expand Up @@ -180,6 +182,23 @@ public function theProductPriceRuleHasACustomerCondition(
]));
}

/**
* @Given /^the (price rule "[^"]+") has a condition companies with (company "[^"]+")$/
* @Given /^the (price rule) has a condition companies with (company "[^"]+")$/
*/
public function theProductPriceRuleHasACompanyCondition(
ProductPriceRuleInterface $rule,
CompanyInterface $company,
): void {
$this->assertConditionForm(CompaniesConfigurationType::class, 'companies');

$this->addCondition($rule, $this->createConditionWithForm('companies', [
'companies' => [
$company->getId(),
],
]));
}

/**
* @Given /^the (price rule "[^"]+") has a condition guest$/
* @Given /^the (price rule) has a condition guest$/
Expand Down Expand Up @@ -467,6 +486,37 @@ public function theProductsPriceRuleHasANestedConditionWithProduct(ProductPriceR
]));
}

/**
* @Given /^the (price rule "[^"]+") has a condition nested with operator "([^"]+)" with (customer-group "[^"]+") and (country "[^"]+")$/
* @Given /^the (price rule) has a condition nested with operator "([^"]+)" with (customer-group "[^"]+") and (country "[^"]+")$/
*/
public function theProductsPriceRuleHasANestedConditionWithCustomerGroupAndCountry(
ProductPriceRuleInterface $rule,
string $operator,
CustomerGroupInterface $group,
CountryInterface $country,
): void {
$this->assertConditionForm(ProductPriceNestedConfigurationType::class, 'nested');

$this->addCondition($rule, $this->createConditionWithForm('nested', [
'operator' => $operator,
'conditions' => [
[
'type' => 'customerGroups',
'configuration' => [
'customerGroups' => [$group->getId()],
],
],
[
'type' => 'countries',
'configuration' => [
'countries' => [$country->getId()],
],
],
],
]));
}

/**
* @Given /^the (price rule "[^"]+") has a condition not combinable with (cart rule "[^"]+")$/
* @Given /^the (price rule) has a condition not combinable with (cart rule "[^"]+")$/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

/*
* CoreShop
*
* This source file is available under the terms of the
* CoreShop Commercial License (CCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) CoreShop GmbH (https://www.coreshop.com)
* @license CoreShop Commercial License (CCL)
*
*/

namespace CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition;

use CoreShop\Bundle\StudioFormBundle\Form\Type\PimcoreRelationType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;

final class CompaniesConfigurationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('companies', PimcoreRelationType::class, [
'label' => 'coreshop_condition_companies',
'relation_class' => 'CoreShopCompany',
'multiple' => true,
])
;
}

public function getBlockPrefix(): string
{
return 'coreshop_rule_condition_companies';
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ services:
- { name: coreshop.product_price_rule.condition, type: customers, form-type: CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CustomersConfigurationType }
- { name: coreshop.product_specific_price_rule.condition, type: customers, form-type: CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CustomersConfigurationType }

coreshop.rule.condition.companies:
class: CoreShop\Component\Core\Product\Rule\Condition\CompaniesConditionChecker
tags:
- { name: coreshop.product_price_rule.condition, type: companies, form-type: CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CompaniesConfigurationType }
- { name: coreshop.product_specific_price_rule.condition, type: companies, form-type: CoreShop\Bundle\CoreBundle\Form\Type\Rule\Condition\CompaniesConfigurationType }

coreshop.rule.condition.customer_groups:
class: CoreShop\Component\Core\Product\Rule\Condition\CustomerGroupsConditionChecker
tags:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ coreshop_product_pricerules: 'Produkt Preisregel'
coreshop_prices: 'Preise'
coreshop_prices_are_gross: 'Preise sind Bruttopreise'
coreshop_condition_stores: 'Stores'
coreshop_condition_companies: 'Firmen'
coreshop_condition_customers: 'Kunden'
coreshop_condition_customerGroups: 'Kundengruppen'
coreshop_condition_guest: 'Gast'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ coreshop_product_pricerules: 'Product Price Rules'
coreshop_prices: 'Prices'
coreshop_prices_are_gross: 'Prices are gross prices'
coreshop_condition_stores: 'Stores'
coreshop_condition_companies: 'Companies'
coreshop_condition_customers: 'Customers'
coreshop_condition_customerGroups: 'Customer Groups'
coreshop_condition_guest: 'Guest'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

namespace CoreShop\Bundle\FrontendBundle\Controller;

use CoreShop\Bundle\FrontendBundle\Listing\CategorySortApplierInterface;
use CoreShop\Component\Core\Configuration\ConfigurationServiceInterface;
use CoreShop\Component\Core\Context\ShopperContextInterface;
use CoreShop\Component\Core\Model\CategoryInterface;
Expand Down Expand Up @@ -158,10 +159,9 @@ public function detail(Request $request, CategoryInterface $category): Response
'coreshop.frontend.category.default_sort_direction',
) : $orderDirection);
$sort = $this->getParameterFromRequest($request, 'sort', $sortKey);
$sortParsed = $this->parseSorting($sort);
$sortParsed = $this->parseSorting($sort, array_unique(array_filter([$orderKey, ...$this->getSortApplier()->getSortOptions()])));

$filteredList->setOrderKey($sortParsed['name']);
$filteredList->setOrder($sortParsed['direction']);
$this->getSortApplier()->applyToIndexListing($filteredList, $sortParsed['name'], $sortParsed['direction']);

$currentFilter = $this->container->get(FilterProcessorInterface::class)->processConditions(
$category->getFilter(),
Expand Down Expand Up @@ -194,7 +194,7 @@ public function detail(Request $request, CategoryInterface $category): Response
'coreshop.frontend.category.default_sort_direction',
),
);
$sortParsed = $this->parseSorting($sort);
$sortParsed = $this->parseSorting($sort, $this->getSortApplier()->getSortOptions());

$categories = [$category];
if ($displaySubCategories === true) {
Expand All @@ -206,13 +206,11 @@ public function detail(Request $request, CategoryInterface $category): Response
}
}

$options = [
'order_key' => $sortParsed['name'],
'order' => $sortParsed['direction'],
$options = $this->getSortApplier()->applyToProductListingOptions([
'categories' => $categories,
'store' => $this->getContext()->getStore(),
'return_type' => 'list',
];
], $sortParsed['name'], $sortParsed['direction']);

if ($variantMode !== ListingInterface::VARIANT_MODE_HIDE) {
$options['object_types'] = [AbstractObject::OBJECT_TYPE_OBJECT, AbstractObject::OBJECT_TYPE_VARIANT];
Expand All @@ -235,7 +233,10 @@ public function detail(Request $request, CategoryInterface $category): Response
$viewParameters['type'] = $type;
$viewParameters['perPageAllowed'] = $allowedPerPage;
$viewParameters['sort'] = $sort;
$viewParameters['validSortElements'] = $this->getParameter('coreshop.frontend.category.valid_sort_options');
$viewParameters['validSortElements'] = array_values(array_unique([
...$this->getParameter('coreshop.frontend.category.valid_sort_options'),
...$this->getSortApplier()->getSortOptions(),
]));

foreach ($paginator as $product) {
$this->container->get(TrackerInterface::class)->trackProductImpression($product);
Expand Down Expand Up @@ -265,7 +266,16 @@ protected function validateCategory(Request $request, CategoryInterface $categor
}
}

protected function parseSorting(string $sortString): array
protected function getSortApplier(): CategorySortApplierInterface
{
return $this->container->get(CategorySortApplierInterface::class);
}

/**
* @param string[] $additionalValidNames sort names accepted besides the configured valid_sort_options,
* e.g. the order key configured on the category's filter
*/
protected function parseSorting(string $sortString, array $additionalValidNames = []): array
{
$sort = [
'name' => 'name',
Expand All @@ -281,10 +291,9 @@ protected function parseSorting(string $sortString): array
$name = $sortString[0];
$direction = $sortString[1];

if (in_array($name, $this->getParameter('coreshop.frontend.category.valid_sort_options')) && in_array(
$direction,
['desc', 'asc'],
)) {
$validNames = [...$this->getParameter('coreshop.frontend.category.valid_sort_options'), ...$additionalValidNames];

if (in_array($name, $validNames, true) && in_array($direction, ['desc', 'asc'], true)) {
return [
'name' => $name,
'direction' => $direction,
Expand Down Expand Up @@ -324,6 +333,7 @@ public static function getSubscribedServices(): array
TrackerInterface::class,
FilteredListingFactoryInterface::class,
FilterProcessorInterface::class,
CategorySortApplierInterface::class,
new SubscribedService('coreshop.repository.product', ProductRepositoryInterface::class),
]);
}
Expand Down
45 changes: 45 additions & 0 deletions src/CoreShop/Bundle/FrontendBundle/Listing/CategorySortApplier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

/*
* CoreShop
*
* This source file is available under the terms of the
* CoreShop Commercial License (CCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) CoreShop GmbH (https://www.coreshop.com)
* @license CoreShop Commercial License (CCL)
*
*/

namespace CoreShop\Bundle\FrontendBundle\Listing;

use CoreShop\Component\Index\Listing\ListingInterface;

/**
* Default behaviour: every sort name is an index column (filtered listing) or a product listing column.
*/
final class CategorySortApplier implements CategorySortApplierInterface
{
public function getSortOptions(): array
{
return [];
}

public function applyToIndexListing(ListingInterface $list, string $name, string $direction): void
{
$list->setOrderKey($name);
$list->setOrder($direction);
}

public function applyToProductListingOptions(array $options, string $name, string $direction): array
{
$options['order_key'] = $name;
$options['order'] = $direction;

return $options;
}
}
Loading
Loading