src/Storefront/Theme/ThemeService.php line 179

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Storefront\Theme;
  3. use Doctrine\DBAL\Connection;
  4. use Shopware\Core\Framework\Context;
  5. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  6. use Shopware\Core\Framework\DataAbstractionLayer\Exception\InconsistentCriteriaIdsException;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  8. use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult;
  9. use Shopware\Core\Framework\Log\Package;
  10. use Shopware\Core\Framework\Uuid\Uuid;
  11. use Shopware\Storefront\Theme\ConfigLoader\AbstractConfigLoader;
  12. use Shopware\Storefront\Theme\Event\ThemeAssignedEvent;
  13. use Shopware\Storefront\Theme\Event\ThemeConfigChangedEvent;
  14. use Shopware\Storefront\Theme\Event\ThemeConfigResetEvent;
  15. use Shopware\Storefront\Theme\Exception\InvalidThemeConfigException;
  16. use Shopware\Storefront\Theme\Exception\InvalidThemeException;
  17. use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfigurationCollection;
  18. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  19. #[Package('storefront')]
  20. class ThemeService
  21. {
  22.     /**
  23.      * @internal
  24.      */
  25.     public function __construct(private readonly StorefrontPluginRegistryInterface $extensionRegistry, private readonly EntityRepository $themeRepository, private readonly EntityRepository $themeSalesChannelRepository, private readonly ThemeCompilerInterface $themeCompiler, private readonly EventDispatcherInterface $dispatcher, private readonly AbstractConfigLoader $configLoader, private readonly Connection $connection)
  26.     {
  27.     }
  28.     /**
  29.      * Only compiles a single theme/saleschannel combination.
  30.      * Use `compileThemeById` to compile all dependend saleschannels
  31.      */
  32.     public function compileTheme(
  33.         string $salesChannelId,
  34.         string $themeId,
  35.         Context $context,
  36.         ?StorefrontPluginConfigurationCollection $configurationCollection null,
  37.         bool $withAssets true
  38.     ): void {
  39.         $this->themeCompiler->compileTheme(
  40.             $salesChannelId,
  41.             $themeId,
  42.             $this->configLoader->load($themeId$context),
  43.             $configurationCollection ?? $this->extensionRegistry->getConfigurations(),
  44.             $withAssets,
  45.             $context
  46.         );
  47.     }
  48.     /**
  49.      * Compiles all dependend saleschannel/Theme combinations
  50.      *
  51.      * @return array<int, string>
  52.      */
  53.     public function compileThemeById(
  54.         string $themeId,
  55.         Context $context,
  56.         ?StorefrontPluginConfigurationCollection $configurationCollection null,
  57.         bool $withAssets true
  58.     ): array {
  59.         $mappings $this->getThemeDependencyMapping($themeId);
  60.         $compiledThemeIds = [];
  61.         /** @var ThemeSalesChannel $mapping */
  62.         foreach ($mappings as $mapping) {
  63.             $this->themeCompiler->compileTheme(
  64.                 $mapping->getSalesChannelId(),
  65.                 $mapping->getThemeId(),
  66.                 $this->configLoader->load($mapping->getThemeId(), $context),
  67.                 $configurationCollection ?? $this->extensionRegistry->getConfigurations(),
  68.                 $withAssets,
  69.                 $context
  70.             );
  71.             $compiledThemeIds[] = $mapping->getThemeId();
  72.         }
  73.         return $compiledThemeIds;
  74.     }
  75.     /**
  76.      * @param array<string, mixed>|null $config
  77.      */
  78.     public function updateTheme(string $themeId, ?array $config, ?string $parentThemeIdContext $context): void
  79.     {
  80.         $criteria = new Criteria([$themeId]);
  81.         $criteria->addAssociation('salesChannels');
  82.         /** @var ThemeEntity|null $theme */
  83.         $theme $this->themeRepository->search($criteria$context)->get($themeId);
  84.         if (!$theme) {
  85.             throw new InvalidThemeException($themeId);
  86.         }
  87.         $data = ['id' => $themeId];
  88.         if ($config) {
  89.             foreach ($config as $key => $value) {
  90.                 $data['configValues'][$key] = $value;
  91.             }
  92.         }
  93.         if ($parentThemeId) {
  94.             $data['parentThemeId'] = $parentThemeId;
  95.         }
  96.         if (\array_key_exists('configValues'$data)) {
  97.             $this->dispatcher->dispatch(new ThemeConfigChangedEvent($themeId$data['configValues']));
  98.         }
  99.         if (\array_key_exists('configValues'$data) && $theme->getConfigValues()) {
  100.             $submittedChanges $data['configValues'];
  101.             $currentConfig $theme->getConfigValues();
  102.             $data['configValues'] = array_replace_recursive($currentConfig$data['configValues']);
  103.             foreach ($submittedChanges as $key => $changes) {
  104.                 if (isset($changes['value']) && \is_array($changes['value']) && isset($currentConfig[(string) $key]) && \is_array($currentConfig[(string) $key])) {
  105.                     $data['configValues'][$key]['value'] = array_unique($changes['value']);
  106.                 }
  107.             }
  108.         }
  109.         $this->themeRepository->update([$data], $context);
  110.         if ($theme->getSalesChannels() === null) {
  111.             return;
  112.         }
  113.         $this->compileThemeById($themeId$contextnullfalse);
  114.     }
  115.     public function assignTheme(string $themeIdstring $salesChannelIdContext $contextbool $skipCompile false): bool
  116.     {
  117.         if (!$skipCompile) {
  118.             $this->compileTheme($salesChannelId$themeId$context);
  119.         }
  120.         $this->themeSalesChannelRepository->upsert([[
  121.             'themeId' => $themeId,
  122.             'salesChannelId' => $salesChannelId,
  123.         ]], $context);
  124.         $this->dispatcher->dispatch(new ThemeAssignedEvent($themeId$salesChannelId));
  125.         return true;
  126.     }
  127.     public function resetTheme(string $themeIdContext $context): void
  128.     {
  129.         $criteria = new Criteria([$themeId]);
  130.         $theme $this->themeRepository->search($criteria$context)->get($themeId);
  131.         if (!$theme) {
  132.             throw new InvalidThemeException($themeId);
  133.         }
  134.         $data = ['id' => $themeId];
  135.         $data['configValues'] = null;
  136.         $this->dispatcher->dispatch(new ThemeConfigResetEvent($themeId));
  137.         $this->themeRepository->update([$data], $context);
  138.     }
  139.     /**
  140.      * @throws InvalidThemeConfigException
  141.      * @throws InvalidThemeException
  142.      * @throws InconsistentCriteriaIdsException
  143.      *
  144.      * @return array<string, mixed>
  145.      */
  146.     public function getThemeConfiguration(string $themeIdbool $translateContext $context): array
  147.     {
  148.         $criteria = new Criteria();
  149.         $criteria->setTitle('theme-service::load-config');
  150.         $themes $this->themeRepository->search($criteria$context);
  151.         $theme $themes->get($themeId);
  152.         /** @var ThemeEntity|null $theme */
  153.         if (!$theme) {
  154.             throw new InvalidThemeException($themeId);
  155.         }
  156.         /** @var ThemeEntity $baseTheme */
  157.         $baseTheme $themes->filter(fn (ThemeEntity $themeEntry) => $themeEntry->getTechnicalName() === StorefrontPluginRegistry::BASE_THEME_NAME)->first();
  158.         $baseThemeConfig $this->mergeStaticConfig($baseTheme);
  159.         $themeConfigFieldFactory = new ThemeConfigFieldFactory();
  160.         $configFields = [];
  161.         $labels array_replace_recursive($baseTheme->getLabels() ?? [], $theme->getLabels() ?? []);
  162.         if ($theme->getParentThemeId()) {
  163.             $parentThemes $this->getParentThemeIds($themes$theme);
  164.             foreach ($parentThemes as $parentTheme) {
  165.                 $configuredParentTheme $this->mergeStaticConfig($parentTheme);
  166.                 $baseThemeConfig array_replace_recursive($baseThemeConfig$configuredParentTheme);
  167.                 $labels array_replace_recursive($labels$parentTheme->getLabels() ?? []);
  168.             }
  169.         }
  170.         $configuredTheme $this->mergeStaticConfig($theme);
  171.         $themeConfig array_replace_recursive($baseThemeConfig$configuredTheme);
  172.         foreach ($themeConfig['fields'] ?? [] as $name => &$item) {
  173.             $configFields[$name] = $themeConfigFieldFactory->create($name$item);
  174.             if (
  175.                 isset($item['value'])
  176.                 && isset($configuredTheme['fields'])
  177.                 && \is_array($item['value'])
  178.                 && \array_key_exists($name$configuredTheme['fields'])
  179.             ) {
  180.                 $configFields[$name]->setValue($configuredTheme['fields'][$name]['value']);
  181.             }
  182.         }
  183.         $configFields json_decode((string) json_encode($configFields\JSON_THROW_ON_ERROR), true512\JSON_THROW_ON_ERROR);
  184.         if ($translate && !empty($labels)) {
  185.             $configFields $this->translateLabels($configFields$labels);
  186.         }
  187.         $helpTexts array_replace_recursive($baseTheme->getHelpTexts() ?? [], $theme->getHelpTexts() ?? []);
  188.         if ($translate && !empty($helpTexts)) {
  189.             $configFields $this->translateHelpTexts($configFields$helpTexts);
  190.         }
  191.         $themeConfig['fields'] = $configFields;
  192.         $themeConfig['currentFields'] = [];
  193.         $themeConfig['baseThemeFields'] = [];
  194.         foreach ($themeConfig['fields'] as $field => $fieldItem) {
  195.             $isInherited $this->fieldIsInherited($field$configuredTheme);
  196.             $themeConfig['currentFields'][$field]['isInherited'] = $isInherited;
  197.             if ($isInherited) {
  198.                 $themeConfig['currentFields'][$field]['value'] = null;
  199.             } elseif (\array_key_exists('value'$fieldItem)) {
  200.                 $themeConfig['currentFields'][$field]['value'] = $fieldItem['value'];
  201.             }
  202.             $isInherited $this->fieldIsInherited($field$baseThemeConfig);
  203.             $themeConfig['baseThemeFields'][$field]['isInherited'] = $isInherited;
  204.             if ($isInherited) {
  205.                 $themeConfig['baseThemeFields'][$field]['value'] = null;
  206.             } elseif (\array_key_exists('value'$fieldItem) && isset($baseThemeConfig['fields'][$field]['value'])) {
  207.                 $themeConfig['baseThemeFields'][$field]['value'] = $baseThemeConfig['fields'][$field]['value'];
  208.             }
  209.         }
  210.         return $themeConfig;
  211.     }
  212.     /**
  213.      * @return array<string, mixed>
  214.      */
  215.     public function getThemeConfigurationStructuredFields(string $themeIdbool $translateContext $context): array
  216.     {
  217.         $mergedConfig $this->getThemeConfiguration($themeId$translate$context)['fields'];
  218.         $translations = [];
  219.         if ($translate) {
  220.             $translations $this->getTranslations($themeId$context);
  221.             $mergedConfig $this->translateLabels($mergedConfig$translations);
  222.         }
  223.         $outputStructure = [];
  224.         foreach ($mergedConfig as $fieldName => $fieldConfig) {
  225.             $tab $this->getTab($fieldConfig);
  226.             $tabLabel $this->getTabLabel($tab$translations);
  227.             $block $this->getBlock($fieldConfig);
  228.             $blockLabel $this->getBlockLabel($block$translations);
  229.             $section $this->getSection($fieldConfig);
  230.             $sectionLabel $this->getSectionLabel($section$translations);
  231.             // set default tab
  232.             $outputStructure['tabs']['default']['label'] = '';
  233.             // set labels
  234.             $outputStructure['tabs'][$tab]['label'] = $tabLabel;
  235.             $outputStructure['tabs'][$tab]['blocks'][$block]['label'] = $blockLabel;
  236.             $outputStructure['tabs'][$tab]['blocks'][$block]['sections'][$section]['label'] = $sectionLabel;
  237.             // add fields to sections
  238.             $outputStructure['tabs'][$tab]['blocks'][$block]['sections'][$section]['fields'][$fieldName] = [
  239.                 'label' => $fieldConfig['label'],
  240.                 'helpText' => $fieldConfig['helpText'] ?? null,
  241.                 'type' => $fieldConfig['type'],
  242.                 'custom' => $fieldConfig['custom'],
  243.                 'fullWidth' => $fieldConfig['fullWidth'],
  244.             ];
  245.         }
  246.         return $outputStructure;
  247.     }
  248.     public function getThemeDependencyMapping(string $themeId): ThemeSalesChannelCollection
  249.     {
  250.         $mappings = new ThemeSalesChannelCollection();
  251.         $themeData $this->connection->fetchAllAssociative(
  252.             'SELECT LOWER(HEX(theme.id)) as id, LOWER(HEX(childTheme.id)) as dependentId,
  253.             LOWER(HEX(tsc.sales_channel_id)) as saleschannelId,
  254.             LOWER(HEX(dtsc.sales_channel_id)) as dsaleschannelId
  255.             FROM theme
  256.             LEFT JOIN theme as childTheme ON childTheme.parent_theme_id = theme.id
  257.             LEFT JOIN theme_sales_channel as tsc ON theme.id = tsc.theme_id
  258.             LEFT JOIN theme_sales_channel as dtsc ON childTheme.id = dtsc.theme_id
  259.             WHERE theme.id = :id',
  260.             ['id' => Uuid::fromHexToBytes($themeId)]
  261.         );
  262.         foreach ($themeData as $data) {
  263.             if (isset($data['id']) && isset($data['saleschannelId']) && $data['id'] === $themeId) {
  264.                 $mappings->add(new ThemeSalesChannel($data['id'], $data['saleschannelId']));
  265.             }
  266.             if (isset($data['dependentId']) && isset($data['dsaleschannelId'])) {
  267.                 $mappings->add(new ThemeSalesChannel($data['dependentId'], $data['dsaleschannelId']));
  268.             }
  269.         }
  270.         return $mappings;
  271.     }
  272.     /**
  273.      * @param array<string, mixed> $parentThemes
  274.      *
  275.      * @return array<string, mixed>
  276.      */
  277.     private function getParentThemeIds(EntitySearchResult $themesThemeEntity $mainTheme, array $parentThemes = []): array
  278.     {
  279.         foreach ($this->getConfigInheritance($mainTheme) as $parentThemeName) {
  280.             $parentTheme $themes->filter(fn (ThemeEntity $themeEntry) => $themeEntry->getTechnicalName() === str_replace('@''', (string) $parentThemeName))->first();
  281.             if ($parentTheme instanceof ThemeEntity && !\array_key_exists($parentTheme->getId(), $parentThemes)) {
  282.                 $parentThemes[$parentTheme->getId()] = $parentTheme;
  283.                 if ($parentTheme->getParentThemeId()) {
  284.                     $parentThemes $this->getParentThemeIds($themes$mainTheme$parentThemes);
  285.                 }
  286.             }
  287.         }
  288.         if ($mainTheme->getParentThemeId()) {
  289.             $parentTheme $themes->filter(fn (ThemeEntity $themeEntry) => $themeEntry->getId() === $mainTheme->getParentThemeId())->first();
  290.             if ($parentTheme instanceof ThemeEntity && !\array_key_exists($parentTheme->getId(), $parentThemes)) {
  291.                 $parentThemes[$parentTheme->getId()] = $parentTheme;
  292.                 if ($parentTheme->getParentThemeId()) {
  293.                     $parentThemes $this->getParentThemeIds($themes$mainTheme$parentThemes);
  294.                 }
  295.             }
  296.         }
  297.         return $parentThemes;
  298.     }
  299.     /**
  300.      * @return array<string, mixed>
  301.      */
  302.     private function getConfigInheritance(ThemeEntity $mainTheme): array
  303.     {
  304.         if (\is_array($mainTheme->getBaseConfig())
  305.             && \array_key_exists('configInheritance'$mainTheme->getBaseConfig())
  306.             && \is_array($mainTheme->getBaseConfig()['configInheritance'])
  307.             && !empty($mainTheme->getBaseConfig()['configInheritance'])
  308.         ) {
  309.             return $mainTheme->getBaseConfig()['configInheritance'];
  310.         }
  311.         return [];
  312.     }
  313.     /**
  314.      * @return array<string, mixed>
  315.      */
  316.     private function mergeStaticConfig(ThemeEntity $theme): array
  317.     {
  318.         $configuredTheme = [];
  319.         $pluginConfig null;
  320.         if ($theme->getTechnicalName()) {
  321.             $pluginConfig $this->extensionRegistry->getConfigurations()->getByTechnicalName($theme->getTechnicalName());
  322.         }
  323.         if ($pluginConfig !== null) {
  324.             $configuredTheme $pluginConfig->getThemeConfig();
  325.         }
  326.         if ($theme->getBaseConfig() !== null) {
  327.             $configuredTheme array_replace_recursive($configuredTheme ?? [], $theme->getBaseConfig());
  328.         }
  329.         if ($theme->getConfigValues() !== null) {
  330.             foreach ($theme->getConfigValues() as $fieldName => $configValue) {
  331.                 if (\array_key_exists('value'$configValue)) {
  332.                     $configuredTheme['fields'][$fieldName]['value'] = $configValue['value'];
  333.                 }
  334.             }
  335.         }
  336.         return $configuredTheme ?: [];
  337.     }
  338.     /**
  339.      * @param array<string, mixed> $fieldConfig
  340.      */
  341.     private function getTab(array $fieldConfig): string
  342.     {
  343.         $tab 'default';
  344.         if (isset($fieldConfig['tab'])) {
  345.             $tab $fieldConfig['tab'];
  346.         }
  347.         return $tab;
  348.     }
  349.     /**
  350.      * @param array<string, mixed> $fieldConfig
  351.      */
  352.     private function getBlock(array $fieldConfig): string
  353.     {
  354.         $block 'default';
  355.         if (isset($fieldConfig['block'])) {
  356.             $block $fieldConfig['block'];
  357.         }
  358.         return $block;
  359.     }
  360.     /**
  361.      * @param array<string, mixed> $fieldConfig
  362.      */
  363.     private function getSection(array $fieldConfig): string
  364.     {
  365.         $section 'default';
  366.         if (isset($fieldConfig['section'])) {
  367.             $section $fieldConfig['section'];
  368.         }
  369.         return $section;
  370.     }
  371.     /**
  372.      * @param array<string, mixed> $translations
  373.      */
  374.     private function getTabLabel(string $tabName, array $translations): string
  375.     {
  376.         if ($tabName === 'default') {
  377.             return '';
  378.         }
  379.         return $translations['tabs.' $tabName] ?? $tabName;
  380.     }
  381.     /**
  382.      * @param array<string, mixed> $translations
  383.      */
  384.     private function getBlockLabel(string $blockName, array $translations): string
  385.     {
  386.         if ($blockName === 'default') {
  387.             return '';
  388.         }
  389.         return $translations['blocks.' $blockName] ?? $blockName;
  390.     }
  391.     /**
  392.      * @param array<string, mixed> $translations
  393.      */
  394.     private function getSectionLabel(string $sectionName, array $translations): string
  395.     {
  396.         if ($sectionName === 'default') {
  397.             return '';
  398.         }
  399.         return $translations['sections.' $sectionName] ?? $sectionName;
  400.     }
  401.     /**
  402.      * @param array<string, mixed> $themeConfiguration
  403.      * @param array<string, mixed> $translations
  404.      *
  405.      * @return array<string, mixed>
  406.      */
  407.     private function translateLabels(array $themeConfiguration, array $translations): array
  408.     {
  409.         foreach ($themeConfiguration as $key => &$value) {
  410.             $value['label'] = $translations['fields.' $key] ?? $key;
  411.         }
  412.         return $themeConfiguration;
  413.     }
  414.     /**
  415.      * @param array<string, mixed> $themeConfiguration
  416.      * @param array<string, mixed> $translations
  417.      *
  418.      * @return array<string, mixed>
  419.      */
  420.     private function translateHelpTexts(array $themeConfiguration, array $translations): array
  421.     {
  422.         foreach ($themeConfiguration as $key => &$value) {
  423.             $value['helpText'] = $translations['fields.' $key] ?? null;
  424.         }
  425.         return $themeConfiguration;
  426.     }
  427.     /**
  428.      * @return array<string, mixed>
  429.      */
  430.     private function getTranslations(string $themeIdContext $context): array
  431.     {
  432.         /** @var ThemeEntity $theme */
  433.         $theme $this->themeRepository->search(new Criteria([$themeId]), $context)->get($themeId);
  434.         $translations $theme->getLabels() ?: [];
  435.         if ($theme->getParentThemeId()) {
  436.             $criteria = new Criteria();
  437.             $criteria->setTitle('theme-service::load-translations');
  438.             $themes $this->themeRepository->search($criteria$context);
  439.             $parentThemes $this->getParentThemeIds($themes$theme);
  440.             foreach ($parentThemes as $parentTheme) {
  441.                 $parentTranslations $parentTheme->getLabels() ?: [];
  442.                 $translations array_replace_recursive($parentTranslations$translations);
  443.             }
  444.         }
  445.         return $translations;
  446.     }
  447.     /**
  448.      * @param array<string, mixed> $configuration
  449.      */
  450.     private function fieldIsInherited(string $fieldName, array $configuration): bool
  451.     {
  452.         if (!isset($configuration['fields'])) {
  453.             return true;
  454.         }
  455.         if (!\is_array($configuration['fields'])) {
  456.             return true;
  457.         }
  458.         if (!\array_key_exists($fieldName$configuration['fields'])) {
  459.             return true;
  460.         }
  461.         return false;
  462.     }
  463. }