From d78cb4656e554cab500aa58ec34404a256594a97 Mon Sep 17 00:00:00 2001 From: "D.Neustadt" Date: Mon, 5 Oct 2026 14:12:31 +0200 Subject: [PATCH 1/5] feat: agentic commerce settings page --- .../core/service/api/ucp-admin.api.service.js | 8 + .../sw-settings-agentic-commerce/index.js | 143 +++++++++++ .../prepare-modal/index.js | 146 +++++++++++ ...s-agentic-commerce-prepare-modal.html.twig | 64 +++++ ...ttings-agentic-commerce-prepare-modal.scss | 20 ++ .../snippet/de.json | 26 ++ .../snippet/en.json | 26 ++ .../sw-settings-agentic-commerce.html.twig | 8 + src/Resources/app/administration/src/main.js | 1 + src/Resources/config/services.php | 8 + src/Ucp/Admin/Api/UcpAdminController.php | 33 +++ src/Ucp/Admin/ReadinessSummary.php | 31 +++ src/Ucp/Admin/ReadinessSummaryProvider.php | 66 +++++ .../Admin/SalesChannelPreparationService.php | 55 +++++ .../Config/UcpActivationReaderInterface.php | 23 ++ .../Config/UcpActivationWriterInterface.php | 18 ++ src/Ucp/Config/UcpConfigService.php | 22 +- .../Fixtures/RecordingUcpActivationWriter.php | 19 ++ .../Admin/ReadinessSummaryProviderTest.php | 149 ++++++++++++ .../SalesChannelPreparationServiceTest.php | 73 ++++++ .../index.spec.js | 190 +++++++++++++++ .../index.spec.js | 226 ++++++++++++++++++ 22 files changed, 1354 insertions(+), 1 deletion(-) create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/index.js create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.html.twig create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.scss create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json create mode 100644 src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/sw-settings-agentic-commerce.html.twig create mode 100644 src/Ucp/Admin/ReadinessSummary.php create mode 100644 src/Ucp/Admin/ReadinessSummaryProvider.php create mode 100644 src/Ucp/Admin/SalesChannelPreparationService.php create mode 100644 src/Ucp/Config/UcpActivationReaderInterface.php create mode 100644 src/Ucp/Config/UcpActivationWriterInterface.php create mode 100644 tests/Unit/Ucp/Admin/Fixtures/RecordingUcpActivationWriter.php create mode 100644 tests/Unit/Ucp/Admin/ReadinessSummaryProviderTest.php create mode 100644 tests/Unit/Ucp/Admin/SalesChannelPreparationServiceTest.php create mode 100644 tests/jest/administration/sw-settings-agentic-commerce-prepare-modal/index.spec.js create mode 100644 tests/jest/administration/sw-settings-agentic-commerce/index.spec.js diff --git a/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js b/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js index 0bee8883..71889a98 100644 --- a/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js +++ b/src/Resources/app/administration/src/core/service/api/ucp-admin.api.service.js @@ -6,6 +6,14 @@ class UcpAdminApiService extends ApiService { super(httpClient, loginService, apiEndpoint); } + getReadiness() { + return this.httpClient.get('/_admin/ucp/readiness', this.options()); + } + + prepareSalesChannels(salesChannelIds) { + return this.httpClient.post('/_admin/ucp/prepare', { salesChannelIds }, this.options()); + } + getSalesChannels() { return this.httpClient.get('/_admin/ucp/sales-channels', this.options()); } diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js new file mode 100644 index 00000000..706bdbce --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js @@ -0,0 +1,143 @@ +import template from './sw-settings-agentic-commerce.html.twig'; +import './prepare-modal'; + +const { Component } = Shopware; + +/** + * Drives the counts on the core Agentic Commerce settings page and owns the + * "prepare sales channels" flow once the plugin is active. The counts are + * computed server-side (UCP config lives in the plugin config table, not on the + * sales_channel entity) and fetched via `ucpAdminApiService.getReadiness()`. + * + * The core settings page only exists on Shopware versions that ship it, so the + * override is registered conditionally — on older versions there is nothing to + * override and the plugin keeps its sales-channel-scoped admin instead. + */ +export const swSettingsAgenticCommerceOverride = { + template, + + inject: ['acl'], + + data() { + return { + showPrepareModal: false, + transactionalSalesChannelCount: 0, + }; + }, + + computed: { + canViewUcpStatus() { + return this.acl?.can?.('ucp.viewer') === true; + }, + + // Marks the "prepare" step complete once every transactional (Storefront/Headless) + // sales channel has UCP enabled. Comparing against the total keeps the flag false + // before the counts load (all zero) and before any channel exists. + allSalesChannelsPrepared() { + return ( + this.transactionalSalesChannelCount > 0 && + this.preparedSalesChannelCount >= this.transactionalSalesChannelCount + ); + }, + + // The plugin's admin bundle only loads when the plugin is installed and active, + // so here the extension step is always complete; the prepare step reflects the + // actual preparation state. + steps() { + return this.$super('steps').map((step) => { + if (step.key === 'extension') { + return { + ...step, + complete: true, + description: this.$t('swagAgenticCommerce.extensionStep.activeDescription'), + }; + } + + if (step.key === 'prepareSalesChannels') { + const complete = this.allSalesChannelsPrepared; + + return { + ...step, + complete, + description: complete + ? this.$t('swagAgenticCommerce.prepareStep.completedDescription') + : step.description, + }; + } + + return step; + }); + }, + + canPrepareSalesChannels() { + return ( + this.acl?.can?.('ucp.editor') === true && + this.preparedSalesChannelCount < this.transactionalSalesChannelCount + ); + }, + + readinessSubtitle() { + return this.isReady + ? this.$t('swagAgenticCommerce.subtitleReady') + : this.$t('swagAgenticCommerce.subtitleActive'); + }, + + extensionStatusLabel() { + return this.$t('swagAgenticCommerce.shopReadiness.extensionInstalled'); + }, + }, + + created() { + this.loadReadinessCounts(); + }, + + methods: { + loadReadinessCounts() { + if (!this.canViewUcpStatus) { + return; + } + + const service = Shopware.Service('ucpAdminApiService'); + if (!service?.getReadiness) { + return; + } + + service + .getReadiness() + .then((response) => { + this.applyReadiness(response?.data?.data ?? {}); + }) + .catch(() => {}); + }, + + applyReadiness(data) { + this.preparedSalesChannelCount = data.preparedSalesChannels ?? 0; + this.agenticSalesChannelCount = data.agenticSalesChannels ?? 0; + this.transactionalSalesChannelCount = data.transactionalSalesChannels ?? 0; + }, + + // Overrides the core no-op: open the selection modal instead. + onPrepareSalesChannels() { + this.showPrepareModal = true; + }, + + onPrepareModalClose() { + this.showPrepareModal = false; + }, + + onSalesChannelsPrepared(summary) { + this.showPrepareModal = false; + + if (summary) { + this.applyReadiness(summary); + return; + } + + this.loadReadinessCounts(); + }, + }, +}; + +if (Component.getComponentRegistry().has('sw-settings-agentic-commerce')) { + Component.override('sw-settings-agentic-commerce', swSettingsAgenticCommerceOverride); +} diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/index.js b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/index.js new file mode 100644 index 00000000..6a10cbf2 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/index.js @@ -0,0 +1,146 @@ +import template from './sw-settings-agentic-commerce-prepare-modal.html.twig'; +import './sw-settings-agentic-commerce-prepare-modal.scss'; + +const { Component, Mixin } = Shopware; +const { Criteria, EntityCollection } = Shopware.Data; + +/** + * Modal for the "prepare sales channels" flow. Lists the transactional + * (Storefront/Headless) sales channels that do not have UCP enabled yet in a + * multi-select, pre-selects all of them, and — on confirm — activates UCP plus + * the AI files for the selected channels via + * `ucpAdminApiService.prepareSalesChannels()`. + * + * Which channels are still unprepared is known only to the plugin (UCP config + * lives in its own table), so that set comes from `getSalesChannels()`. The + * multi-select itself is bound to the `sales_channel` repository so it renders + * the translated channel names, and is constrained to the unprepared ids. + */ +export const swSettingsAgenticCommercePrepareModal = { + template, + + inject: ['repositoryFactory'], + + mixins: [Mixin.getByName('notification')], + + emits: ['prepared', 'close'], + + data() { + return { + unpreparedIds: [], + selectedSalesChannelsCollection: null, + isLoading: false, + isPreparing: false, + }; + }, + + computed: { + salesChannelRepository() { + return this.repositoryFactory.create('sales_channel'); + }, + + salesChannelCriteria() { + const criteria = new Criteria(1, 25); + criteria.addFilter(Criteria.equalsAny('id', this.unpreparedIds)); + + return criteria; + }, + + hasUnpreparedSalesChannels() { + return this.unpreparedIds.length > 0; + }, + + selectedIds() { + return this.selectedSalesChannelsCollection ? this.selectedSalesChannelsCollection.getIds() : []; + }, + + hasSelection() { + return this.selectedIds.length > 0; + }, + }, + + created() { + this.loadUnpreparedSalesChannels(); + }, + + methods: { + loadUnpreparedSalesChannels() { + const service = Shopware.Service('ucpAdminApiService'); + if (!service?.getSalesChannels) { + return; + } + + this.isLoading = true; + + service + .getSalesChannels() + .then((response) => { + // The endpoint already returns only transactional channels; keep the ones + // without UCP active, i.e. the ones still to prepare. + this.unpreparedIds = (response?.data?.data ?? []) + .filter((salesChannel) => !salesChannel.ucp?.active) + .map((salesChannel) => salesChannel.id); + + return this.buildSelectedSalesChannelsCollection(); + }) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + this.createNotificationError({ + message: this.$t('swagAgenticCommerce.prepareModal.loadErrorMessage'), + }); + }); + }, + + buildSelectedSalesChannelsCollection() { + if (this.unpreparedIds.length === 0) { + this.selectedSalesChannelsCollection = new EntityCollection( + '/sales-channel', + 'sales_channel', + Shopware.Context.api, + new Criteria(1, 25), + ); + + return Promise.resolve(); + } + + // Pre-select all unprepared channels (per the ticket). + return this.salesChannelRepository.search(this.salesChannelCriteria, Shopware.Context.api).then((result) => { + this.selectedSalesChannelsCollection = result; + }); + }, + + onCancel() { + this.$emit('close'); + }, + + onConfirm() { + if (!this.hasSelection || this.isPreparing) { + return; + } + + const service = Shopware.Service('ucpAdminApiService'); + this.isPreparing = true; + + service + .prepareSalesChannels(this.selectedIds) + .then((response) => { + this.isPreparing = false; + this.createNotificationSuccess({ + message: this.$t('swagAgenticCommerce.prepareModal.successMessage'), + }); + this.$emit('prepared', response?.data?.data ?? null); + }) + .catch(() => { + this.isPreparing = false; + this.createNotificationError({ + message: this.$t('swagAgenticCommerce.prepareModal.errorMessage'), + }); + }); + }, + }, +}; + +Component.register('sw-settings-agentic-commerce-prepare-modal', swSettingsAgenticCommercePrepareModal); diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.html.twig b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.html.twig new file mode 100644 index 00000000..474970a9 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.html.twig @@ -0,0 +1,64 @@ +{% block sw_settings_agentic_commerce_prepare_modal %} + + {% block sw_settings_agentic_commerce_prepare_modal_description %} +

+ {{ $t('swagAgenticCommerce.prepareModal.description') }} +

+ {% endblock %} + + {% block sw_settings_agentic_commerce_prepare_modal_content %} + + + + {% endblock %} + + {% block sw_settings_agentic_commerce_prepare_modal_footer %} + + {% endblock %} +
+{% endblock %} diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.scss b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.scss new file mode 100644 index 00000000..1c6360bf --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/prepare-modal/sw-settings-agentic-commerce-prepare-modal.scss @@ -0,0 +1,20 @@ +.sw-settings-agentic-commerce-prepare-modal { + &__description { + margin: 0 0 var(--scale-size-16); + color: var(--color-text-secondary-default); + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--scale-size-12); + } + + &__empty { + margin: 0; + color: var(--color-text-secondary-default); + } +} diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json new file mode 100644 index 00000000..456fae5a --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json @@ -0,0 +1,26 @@ +{ + "swagAgenticCommerce": { + "subtitleActive": "Agentic Commerce ist installiert. Bereite deine bestehenden Verkaufskanäle vor, um deinen Shop KI-bereit zu machen.", + "subtitleReady": "Agentic Commerce ist installiert und deine Verkaufskanäle sind vorbereitet. Dein Shop ist KI-bereit.", + "extensionStep": { + "activeDescription": "Die Erweiterung ist in diesem Shop aktiv." + }, + "shopReadiness": { + "extensionInstalled": "Installiert" + }, + "prepareStep": { + "completedDescription": "Für alle infrage kommenden Verkaufskanäle sind UCP und die KI-Datei-Einstellungen aktiviert." + }, + "prepareModal": { + "title": "Verkaufskanäle vorbereiten", + "description": "Dies aktiviert UCP und die KI-Dateien (llms.txt, agents.md und den KI-Katalog) mit Standardeinstellungen für die ausgewählten Verkaufskanäle.", + "selectLabel": "Vorzubereitende Verkaufskanäle", + "selectPlaceholder": "Verkaufskanäle auswählen …", + "emptyState": "Alle infrage kommenden Verkaufskanäle sind bereits vorbereitet.", + "confirm": "Verkaufskanäle vorbereiten", + "successMessage": "Die ausgewählten Verkaufskanäle wurden vorbereitet.", + "errorMessage": "Die Verkaufskanäle konnten nicht vorbereitet werden. Bitte versuche es erneut.", + "loadErrorMessage": "Die Verkaufskanäle konnten nicht geladen werden. Bitte versuche es erneut." + } + } +} diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json new file mode 100644 index 00000000..54a07020 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json @@ -0,0 +1,26 @@ +{ + "swagAgenticCommerce": { + "subtitleActive": "Agentic Commerce is installed. Prepare your existing sales channels to make your shop AI ready.", + "subtitleReady": "Agentic Commerce is installed and your sales channels are prepared. Your shop is AI ready.", + "extensionStep": { + "activeDescription": "The extension is active in this shop." + }, + "shopReadiness": { + "extensionInstalled": "Installed" + }, + "prepareStep": { + "completedDescription": "All eligible sales channels have UCP and the AI-file settings enabled." + }, + "prepareModal": { + "title": "Prepare sales channels", + "description": "This enables UCP and the AI files (llms.txt, agents.md and the AI catalog) with default settings for the selected sales channels.", + "selectLabel": "Sales channels to prepare", + "selectPlaceholder": "Select sales channels…", + "emptyState": "All eligible sales channels are already prepared.", + "confirm": "Prepare sales channels", + "successMessage": "The selected sales channels have been prepared.", + "errorMessage": "The sales channels could not be prepared. Please try again.", + "loadErrorMessage": "The sales channels could not be loaded. Please try again." + } + } +} diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/sw-settings-agentic-commerce.html.twig b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/sw-settings-agentic-commerce.html.twig new file mode 100644 index 00000000..a552a222 --- /dev/null +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/sw-settings-agentic-commerce.html.twig @@ -0,0 +1,8 @@ +{% block sw_settings_agentic_commerce_after_content %} +{% parent %} + +{% endblock %} diff --git a/src/Resources/app/administration/src/main.js b/src/Resources/app/administration/src/main.js index 95c39bdb..3bf41b62 100644 --- a/src/Resources/app/administration/src/main.js +++ b/src/Resources/app/administration/src/main.js @@ -5,6 +5,7 @@ import './init/defaults.init'; import './core/service/api/ucp-admin.api.service.js'; import './extension/sw-customer/acl'; import './extension/sw-sales-channel'; +import './extension/sw-settings-agentic-commerce'; // Core ships the list-column tracking module natively from 6.7.10+. if (!coreShipsAgenticCommerce) { diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 3655812d..6210a6d6 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -93,6 +93,7 @@ use Swag\AgenticCommerce\Ucp\Adapter\ShopwareDiscountAdapter; use Swag\AgenticCommerce\Ucp\Adapter\ShopwareOrderAdapter; use Swag\AgenticCommerce\Ucp\Admin\Api\UcpAdminController; +use Swag\AgenticCommerce\Ucp\Admin\ReadinessSummaryProvider; use Swag\AgenticCommerce\Ucp\Capability\CartCapability; use Swag\AgenticCommerce\Ucp\Capability\CatalogCapability; use Swag\AgenticCommerce\Ucp\Capability\CheckoutCapability; @@ -115,6 +116,8 @@ use Swag\AgenticCommerce\Ucp\Config\LegacyConfigStoreInterface; use Swag\AgenticCommerce\Ucp\Config\ShopwareRuntimeConfigurationResolver; use Swag\AgenticCommerce\Ucp\Config\SystemConfigLegacyConfigStore; +use Swag\AgenticCommerce\Ucp\Config\UcpActivationReaderInterface; +use Swag\AgenticCommerce\Ucp\Config\UcpActivationWriterInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigRepositoryInterface; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; use Swag\AgenticCommerce\Ucp\Customer\GuestCustomerContextProvisioner; @@ -242,6 +245,9 @@ ->arg('$salesChannelRepository', service('sales_channel.repository')) ->arg('$salesChannelTypeResolver', service(AbstractSalesChannelTypeResolver::class)); + $services->set(ReadinessSummaryProvider::class) + ->arg('$salesChannelRepository', service('sales_channel.repository')); + $services->set(SalesChannelTypeResolver::class) ->arg('$salesChannelRepository', service('sales_channel.repository')); @@ -527,6 +533,8 @@ $services->alias(AbstractSalesChannelTypeResolver::class, SalesChannelTypeResolver::class); $services->alias(UcpConfigRepositoryInterface::class, DoctrineDbalUcpConfigRepository::class); + $services->alias(UcpActivationReaderInterface::class, UcpConfigService::class); + $services->alias(UcpActivationWriterInterface::class, UcpConfigService::class); $services->alias(LegacyConfigStoreInterface::class, SystemConfigLegacyConfigStore::class); // The same development-mode switch the URL-safety validator gets (above). The SDK's // request-context factory reads it from the resolved RuntimeConfiguration, so without this diff --git a/src/Ucp/Admin/Api/UcpAdminController.php b/src/Ucp/Admin/Api/UcpAdminController.php index 8146bf36..8ad70143 100644 --- a/src/Ucp/Admin/Api/UcpAdminController.php +++ b/src/Ucp/Admin/Api/UcpAdminController.php @@ -9,6 +9,8 @@ use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\PlatformRequest; use Swag\AgenticCommerce\Compatibility\ShopwareVersionDetector; +use Swag\AgenticCommerce\Ucp\Admin\ReadinessSummaryProvider; +use Swag\AgenticCommerce\Ucp\Admin\SalesChannelPreparationService; use Swag\AgenticCommerce\Ucp\Config\UcpConfig; use Swag\AgenticCommerce\Ucp\Config\UcpConfigException; use Swag\AgenticCommerce\Ucp\Config\UcpConfigService; @@ -34,10 +36,41 @@ public function __construct( private readonly ProfilePreviewBuilder $profilePreviewBuilder, private readonly ShopwareVersionDetector $versionDetector, private readonly PlatformProfileCacheRepositoryInterface $platformProfileCacheRepository, + private readonly ReadinessSummaryProvider $readinessSummaryProvider, + private readonly SalesChannelPreparationService $salesChannelPreparationService, private readonly bool $allowHttpLocalWebhookOverride = false, ) { } + #[Route(path: '/api/_admin/ucp/readiness', name: 'api.action.swag_agentic_commerce.ucp.readiness', methods: ['GET'], defaults: [PlatformRequest::ATTRIBUTE_ACL => ['ucp.viewer']])] + public function readiness(Context $context): JsonResponse + { + return new JsonResponse([ + 'data' => $this->readinessSummaryProvider->summary($context)->toArray(), + ]); + } + + #[Route(path: '/api/_admin/ucp/prepare', name: 'api.action.swag_agentic_commerce.ucp.prepare', methods: ['POST'], defaults: [PlatformRequest::ATTRIBUTE_ACL => ['ucp.editor']])] + public function prepare(Request $request, Context $context): JsonResponse + { + try { + $payload = $request->toArray(); + } catch (JsonException) { + throw UcpConfigException::invalidJsonPayload(); + } + + $salesChannelIds = array_values(array_filter( + \is_array($payload['salesChannelIds'] ?? null) ? $payload['salesChannelIds'] : [], + static fn (mixed $id): bool => \is_string($id), + )); + + $this->salesChannelPreparationService->prepare($salesChannelIds); + + return new JsonResponse([ + 'data' => $this->readinessSummaryProvider->summary($context)->toArray(), + ]); + } + #[Route(path: '/api/_admin/ucp/sales-channels', name: 'api.action.swag_agentic_commerce.ucp.sales_channels', methods: ['GET'], defaults: [PlatformRequest::ATTRIBUTE_ACL => ['ucp.viewer']])] public function salesChannels(Context $context): JsonResponse { diff --git a/src/Ucp/Admin/ReadinessSummary.php b/src/Ucp/Admin/ReadinessSummary.php new file mode 100644 index 00000000..9333e8f8 --- /dev/null +++ b/src/Ucp/Admin/ReadinessSummary.php @@ -0,0 +1,31 @@ + $this->preparedSalesChannels, + 'agenticSalesChannels' => $this->agenticSalesChannels, + 'transactionalSalesChannels' => $this->transactionalSalesChannels, + ]; + } +} diff --git a/src/Ucp/Admin/ReadinessSummaryProvider.php b/src/Ucp/Admin/ReadinessSummaryProvider.php new file mode 100644 index 00000000..557a5f58 --- /dev/null +++ b/src/Ucp/Admin/ReadinessSummaryProvider.php @@ -0,0 +1,66 @@ + $salesChannelRepository + */ + public function __construct( + private readonly EntityRepository $salesChannelRepository, + private readonly AbstractSalesChannelTypeResolver $salesChannelTypeResolver, + private readonly UcpActivationReaderInterface $ucpActivationReader, + ) { + } + + public function summary(Context $context): ReadinessSummary + { + /** @var list $salesChannelIds */ + $salesChannelIds = array_values($this->salesChannelRepository->searchIds(new Criteria(), $context)->getIds()); + + if ([] === $salesChannelIds) { + return new ReadinessSummary(0, 0, 0); + } + + $classifications = $this->salesChannelTypeResolver->resolveMany($salesChannelIds); + + $agenticSalesChannels = \count(array_filter( + $classifications, + static fn (SalesChannelTypeClassification $classification): bool => SalesChannelTypeClassification::AgenticCommerce === $classification, + )); + + $transactionalIds = array_keys(array_filter( + $classifications, + static fn (SalesChannelTypeClassification $classification): bool => $classification->isTransactional(), + )); + + $preparedSalesChannels = [] === $transactionalIds + ? 0 + : \count($this->ucpActivationReader->activeSalesChannelIds($transactionalIds)); + + return new ReadinessSummary($preparedSalesChannels, $agenticSalesChannels, \count($transactionalIds)); + } +} diff --git a/src/Ucp/Admin/SalesChannelPreparationService.php b/src/Ucp/Admin/SalesChannelPreparationService.php new file mode 100644 index 00000000..687337ec --- /dev/null +++ b/src/Ucp/Admin/SalesChannelPreparationService.php @@ -0,0 +1,55 @@ + $salesChannelIds + * + * @return list the ids that were prepared + */ + public function prepare(array $salesChannelIds): array + { + if ([] === $salesChannelIds) { + return []; + } + + $classifications = $this->salesChannelTypeResolver->resolveMany($salesChannelIds); + + $prepared = []; + foreach ($salesChannelIds as $salesChannelId) { + $classification = $classifications[$salesChannelId] ?? SalesChannelTypeClassification::Other; + if (!$classification->isTransactional()) { + continue; + } + + $this->activationWriter->activate($salesChannelId); + $prepared[] = $salesChannelId; + } + + return $prepared; + } +} diff --git a/src/Ucp/Config/UcpActivationReaderInterface.php b/src/Ucp/Config/UcpActivationReaderInterface.php new file mode 100644 index 00000000..19fc53a2 --- /dev/null +++ b/src/Ucp/Config/UcpActivationReaderInterface.php @@ -0,0 +1,23 @@ + $salesChannelIds + * + * @return list + */ + public function activeSalesChannelIds(array $salesChannelIds): array; +} diff --git a/src/Ucp/Config/UcpActivationWriterInterface.php b/src/Ucp/Config/UcpActivationWriterInterface.php new file mode 100644 index 00000000..ebea6494 --- /dev/null +++ b/src/Ucp/Config/UcpActivationWriterInterface.php @@ -0,0 +1,18 @@ +getConfigs($salesChannelIds), + static fn (UcpConfig $config): bool => $config->active, + )); + } + + public function activate(string $salesChannelId): void + { + // Turns on UCP with the default production-ready capability set (the "prepare" + // flow only targets channels that have UCP off). saveConfig() also enables the + // AI files and ensures the signing key; the transport default stays 'rest'. + $this->saveConfig([ + 'active' => true, + 'enabledCapabilities' => UcpCapabilityCatalog::defaultConfigKeys(), + ], $salesChannelId); + } + /** * Persist a (possibly partial) config payload. The incoming keys are merged * over the currently stored config, so callers that only own a subset of the diff --git a/tests/Unit/Ucp/Admin/Fixtures/RecordingUcpActivationWriter.php b/tests/Unit/Ucp/Admin/Fixtures/RecordingUcpActivationWriter.php new file mode 100644 index 00000000..aa7eee43 --- /dev/null +++ b/tests/Unit/Ucp/Admin/Fixtures/RecordingUcpActivationWriter.php @@ -0,0 +1,19 @@ + */ + public array $activated = []; + + public function activate(string $salesChannelId): void + { + $this->activated[] = $salesChannelId; + } +} diff --git a/tests/Unit/Ucp/Admin/ReadinessSummaryProviderTest.php b/tests/Unit/Ucp/Admin/ReadinessSummaryProviderTest.php new file mode 100644 index 00000000..437bad3b --- /dev/null +++ b/tests/Unit/Ucp/Admin/ReadinessSummaryProviderTest.php @@ -0,0 +1,149 @@ +createProvider( + activeUcpIds: [self::STOREFRONT_ACTIVE, self::HEADLESS_ACTIVE], + ); + + $summary = $provider->summary(Context::createDefaultContext()); + + static::assertSame(2, $summary->preparedSalesChannels); + static::assertSame(2, $summary->agenticSalesChannels); + static::assertSame(3, $summary->transactionalSalesChannels); + } + + public function testItExcludesInactiveTransactionalChannelsFromThePreparedCount(): void + { + $provider = $this->createProvider( + activeUcpIds: [self::STOREFRONT_ACTIVE], + ); + + $summary = $provider->summary(Context::createDefaultContext()); + + static::assertSame(1, $summary->preparedSalesChannels); + static::assertSame(2, $summary->agenticSalesChannels); + static::assertSame(3, $summary->transactionalSalesChannels); + } + + public function testItDoesNotCountAgenticChannelsAsPrepared(): void + { + // Agentic channels can have UCP active, but they must never count as "prepared"; + // no transactional channel is active here, so the prepared count is zero. + $provider = $this->createProvider( + activeUcpIds: [self::AGENTIC_ONE, self::AGENTIC_TWO], + ); + + $summary = $provider->summary(Context::createDefaultContext()); + + static::assertSame(0, $summary->preparedSalesChannels); + static::assertSame(2, $summary->agenticSalesChannels); + static::assertSame(3, $summary->transactionalSalesChannels); + } + + public function testItReturnsZeroCountsWithoutSalesChannels(): void + { + $provider = $this->createProvider(activeUcpIds: [], salesChannelIds: []); + + $summary = $provider->summary(Context::createDefaultContext()); + + static::assertSame(0, $summary->preparedSalesChannels); + static::assertSame(0, $summary->agenticSalesChannels); + static::assertSame(0, $summary->transactionalSalesChannels); + } + + /** + * @param list $activeUcpIds + * @param list|null $salesChannelIds + */ + private function createProvider(array $activeUcpIds, ?array $salesChannelIds = null): ReadinessSummaryProvider + { + $salesChannelIds ??= [ + self::STOREFRONT_ACTIVE, + self::HEADLESS_ACTIVE, + self::STOREFRONT_INACTIVE, + self::AGENTIC_ONE, + self::AGENTIC_TWO, + self::PRODUCT_COMPARISON, + ]; + + $data = []; + foreach ($salesChannelIds as $id) { + $data[$id] = ['primaryKey' => $id, 'data' => []]; + } + + $idSearchResult = new IdSearchResult( + \count($salesChannelIds), + $data, + new Criteria(), + Context::createDefaultContext(), + ); + + $salesChannelRepository = $this->createMock(EntityRepository::class); + $salesChannelRepository->method('searchIds')->willReturn($idSearchResult); + + $resolver = new StaticSalesChannelTypeResolver(SalesChannelTypeClassification::Other, [ + self::STOREFRONT_ACTIVE => SalesChannelTypeClassification::Storefront, + self::HEADLESS_ACTIVE => SalesChannelTypeClassification::Headless, + self::STOREFRONT_INACTIVE => SalesChannelTypeClassification::Storefront, + self::AGENTIC_ONE => SalesChannelTypeClassification::AgenticCommerce, + self::AGENTIC_TWO => SalesChannelTypeClassification::AgenticCommerce, + self::PRODUCT_COMPARISON => SalesChannelTypeClassification::ProductComparison, + ]); + + $activationReader = new class($activeUcpIds) implements UcpActivationReaderInterface { + /** + * @param list $activeUcpIds + */ + public function __construct(private readonly array $activeUcpIds) + { + } + + public function activeSalesChannelIds(array $salesChannelIds): array + { + return array_values(array_intersect($salesChannelIds, $this->activeUcpIds)); + } + }; + + return new ReadinessSummaryProvider( + $salesChannelRepository, + $resolver, + $activationReader, + ); + } + + public function testHexConstantsAreValidUuids(): void + { + // Guards the hand-written hex constants above against typos. + foreach ([self::STOREFRONT_ACTIVE, self::AGENTIC_ONE, self::PRODUCT_COMPARISON] as $id) { + static::assertTrue(Uuid::isValid($id)); + } + } +} diff --git a/tests/Unit/Ucp/Admin/SalesChannelPreparationServiceTest.php b/tests/Unit/Ucp/Admin/SalesChannelPreparationServiceTest.php new file mode 100644 index 00000000..d1bb1062 --- /dev/null +++ b/tests/Unit/Ucp/Admin/SalesChannelPreparationServiceTest.php @@ -0,0 +1,73 @@ +createWriter(); + $service = new SalesChannelPreparationService($writer, $this->createResolver()); + + $prepared = $service->prepare([ + self::STOREFRONT, + self::AGENTIC, + self::HEADLESS, + self::PRODUCT_COMPARISON, + ]); + + static::assertSame([self::STOREFRONT, self::HEADLESS], $prepared); + static::assertSame([self::STOREFRONT, self::HEADLESS], $writer->activated); + } + + public function testItSkipsUnknownAndNonTransactionalIdsWithoutActivatingThem(): void + { + $writer = $this->createWriter(); + $service = new SalesChannelPreparationService($writer, $this->createResolver()); + + $prepared = $service->prepare([self::AGENTIC, self::PRODUCT_COMPARISON, 'unknown-id']); + + static::assertSame([], $prepared); + static::assertSame([], $writer->activated); + } + + public function testItReturnsEmptyForAnEmptyList(): void + { + $writer = $this->createWriter(); + $service = new SalesChannelPreparationService($writer, $this->createResolver()); + + static::assertSame([], $service->prepare([])); + static::assertSame([], $writer->activated); + } + + private function createResolver(): StaticSalesChannelTypeResolver + { + return new StaticSalesChannelTypeResolver(SalesChannelTypeClassification::Other, [ + self::STOREFRONT => SalesChannelTypeClassification::Storefront, + self::HEADLESS => SalesChannelTypeClassification::Headless, + self::AGENTIC => SalesChannelTypeClassification::AgenticCommerce, + self::PRODUCT_COMPARISON => SalesChannelTypeClassification::ProductComparison, + ]); + } + + private function createWriter(): RecordingUcpActivationWriter + { + return new RecordingUcpActivationWriter(); + } +} diff --git a/tests/jest/administration/sw-settings-agentic-commerce-prepare-modal/index.spec.js b/tests/jest/administration/sw-settings-agentic-commerce-prepare-modal/index.spec.js new file mode 100644 index 00000000..fb69c493 --- /dev/null +++ b/tests/jest/administration/sw-settings-agentic-commerce-prepare-modal/index.spec.js @@ -0,0 +1,190 @@ +class FakeCriteria { + constructor(page, limit) { + this.page = page; + this.limit = limit; + this.filters = []; + } + + addFilter(filter) { + this.filters.push(filter); + } + + static equalsAny(field, value) { + return { type: 'equalsAny', field, value }; + } +} + +class FakeEntityCollection { + constructor(source, entity) { + this.source = source; + this.entity = entity; + this.ids = []; + } + + getIds() { + return this.ids; + } +} + +global.Shopware = { + Component: { register: jest.fn() }, + Mixin: { getByName: jest.fn(() => ({})) }, + Service: jest.fn(), + Context: { api: {} }, + Data: { Criteria: FakeCriteria, EntityCollection: FakeEntityCollection }, +}; + +const { swSettingsAgenticCommercePrepareModal } = require('Resources/extension/sw-settings-agentic-commerce/prepare-modal'); + +const { salesChannelCriteria, hasUnpreparedSalesChannels, selectedIds, hasSelection } = + swSettingsAgenticCommercePrepareModal.computed; +const { loadUnpreparedSalesChannels, buildSelectedSalesChannelsCollection, onCancel, onConfirm } = + swSettingsAgenticCommercePrepareModal.methods; + +function baseContext(overrides = {}) { + return { + unpreparedIds: [], + selectedSalesChannelsCollection: null, + isLoading: false, + isPreparing: false, + $t: (key) => key, + createNotificationSuccess: jest.fn(), + createNotificationError: jest.fn(), + $emit: jest.fn(), + ...overrides, + }; +} + +describe('sw-settings-agentic-commerce-prepare-modal', () => { + beforeEach(() => { + Shopware.Service.mockReset(); + }); + + describe('loading the unprepared sales channels', () => { + it('keeps only the ids of channels without UCP active and builds the selection', async () => { + const getSalesChannels = jest.fn(() => + Promise.resolve({ + data: { + data: [ + { id: 'sc-1', ucp: { active: false } }, + { id: 'sc-2', ucp: { active: true } }, + { id: 'sc-3', ucp: { active: false } }, + ], + }, + }), + ); + Shopware.Service.mockReturnValue({ getSalesChannels }); + + const context = baseContext({ buildSelectedSalesChannelsCollection: jest.fn(() => Promise.resolve()) }); + loadUnpreparedSalesChannels.call(context); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(context.unpreparedIds).toEqual(['sc-1', 'sc-3']); + expect(context.buildSelectedSalesChannelsCollection).toHaveBeenCalled(); + expect(context.isLoading).toBe(false); + }); + }); + + describe('salesChannelCriteria', () => { + it('restricts the dropdown to the unprepared ids', () => { + const criteria = salesChannelCriteria.call({ unpreparedIds: ['sc-1', 'sc-2'] }); + + expect(criteria.filters).toEqual([{ type: 'equalsAny', field: 'id', value: ['sc-1', 'sc-2'] }]); + }); + }); + + describe('building the pre-selected collection', () => { + it('pre-selects all unprepared channels via the repository', async () => { + const collection = new FakeEntityCollection('/sales-channel', 'sales_channel'); + collection.ids = ['sc-1', 'sc-3']; + const search = jest.fn(() => Promise.resolve(collection)); + + const context = baseContext({ + unpreparedIds: ['sc-1', 'sc-3'], + salesChannelRepository: { search }, + salesChannelCriteria: new FakeCriteria(1, 25), + }); + + await buildSelectedSalesChannelsCollection.call(context); + + expect(search).toHaveBeenCalledWith(context.salesChannelCriteria, Shopware.Context.api); + expect(context.selectedSalesChannelsCollection).toBe(collection); + }); + + it('uses an empty collection when nothing is unprepared', async () => { + const search = jest.fn(); + const context = baseContext({ unpreparedIds: [], salesChannelRepository: { search } }); + + await buildSelectedSalesChannelsCollection.call(context); + + expect(search).not.toHaveBeenCalled(); + expect(context.selectedSalesChannelsCollection).toBeInstanceOf(FakeEntityCollection); + }); + }); + + describe('selection computeds', () => { + it('reads selectedIds from the collection', () => { + const collection = new FakeEntityCollection('/sales-channel', 'sales_channel'); + collection.ids = ['sc-1']; + + expect(selectedIds.call({ selectedSalesChannelsCollection: collection })).toEqual(['sc-1']); + expect(selectedIds.call({ selectedSalesChannelsCollection: null })).toEqual([]); + }); + + it('computes hasUnpreparedSalesChannels and hasSelection', () => { + expect(hasUnpreparedSalesChannels.call({ unpreparedIds: ['sc-1'] })).toBe(true); + expect(hasUnpreparedSalesChannels.call({ unpreparedIds: [] })).toBe(false); + expect(hasSelection.call({ selectedIds: ['sc-1'] })).toBe(true); + expect(hasSelection.call({ selectedIds: [] })).toBe(false); + }); + }); + + describe('confirm / cancel', () => { + it('emits close on cancel', () => { + const context = baseContext(); + onCancel.call(context); + expect(context.$emit).toHaveBeenCalledWith('close'); + }); + + it('prepares the selected channels and emits the returned summary', async () => { + const summary = { preparedSalesChannels: 2, agenticSalesChannels: 0, transactionalSalesChannels: 2 }; + const prepareSalesChannels = jest.fn(() => Promise.resolve({ data: { data: summary } })); + Shopware.Service.mockReturnValue({ prepareSalesChannels }); + + const context = baseContext({ hasSelection: true, selectedIds: ['sc-1', 'sc-2'] }); + onConfirm.call(context); + await Promise.resolve(); + await Promise.resolve(); + + expect(prepareSalesChannels).toHaveBeenCalledWith(['sc-1', 'sc-2']); + expect(context.createNotificationSuccess).toHaveBeenCalled(); + expect(context.$emit).toHaveBeenCalledWith('prepared', summary); + expect(context.isPreparing).toBe(false); + }); + + it('does nothing without a selection', () => { + const prepareSalesChannels = jest.fn(); + Shopware.Service.mockReturnValue({ prepareSalesChannels }); + + const context = baseContext({ hasSelection: false, selectedIds: [] }); + onConfirm.call(context); + + expect(prepareSalesChannels).not.toHaveBeenCalled(); + expect(context.$emit).not.toHaveBeenCalled(); + }); + + it('notifies and resets the loading state on failure', async () => { + const prepareSalesChannels = jest.fn(() => Promise.reject(new Error('boom'))); + Shopware.Service.mockReturnValue({ prepareSalesChannels }); + + const context = baseContext({ hasSelection: true, selectedIds: ['sc-1'] }); + onConfirm.call(context); + await Promise.resolve(); + await Promise.resolve(); + + expect(context.createNotificationError).toHaveBeenCalled(); + expect(context.$emit).not.toHaveBeenCalledWith('prepared', expect.anything()); + expect(context.isPreparing).toBe(false); + }); + }); +}); diff --git a/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js b/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js new file mode 100644 index 00000000..d54137c2 --- /dev/null +++ b/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js @@ -0,0 +1,226 @@ +global.Shopware = { + Component: { + override: jest.fn(), + register: jest.fn(), + getComponentRegistry: () => ({ has: () => false }), + }, + Mixin: { getByName: jest.fn(() => ({})) }, + Service: jest.fn(), + // Needed because requiring the override pulls in the prepare-modal module. + Data: { Criteria: class {}, EntityCollection: class {} }, +}; + +const { swSettingsAgenticCommerceOverride } = require('Resources/extension/sw-settings-agentic-commerce'); + +const { canViewUcpStatus, allSalesChannelsPrepared, steps, canPrepareSalesChannels, readinessSubtitle, extensionStatusLabel } = + swSettingsAgenticCommerceOverride.computed; +const { loadReadinessCounts, applyReadiness, onPrepareSalesChannels, onPrepareModalClose, onSalesChannelsPrepared } = + swSettingsAgenticCommerceOverride.methods; + +describe('sw-settings-agentic-commerce override', () => { + beforeEach(() => { + Shopware.Service.mockReset(); + }); + + it('exposes canViewUcpStatus based on the ucp.viewer privilege', () => { + expect(canViewUcpStatus.call({ acl: { can: () => true } })).toBe(true); + expect(canViewUcpStatus.call({ acl: { can: () => false } })).toBe(false); + expect(canViewUcpStatus.call({})).toBe(false); + }); + + describe('readiness counts', () => { + it('does not call the readiness API without ucp.viewer', () => { + loadReadinessCounts.call({ canViewUcpStatus: false }); + + expect(Shopware.Service).not.toHaveBeenCalled(); + }); + + it('fetches the readiness data and hands it to applyReadiness when ucp.viewer is granted', async () => { + const summary = { + preparedSalesChannels: 2, + agenticSalesChannels: 1, + transactionalSalesChannels: 3, + }; + const getReadiness = jest.fn(() => Promise.resolve({ data: { data: summary } })); + Shopware.Service.mockReturnValue({ getReadiness }); + + const context = { canViewUcpStatus: true, applyReadiness: jest.fn() }; + loadReadinessCounts.call(context); + await Promise.resolve(); + + expect(Shopware.Service).toHaveBeenCalledWith('ucpAdminApiService'); + expect(context.applyReadiness).toHaveBeenCalledWith(summary); + }); + + it('falls back to zero counts when the response omits them', () => { + const context = {}; + applyReadiness.call(context, {}); + + expect(context.preparedSalesChannelCount).toBe(0); + expect(context.agenticSalesChannelCount).toBe(0); + expect(context.transactionalSalesChannelCount).toBe(0); + }); + }); + + describe('prepare-step completion', () => { + it('is not complete until every transactional channel is prepared', () => { + expect( + allSalesChannelsPrepared.call({ + preparedSalesChannelCount: 1, + transactionalSalesChannelCount: 3, + }), + ).toBe(false); + }); + + it('is complete once prepared reaches the transactional total', () => { + expect( + allSalesChannelsPrepared.call({ + preparedSalesChannelCount: 3, + transactionalSalesChannelCount: 3, + }), + ).toBe(true); + }); + + it('is never complete before the counts have loaded', () => { + expect( + allSalesChannelsPrepared.call({ + preparedSalesChannelCount: 0, + transactionalSalesChannelCount: 0, + }), + ).toBe(false); + }); + + it('completes the extension step with the active description', () => { + const context = { + allSalesChannelsPrepared: false, + $t: (key) => key, + $super: () => [{ key: 'extension', complete: false, description: 'install-desc' }], + }; + + const extensionStep = steps.call(context).find((step) => step.key === 'extension'); + + expect(extensionStep.complete).toBe(true); + expect(extensionStep.description).toBe('swagAgenticCommerce.extensionStep.activeDescription'); + }); + + it('marks the prepareSalesChannels step complete and swaps its description when prepared', () => { + const context = { + allSalesChannelsPrepared: true, + $t: (key) => key, + $super: () => [ + { key: 'extension', complete: false, description: 'install-desc' }, + { key: 'prepareSalesChannels', complete: false, description: 'pending-desc' }, + ], + }; + + const result = steps.call(context); + const prepareStep = result.find((step) => step.key === 'prepareSalesChannels'); + + expect(prepareStep.complete).toBe(true); + expect(prepareStep.description).toBe('swagAgenticCommerce.prepareStep.completedDescription'); + expect(result.find((step) => step.key === 'extension').complete).toBe(true); + }); + + it('keeps the pending description while not every channel is prepared', () => { + const context = { + allSalesChannelsPrepared: false, + $t: (key) => key, + $super: () => [{ key: 'prepareSalesChannels', complete: false, description: 'pending-desc' }], + }; + + const prepareStep = steps.call(context).find((step) => step.key === 'prepareSalesChannels'); + + expect(prepareStep.complete).toBe(false); + expect(prepareStep.description).toBe('pending-desc'); + }); + }); + + describe('canPrepareSalesChannels', () => { + const editorAcl = { can: (key) => key === 'ucp.editor' }; + + it('is enabled while unprepared transactional channels remain', () => { + expect( + canPrepareSalesChannels.call({ + acl: editorAcl, + preparedSalesChannelCount: 1, + transactionalSalesChannelCount: 3, + }), + ).toBe(true); + }); + + it('is disabled once all transactional channels are prepared', () => { + expect( + canPrepareSalesChannels.call({ + acl: editorAcl, + preparedSalesChannelCount: 3, + transactionalSalesChannelCount: 3, + }), + ).toBe(false); + }); + + it('is disabled without the ucp.editor privilege', () => { + expect( + canPrepareSalesChannels.call({ + acl: { can: () => false }, + preparedSalesChannelCount: 1, + transactionalSalesChannelCount: 3, + }), + ).toBe(false); + }); + }); + + describe('installed-state texts', () => { + it('uses the active subtitle while setup is pending', () => { + expect(readinessSubtitle.call({ isReady: false, $t: (key) => key })).toBe( + 'swagAgenticCommerce.subtitleActive', + ); + }); + + it('uses the ready subtitle once everything is prepared', () => { + expect(readinessSubtitle.call({ isReady: true, $t: (key) => key })).toBe('swagAgenticCommerce.subtitleReady'); + }); + + it('reports the extension as installed', () => { + expect(extensionStatusLabel.call({ $t: (key) => key })).toBe( + 'swagAgenticCommerce.shopReadiness.extensionInstalled', + ); + }); + }); + + describe('prepare modal', () => { + it('opens the modal on prepare', () => { + const context = { showPrepareModal: false }; + onPrepareSalesChannels.call(context); + + expect(context.showPrepareModal).toBe(true); + }); + + it('closes the modal on cancel', () => { + const context = { showPrepareModal: true }; + onPrepareModalClose.call(context); + + expect(context.showPrepareModal).toBe(false); + }); + + it('applies the returned summary and closes the modal after preparing', () => { + const summary = { preparedSalesChannels: 3, agenticSalesChannels: 0, transactionalSalesChannels: 3 }; + const context = { showPrepareModal: true, applyReadiness: jest.fn(), loadReadinessCounts: jest.fn() }; + + onSalesChannelsPrepared.call(context, summary); + + expect(context.showPrepareModal).toBe(false); + expect(context.applyReadiness).toHaveBeenCalledWith(summary); + expect(context.loadReadinessCounts).not.toHaveBeenCalled(); + }); + + it('reloads the counts when no summary is returned', () => { + const context = { showPrepareModal: true, applyReadiness: jest.fn(), loadReadinessCounts: jest.fn() }; + + onSalesChannelsPrepared.call(context, null); + + expect(context.showPrepareModal).toBe(false); + expect(context.applyReadiness).not.toHaveBeenCalled(); + expect(context.loadReadinessCounts).toHaveBeenCalled(); + }); + }); +}); From 293244eedddaa4bfa378d8e8a59a4fab2f0933a1 Mon Sep 17 00:00:00 2001 From: "D.Neustadt" Date: Tue, 6 Oct 2026 09:38:55 +0200 Subject: [PATCH 2/5] feat: show tooltip on missing permission --- .../sw-settings-agentic-commerce/index.js | 8 +++++++ .../snippet/de.json | 3 ++- .../snippet/en.json | 3 ++- .../index.spec.js | 24 +++++++++++++++++-- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js index 706bdbce..542f3319 100644 --- a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/index.js @@ -76,6 +76,14 @@ export const swSettingsAgenticCommerceOverride = { ); }, + prepareSalesChannelsTooltip() { + return { + message: this.$t('swagAgenticCommerce.prepareStep.missingPermissionTooltip'), + disabled: this.acl?.can?.('ucp.editor') === true, + showOnDisabledElements: true, + }; + }, + readinessSubtitle() { return this.isReady ? this.$t('swagAgenticCommerce.subtitleReady') diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json index 456fae5a..762f8ff9 100644 --- a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/de.json @@ -9,7 +9,8 @@ "extensionInstalled": "Installiert" }, "prepareStep": { - "completedDescription": "Für alle infrage kommenden Verkaufskanäle sind UCP und die KI-Datei-Einstellungen aktiviert." + "completedDescription": "Für alle infrage kommenden Verkaufskanäle sind UCP und die KI-Datei-Einstellungen aktiviert.", + "missingPermissionTooltip": "Du hast keine Berechtigung, Verkaufskanäle vorzubereiten." }, "prepareModal": { "title": "Verkaufskanäle vorbereiten", diff --git a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json index 54a07020..bf2e7958 100644 --- a/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json +++ b/src/Resources/app/administration/src/extension/sw-settings-agentic-commerce/snippet/en.json @@ -9,7 +9,8 @@ "extensionInstalled": "Installed" }, "prepareStep": { - "completedDescription": "All eligible sales channels have UCP and the AI-file settings enabled." + "completedDescription": "All eligible sales channels have UCP and the AI-file settings enabled.", + "missingPermissionTooltip": "You do not have permission to prepare sales channels." }, "prepareModal": { "title": "Prepare sales channels", diff --git a/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js b/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js index d54137c2..237f5beb 100644 --- a/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js +++ b/tests/jest/administration/sw-settings-agentic-commerce/index.spec.js @@ -12,8 +12,15 @@ global.Shopware = { const { swSettingsAgenticCommerceOverride } = require('Resources/extension/sw-settings-agentic-commerce'); -const { canViewUcpStatus, allSalesChannelsPrepared, steps, canPrepareSalesChannels, readinessSubtitle, extensionStatusLabel } = - swSettingsAgenticCommerceOverride.computed; +const { + canViewUcpStatus, + allSalesChannelsPrepared, + steps, + canPrepareSalesChannels, + prepareSalesChannelsTooltip, + readinessSubtitle, + extensionStatusLabel, +} = swSettingsAgenticCommerceOverride.computed; const { loadReadinessCounts, applyReadiness, onPrepareSalesChannels, onPrepareModalClose, onSalesChannelsPrepared } = swSettingsAgenticCommerceOverride.methods; @@ -167,6 +174,19 @@ describe('sw-settings-agentic-commerce override', () => { }), ).toBe(false); }); + + it('activates the prepare tooltip only without the ucp.editor privilege', () => { + const editor = prepareSalesChannelsTooltip.call({ + acl: { can: (key) => key === 'ucp.editor' }, + $t: (key) => key, + }); + expect(editor.disabled).toBe(true); + + const viewer = prepareSalesChannelsTooltip.call({ acl: { can: () => false }, $t: (key) => key }); + expect(viewer.disabled).toBe(false); + expect(viewer.message).toBe('swagAgenticCommerce.prepareStep.missingPermissionTooltip'); + expect(viewer.showOnDisabledElements).toBe(true); + }); }); describe('installed-state texts', () => { From 93af4599410911e7c71e7869254f38fd56d626a4 Mon Sep 17 00:00:00 2001 From: "D.Neustadt" Date: Tue, 6 Oct 2026 10:13:56 +0200 Subject: [PATCH 3/5] fix: php quality --- src/Ucp/Admin/ReadinessSummaryProvider.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Ucp/Admin/ReadinessSummaryProvider.php b/src/Ucp/Admin/ReadinessSummaryProvider.php index 557a5f58..96995780 100644 --- a/src/Ucp/Admin/ReadinessSummaryProvider.php +++ b/src/Ucp/Admin/ReadinessSummaryProvider.php @@ -38,8 +38,7 @@ public function __construct( public function summary(Context $context): ReadinessSummary { - /** @var list $salesChannelIds */ - $salesChannelIds = array_values($this->salesChannelRepository->searchIds(new Criteria(), $context)->getIds()); + $salesChannelIds = $this->salesChannelRepository->searchIds(new Criteria(), $context)->getIds(); if ([] === $salesChannelIds) { return new ReadinessSummary(0, 0, 0); From 78c083382c7a519612caf632613bfc0e3a4e9464 Mon Sep 17 00:00:00 2001 From: "D.Neustadt" Date: Tue, 6 Oct 2026 10:36:47 +0200 Subject: [PATCH 4/5] fix: php quality --- src/Ucp/Admin/ReadinessSummaryProvider.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Ucp/Admin/ReadinessSummaryProvider.php b/src/Ucp/Admin/ReadinessSummaryProvider.php index 96995780..647b390e 100644 --- a/src/Ucp/Admin/ReadinessSummaryProvider.php +++ b/src/Ucp/Admin/ReadinessSummaryProvider.php @@ -38,13 +38,13 @@ public function __construct( public function summary(Context $context): ReadinessSummary { - $salesChannelIds = $this->salesChannelRepository->searchIds(new Criteria(), $context)->getIds(); + $salesChannelIds = array_filter($this->salesChannelRepository->searchIds(new Criteria(), $context)->getIds(), 'is_string'); if ([] === $salesChannelIds) { return new ReadinessSummary(0, 0, 0); } - $classifications = $this->salesChannelTypeResolver->resolveMany($salesChannelIds); + $classifications = $this->salesChannelTypeResolver->resolveMany(array_values($salesChannelIds)); $agenticSalesChannels = \count(array_filter( $classifications, From d0fd4d5387be7d51ad150891b4dcc72b3ccda95f Mon Sep 17 00:00:00 2001 From: "D.Neustadt" Date: Thu, 8 Oct 2026 10:05:49 +0200 Subject: [PATCH 5/5] feat: increase version and add changelogs --- CHANGELOG.md | 4 ++++ CHANGELOG_de-DE.md | 4 ++++ composer.json | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6624707..00733cef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.5.0 + +- Fill in the Agentic Commerce settings page once the extension is installed: show the readiness counts and add a "Prepare sales channels" flow that enables UCP and the AI files for the selected Storefront and Headless channels at once. + # 1.4.0 - Show the UCP tools only to AI agents that connect through `/ucp/mcp`, instead of to every client of Shopware's own MCP server at `/store-api/_mcp`. 1.3.0 put them into the group Shopware reserves for its own discovery tools, so every client connecting to `/store-api/_mcp` saw the thirteen UCP tools next to Shopware's instruction that no tools are listed until a toolset is enabled -- and models followed that instruction, enabling toolsets for tools they already had. The tools now form their own `ucp` toolset, and `/ucp/mcp` selects it at connect time, so a UCP agent still finds them on its first tool listing while a plain `/store-api/_mcp` connection lists only Shopware's discovery tools. On Shopware versions before `6.7.15.0`, which cannot select a toolset at connect time, the tools stay listed on every connection as before. diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index ad90b77b..65fa1c11 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,3 +1,7 @@ +# 1.5.0 + +- Die Agentic-Commerce-Einstellungsseite wird nach der Installation der Erweiterung mit Inhalt gefüllt: Sie zeigt die Bereitschaftszahlen an und bietet eine Funktion „Verkaufskanäle vorbereiten“, die UCP und die KI-Dateien für die ausgewählten Storefront- und Headless-Kanäle gesammelt aktiviert. + # 1.4.0 - Die UCP-Tools sind jetzt nur noch für KI-Agenten sichtbar, die sich über `/ucp/mcp` verbinden, und nicht mehr für jeden Client von Shopwares eigenem MCP-Server unter `/store-api/_mcp`. In 1.3.0 lagen sie in der Gruppe, die Shopware für seine eigenen Discovery-Tools vorsieht. Jeder Client, der sich mit `/store-api/_mcp` verband, bekam deshalb alle dreizehn UCP-Tools angezeigt, obwohl Shopware ihm gleichzeitig mitteilt, dass Tools erst nach dem Aktivieren eines Toolsets erscheinen. Die Modelle haben sich daran gehalten und Toolsets für Tools aktiviert, die sie längst hatten. Jetzt bilden die Tools ein eigenes Toolset `ucp`, das `/ucp/mcp` direkt beim Verbindungsaufbau auswählt: Ein UCP-Agent sieht sie weiterhin sofort, eine normale Verbindung zu `/store-api/_mcp` zeigt nur noch die Discovery-Tools von Shopware. Unter Shopware-Versionen vor `6.7.15.0`, in denen sich beim Verbindungsaufbau kein Toolset auswählen lässt, bleibt alles wie bisher. diff --git a/composer.json b/composer.json index 6ff02c08..6806de40 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "shopware/agentic-commerce", "description": "Shopware plugin for agentic commerce discovery, product feeds, and Universal Commerce Protocol shopping flows", - "version": "1.4.0", + "version": "1.5.0", "type": "shopware-platform-plugin", "license": "MIT", "authors": [