From 3380f28e9c1f7970d07926e12c0cd2f5202a6258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Thu, 11 Jun 2026 14:31:19 +0200 Subject: [PATCH 1/8] Added badges for async publcations statuses in content "versions" tab --- .../async_publication_status_badge.html.twig | 14 ++++++++++ .../content/tab/versions/table.html.twig | 20 +++++++++++++ .../ContentEditNotificationFormProcessor.php | 28 +++++++++++++++---- src/lib/Tab/LocationView/VersionsTab.php | 20 ++++++++++++- .../VersionsTabVisibilityTest.php | 4 +++ 5 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 src/bundle/Resources/views/themes/admin/content/tab/versions/async_publication_status_badge.html.twig diff --git a/src/bundle/Resources/views/themes/admin/content/tab/versions/async_publication_status_badge.html.twig b/src/bundle/Resources/views/themes/admin/content/tab/versions/async_publication_status_badge.html.twig new file mode 100644 index 0000000000..6987b1cb7d --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/content/tab/versions/async_publication_status_badge.html.twig @@ -0,0 +1,14 @@ +{% set badge_variant = { + 'queued': 'ibexa-badge--secondary', + 'processing': 'ibexa-badge--info', + 'failed': 'ibexa-badge--danger', +} %} +{% set badge_label = { + 'queued': 'Queued', + 'processing': 'Publishing', + 'failed': 'Publish failed', +} %} + +{% if badge_variant[status] is defined %} + {{ badge_label[status] }} +{% endif %} diff --git a/src/bundle/Resources/views/themes/admin/content/tab/versions/table.html.twig b/src/bundle/Resources/views/themes/admin/content/tab/versions/table.html.twig index b172fc3c88..3a5a221bfa 100644 --- a/src/bundle/Resources/views/themes/admin/content/tab/versions/table.html.twig +++ b/src/bundle/Resources/views/themes/admin/content/tab/versions/table.html.twig @@ -29,6 +29,11 @@ {% include '@ibexadesign/ui/component/table/table_head_cell.html.twig' with { content: 'tab.versions.table.modified_language'|trans()|desc('Modified language'), } %} + {% if is_draft and async_publication_enabled|default(false) %} + {% include '@ibexadesign/ui/component/table/table_head_cell.html.twig' with { + content: 'tab.versions.table.async_publication_status'|trans()|desc('Publication status'), + } %} + {% endif %} {% block custom_column_headers %}{% endblock %} {% include '@ibexadesign/ui/component/table/table_head_cell.html.twig' with { content: 'tab.versions.table.contributor'|trans()|desc('Contributor'), @@ -73,6 +78,21 @@ {% include '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { content: ibexa_admin_ui_config.languages.mappings[version.initialLanguageCode].name, } %} + {% if is_draft and async_publication_enabled|default(false) %} + {% set async_status = async_publication_status_map[version.versionNo]|default(null) %} + {% set async_status_cell %} + + {% if async_status is not null %} + {% include '@ibexadesign/content/tab/versions/async_publication_status_badge.html.twig' with { status: async_status } only %} + {% endif %} + + {% endset %} + {% include '@ibexadesign/ui/component/table/table_body_cell.html.twig' with { + content: async_status_cell, + } %} + {% endif %} {{ custom_columns }} {% set author_name %} diff --git a/src/lib/Form/Processor/ContentEditNotificationFormProcessor.php b/src/lib/Form/Processor/ContentEditNotificationFormProcessor.php index d50945546f..52b051dde1 100644 --- a/src/lib/Form/Processor/ContentEditNotificationFormProcessor.php +++ b/src/lib/Form/Processor/ContentEditNotificationFormProcessor.php @@ -12,6 +12,7 @@ use Ibexa\ContentForms\Event\ContentFormEvents; use Ibexa\ContentForms\Event\FormActionEvent; use Ibexa\Contracts\AdminUi\Notification\TranslatableNotificationHandlerInterface; +use Ibexa\Contracts\Core\Container\ApiLoader\RepositoryConfigurationProviderInterface; use JMS\TranslationBundle\Annotation\Desc; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\Request; @@ -25,6 +26,7 @@ public function __construct( private TranslatableNotificationHandlerInterface $notificationHandler, private RequestStack $requestStack, + private RepositoryConfigurationProviderInterface $repositoryConfigurationProvider, private array $siteAccessGroups ) { } @@ -51,12 +53,26 @@ public function addPublishMessage(FormActionEvent $event): void return; } - $this->notificationHandler->success( - /** @Desc("Content published.") */ - 'content.published.success', - [], - 'ibexa_content_edit' - ); + if ($this->isAsyncContentPublishEnabled()) { + $this->notificationHandler->success( + /** @Desc("Content publishing started.") */ + 'content.publish.started', + [], + 'ibexa_content_edit' + ); + } else { + $this->notificationHandler->success( + /** @Desc("Content published.") */ + 'content.published.success', + [], + 'ibexa_content_edit' + ); + } + } + + private function isAsyncContentPublishEnabled(): bool + { + return (bool) ($this->repositoryConfigurationProvider->getRepositoryConfig()['async_content_publish'] ?? false); } /** diff --git a/src/lib/Tab/LocationView/VersionsTab.php b/src/lib/Tab/LocationView/VersionsTab.php index dce76999c7..b3be0ba850 100644 --- a/src/lib/Tab/LocationView/VersionsTab.php +++ b/src/lib/Tab/LocationView/VersionsTab.php @@ -18,9 +18,11 @@ use Ibexa\Contracts\AdminUi\Tab\AbstractEventDispatchingTab; use Ibexa\Contracts\AdminUi\Tab\ConditionalTabInterface; use Ibexa\Contracts\AdminUi\Tab\OrderedTabInterface; +use Ibexa\Contracts\Core\Container\ApiLoader\RepositoryConfigurationProviderInterface; use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Contracts\Core\Repository\Values\Content\Location; +use Ibexa\Core\Repository\ContentService\AsyncPublicationService; use Ibexa\User\UserSetting\UserSettingService; use JMS\TranslationBundle\Annotation\Desc; use Pagerfanta\Adapter\ArrayAdapter; @@ -44,7 +46,9 @@ public function __construct( private readonly PermissionResolver $permissionResolver, protected readonly UserService $userService, protected readonly UserSettingService $userSettingService, - EventDispatcherInterface $eventDispatcher + EventDispatcherInterface $eventDispatcher, + private readonly AsyncPublicationService $asyncPublicationService, + private readonly RepositoryConfigurationProviderInterface $repositoryConfigurationProvider ) { parent::__construct($twig, $translator, $eventDispatcher); } @@ -134,6 +138,18 @@ public function getTemplateParameters(array $contextParameters = []): array 'archived_version_restore' ); + $asyncPublicationEnabled = (bool) ($this->repositoryConfigurationProvider + ->getRepositoryConfig()['async_content_publish'] ?? false); + + /** @var array $asyncPublicationStatusMap */ + $asyncPublicationStatusMap = []; + if ($asyncPublicationEnabled) { + $asyncPublicationJob = $this->asyncPublicationService->getPublicationForContent($contentInfo->id); + if ($asyncPublicationJob !== null) { + $asyncPublicationStatusMap[$asyncPublicationJob->versionNo] = $asyncPublicationJob->status->value; + } + } + $parameters = [ 'versions_dataset' => $versionsDataset, 'published_versions' => $versionsDataset->getPublishedVersions(), @@ -144,6 +160,8 @@ public function getTemplateParameters(array $contextParameters = []): array 'draft_pager' => $draftPagerfanta, 'draft_pagination_params' => $draftPaginationParams, 'content_is_user' => (new ContentIsUser($this->userService))->isSatisfiedBy($content), + 'async_publication_enabled' => $asyncPublicationEnabled, + 'async_publication_status_map' => $asyncPublicationStatusMap, ]; return array_replace($contextParameters, $parameters); diff --git a/tests/lib/Tab/LocationView/VersionsTabVisibilityTest.php b/tests/lib/Tab/LocationView/VersionsTabVisibilityTest.php index 60b8825a73..8099ad0de2 100644 --- a/tests/lib/Tab/LocationView/VersionsTabVisibilityTest.php +++ b/tests/lib/Tab/LocationView/VersionsTabVisibilityTest.php @@ -13,9 +13,11 @@ use Ibexa\AdminUi\UI\Dataset\DatasetFactory; use Ibexa\AdminUi\UserSetting\FocusMode; use Ibexa\Contracts\AdminUi\Tab\TabInterface; +use Ibexa\Contracts\Core\Container\ApiLoader\RepositoryConfigurationProviderInterface; use Ibexa\Contracts\Core\Repository\PermissionResolver; use Ibexa\Contracts\Core\Repository\UserService; use Ibexa\Contracts\Core\Repository\Values\Content\Content; +use Ibexa\Core\Repository\ContentService\AsyncPublicationService; use Ibexa\User\UserSetting\UserSettingService; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -56,6 +58,8 @@ protected function createTabForVisibilityInGivenUserModeTest(UserSettingService $this->createMock(UserService::class), $userSettingService, $this->createMock(EventDispatcherInterface::class), + $this->createMock(AsyncPublicationService::class), + $this->createMock(RepositoryConfigurationProviderInterface::class), ); } From 377183a0073b374a2a70dadbdcef959bc2a06d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Thu, 11 Jun 2026 14:40:57 +0200 Subject: [PATCH 2/8] [TMP] remove that commit before merge --- composer.json | 1 + dependencies.json | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 dependencies.json diff --git a/composer.json b/composer.json index 97beea181e..6c90a1a66f 100644 --- a/composer.json +++ b/composer.json @@ -2,6 +2,7 @@ "name": "ibexa/admin-ui", "license": "(GPL-2.0-only or proprietary)", "type": "project", + "minimum-stability": "dev", "description": "Ibexa Admin Ui", "replace": { "ezsystems/ezplatform-admin-ui": "*" diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000000..9ca3a1b265 --- /dev/null +++ b/dependencies.json @@ -0,0 +1,17 @@ +{ + "recipesEndpoint": "", + "packages": [ + { + "requirement": "dev-IBX-11780-async-content-publishing-spike as 6.0.x-dev", + "repositoryUrl": "https://github.com/ibexa/core", + "package": "ibexa/core", + "shouldBeAddedAsVCS": false + }, + { + "requirement": "dev-IBX-11780-async-content-publishing-spike as 6.0.x-dev", + "repositoryUrl": "https://github.com/ibexa/content-forms", + "package": "ibexa/content-forms", + "shouldBeAddedAsVCS": false + } + ] +} \ No newline at end of file From c9098ec18e6b77d140347b25df80b3b7d35843a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Thu, 11 Jun 2026 15:03:20 +0200 Subject: [PATCH 3/8] Unlocked integration tests --- tests/integration/AdminUiIbexaTestKernel.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/integration/AdminUiIbexaTestKernel.php b/tests/integration/AdminUiIbexaTestKernel.php index df57d07014..f036f9c8f3 100644 --- a/tests/integration/AdminUiIbexaTestKernel.php +++ b/tests/integration/AdminUiIbexaTestKernel.php @@ -29,6 +29,7 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Yaml\Yaml; use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; @@ -85,6 +86,9 @@ public function registerContainerConfiguration(LoaderInterface $loader): void $loader->load(static function (ContainerBuilder $container): void { self::configureIbexaBundles($container); self::configureThirdPartyBundles($container); + + // mocked fake dependency only to allow integration tests start + self::addSyntheticService($container, \stdClass::class, 'ibexa.messenger.bus'); }); } From 11e10eafb5f0bbc4302e924914211df5f97647df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 15 Jun 2026 13:30:26 +0200 Subject: [PATCH 4/8] Rerendered content version publication statuses in content's Version tab using ibexa/mercure --- .../AsyncPublicationStatusSubscriber.php | 112 ++++++++++++++++++ .../Mercure/AsyncPublicationTopicResolver.php | 58 +++++++++ src/bundle/Resources/config/services.yaml | 1 + .../services/async_publication_status.yaml | 24 ++++ .../Resources/encore/ibexa.js.config.js | 3 + .../scripts/admin.async.publication.status.js | 69 +++++++++++ ...async_publication_status_scripts.html.twig | 3 + .../Twig/AsyncPublicationExtension.php | 33 ++++++ 8 files changed, 303 insertions(+) create mode 100644 src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php create mode 100644 src/bundle/Mercure/AsyncPublicationTopicResolver.php create mode 100644 src/bundle/Resources/config/services/async_publication_status.yaml create mode 100644 src/bundle/Resources/public/js/scripts/admin.async.publication.status.js create mode 100644 src/bundle/Resources/views/themes/admin/async_publication_status_scripts.html.twig create mode 100644 src/bundle/Templating/Twig/AsyncPublicationExtension.php diff --git a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php new file mode 100644 index 0000000000..132d69282a --- /dev/null +++ b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php @@ -0,0 +1,112 @@ + 'onQueued', + WorkerMessageReceivedEvent::class => 'onProcessing', + WorkerMessageHandledEvent::class => 'onCompleted', + WorkerMessageFailedEvent::class => 'onFailed', + ]; + } + + public function onQueued(SendMessageToTransportsEvent $event): void + { + $this->publishStatus($event->getEnvelope(), self::STATUS_QUEUED); + } + + public function onProcessing(WorkerMessageReceivedEvent $event): void + { + $this->publishStatus($event->getEnvelope(), self::STATUS_PROCESSING); + } + + public function onCompleted(WorkerMessageHandledEvent $event): void + { + $this->publishStatus($event->getEnvelope(), self::STATUS_COMPLETED); + } + + public function onFailed(WorkerMessageFailedEvent $event): void + { + // Stay "processing" while Messenger will still retry the message. + if ($event->willRetry()) { + return; + } + + $this->publishStatus($event->getEnvelope(), self::STATUS_FAILED); + } + + private function publishStatus(Envelope $envelope, string $status): void + { + $message = $envelope->getMessage(); + + if (!$message instanceof PublishContentAsync) { + return; + } + + try { + $this->publisher->publish( + sprintf(self::TOPIC_TEMPLATE, $message->contentId), + [ + 'contentId' => $message->contentId, + 'versionNo' => $message->versionNo, + 'status' => $status, + ], + self::EVENT_TYPE, + ); + } catch (\Throwable $e) { + $this->logger->error('Mercure: failed to publish async publication status: {error}', [ + 'error' => $e->getMessage(), + 'contentId' => $message->contentId, + 'versionNo' => $message->versionNo, + 'status' => $status, + ]); + } + } + + private function isPublishContentAsyncMessage(Envelope $envelope): bool + { + + } +} diff --git a/src/bundle/Mercure/AsyncPublicationTopicResolver.php b/src/bundle/Mercure/AsyncPublicationTopicResolver.php new file mode 100644 index 0000000000..92601a4c2a --- /dev/null +++ b/src/bundle/Mercure/AsyncPublicationTopicResolver.php @@ -0,0 +1,58 @@ +isAsyncContentPublishEnabled()) { + return []; + } + + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null) { + return []; + } + + if ($request->attributes->get('_route') !== self::CONTENT_VIEW_ROUTE) { + return []; + } + + $contentId = $request->attributes->get('contentId'); + + if ($contentId === null) { + return []; + } + + return [sprintf('/async-publication/%s', $contentId)]; + } + + private function isAsyncContentPublishEnabled(): bool + { + return (bool) ($this->repositoryConfigurationProvider->getRepositoryConfig()['async_content_publish'] ?? false); + } +} diff --git a/src/bundle/Resources/config/services.yaml b/src/bundle/Resources/config/services.yaml index 0c62276219..bfdfcfd4c5 100644 --- a/src/bundle/Resources/config/services.yaml +++ b/src/bundle/Resources/config/services.yaml @@ -34,6 +34,7 @@ imports: - { resource: services/action_dispatchers.yaml } - { resource: services/events.yaml } - { resource: services/twig.yaml } + - { resource: services/async_publication_status.yaml } - { resource: services/autosave.yaml } - { resource: services/user.yaml } - { resource: services/commands.yaml } diff --git a/src/bundle/Resources/config/services/async_publication_status.yaml b/src/bundle/Resources/config/services/async_publication_status.yaml new file mode 100644 index 0000000000..f60b787c2c --- /dev/null +++ b/src/bundle/Resources/config/services/async_publication_status.yaml @@ -0,0 +1,24 @@ +services: + _defaults: + autowire: true + autoconfigure: true + public: false + + # Pushes async publication status changes (queued/processing/completed/failed) to the Mercure hub. + Ibexa\Bundle\AdminUi\EventSubscriber\AsyncPublicationStatusSubscriber: ~ + + # Subscribes the content view page to the per-content async publication topic. + Ibexa\Bundle\AdminUi\Mercure\AsyncPublicationTopicResolver: + tags: + - { name: ibexa.mercure.topic_resolver } + + # async_publish_enabled() Twig function (gates the frontend, sibling to mercure_enabled()). + Ibexa\Bundle\AdminUi\Templating\Twig\AsyncPublicationExtension: ~ + + # Injects the badge-updater script into the AdminUI body (gated in the template itself). + ibexa.admin_ui.component.async_publication_status_scripts: + parent: Ibexa\TwigComponents\Component\TemplateComponent + arguments: + $template: '@@ibexadesign/async_publication_status_scripts.html.twig' + tags: + - { name: ibexa.twig.component, group: admin-ui-script-body } diff --git a/src/bundle/Resources/encore/ibexa.js.config.js b/src/bundle/Resources/encore/ibexa.js.config.js index 30b8296fd9..9654eaf7e4 100644 --- a/src/bundle/Resources/encore/ibexa.js.config.js +++ b/src/bundle/Resources/encore/ibexa.js.config.js @@ -83,6 +83,9 @@ if (fs.existsSync(fieldTypesPath)) { module.exports = (Encore) => { Encore.addEntry('ibexa-admin-ui-layout-js', layout) + .addEntry('ibexa-admin-ui-async-publication-status', [ + path.resolve(__dirname, '../public/js/scripts/admin.async.publication.status.js'), + ]) .addEntry('ibexa-admin-ui-error-page-js', [path.resolve(__dirname, '../public/js/scripts/admin.error.page.js')]) .addEntry('ibexa-admin-ui-bookmark-list-js', [ path.resolve(__dirname, '../public/js/scripts/button.state.toggle.js'), diff --git a/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js b/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js new file mode 100644 index 0000000000..e398316a8d --- /dev/null +++ b/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js @@ -0,0 +1,69 @@ +(function (global, doc) { + 'use strict'; + + // Mirrors @ibexadesign/content/tab/versions/async_publication_status_badge.html.twig. + // 'completed' has no backend counterpart and is faked here purely on the UI. + const STATUS_BADGES = { + queued: { variant: 'ibexa-badge--secondary', label: 'Queued' }, + processing: { variant: 'ibexa-badge--info', label: 'Publishing' }, + completed: { variant: 'ibexa-badge--success', label: 'Published' }, + failed: { variant: 'ibexa-badge--danger', label: 'Publish failed' }, + }; + + function renderBadge(container, status) { + const badgeDefinition = STATUS_BADGES[status]; + + if (!badgeDefinition) { + container.replaceChildren(); + + return; + } + + const badge = doc.createElement('span'); + + badge.className = `ibexa-badge ibexa-badge--status ${badgeDefinition.variant}`; + badge.textContent = badgeDefinition.label; + + container.replaceChildren(badge); + } + + function init() { + const containers = doc.querySelectorAll('.ibexa-async-publication-status'); + + if (!containers.length) { + return; + } + + function registerWithMercure() { + const mercureClient = global.ibexa && global.ibexa.mercureClient; + + if (mercureClient) { + mercureClient.on('async_publication_status', () => {}); + + return true; + } + + return false; + } + + if (!registerWithMercure()) { + doc.body.addEventListener('ibexa-mercure:connected', registerWithMercure, { once: true }); + } + + doc.body.addEventListener('ibexa-mercure:async_publication_status', (event) => { + const { contentId, versionNo, status } = event.detail; + + const selector = `.ibexa-async-publication-status[data-content-id="${contentId}"][data-version-no="${versionNo}"]`; + + doc.querySelectorAll(selector).forEach((container) => { + renderBadge(container, status); + }); + }); + } + + if (doc.readyState === 'loading') { + doc.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(window, document); diff --git a/src/bundle/Resources/views/themes/admin/async_publication_status_scripts.html.twig b/src/bundle/Resources/views/themes/admin/async_publication_status_scripts.html.twig new file mode 100644 index 0000000000..375e140fbd --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/async_publication_status_scripts.html.twig @@ -0,0 +1,3 @@ +{% if mercure_enabled() and async_publish_enabled() %} +{{ encore_entry_script_tags('ibexa-admin-ui-async-publication-status', null, 'ibexa') }} +{% endif %} diff --git a/src/bundle/Templating/Twig/AsyncPublicationExtension.php b/src/bundle/Templating/Twig/AsyncPublicationExtension.php new file mode 100644 index 0000000000..46b4d1972e --- /dev/null +++ b/src/bundle/Templating/Twig/AsyncPublicationExtension.php @@ -0,0 +1,33 @@ +isEnabled(...)), + ]; + } + + public function isEnabled(): bool + { + return (bool) ($this->repositoryConfigurationProvider->getRepositoryConfig()['async_content_publish'] ?? false); + } +} From f84d2d91ded1b8631a398df3eca6c9c3377ea7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 15 Jun 2026 14:02:40 +0200 Subject: [PATCH 5/8] Deleted PublishContentAsyncFailureSubscriber and moved status update logic to AsyncPublicationStatusSubscriber --- .../AsyncPublicationStatusSubscriber.php | 74 +++++++++++++++---- 1 file changed, 58 insertions(+), 16 deletions(-) diff --git a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php index 132d69282a..7756200215 100644 --- a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php +++ b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php @@ -9,6 +9,7 @@ namespace Ibexa\Bundle\AdminUi\EventSubscriber; use Ibexa\Bundle\Core\Message\PublishContentAsync; +use Ibexa\Core\Repository\ContentService\AsyncPublicationService; use Ibexa\Mercure\Publisher\MercurePublisher; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; @@ -19,12 +20,13 @@ use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent; /** - * Pushes async content publication status changes to the Mercure hub so the AdminUI Versions tab can - * refresh the per-version publication badge live, without a page reload. + * Single orchestration point for the async content publication lifecycle: reacting to the Symfony + * Messenger events around a {@see PublishContentAsync} message, each handler both updates the backend + * job store (via {@see AsyncPublicationService}) and notifies the AdminUI via Mercure, so the Versions + * tab can refresh the per-version publication badge live, without a page reload. * - * Mirrors {@see \Ibexa\Bundle\Core\EventSubscriber\PublishContentAsyncFailureSubscriber}: the backend - * job store remains the source of truth, this only emits UI notifications. The "completed" status has - * no backend counterpart (the job row is removed on success) and is faked purely on the UI. + * The "completed" status has no backend counterpart (the job row is removed on success) and is faked + * purely on the UI. */ final class AsyncPublicationStatusSubscriber implements EventSubscriberInterface { @@ -37,6 +39,7 @@ final class AsyncPublicationStatusSubscriber implements EventSubscriberInterface private const string STATUS_FAILED = 'failed'; public function __construct( + private readonly AsyncPublicationService $asyncPublicationService, private readonly MercurePublisher $publisher, private readonly LoggerInterface $logger, ) { @@ -54,17 +57,53 @@ public static function getSubscribedEvents(): array public function onQueued(SendMessageToTransportsEvent $event): void { - $this->publishStatus($event->getEnvelope(), self::STATUS_QUEUED); + $envelope = $event->getEnvelope(); + if (!$this->isPublishContentAsyncMessage($envelope)) { + return; + } + + /** @var PublishContentAsync $message */ + $message = $envelope->getMessage(); + + // The job is already recorded as queued by AsyncPublicationService::registerPublication(); + // here we only notify the UI. + $this->publishStatus($message, self::STATUS_QUEUED); } public function onProcessing(WorkerMessageReceivedEvent $event): void { - $this->publishStatus($event->getEnvelope(), self::STATUS_PROCESSING); + $envelope = $event->getEnvelope(); + if (!$this->isPublishContentAsyncMessage($envelope)) { + return; + } + + /** @var PublishContentAsync $message */ + $message = $envelope->getMessage(); + + /** + * open question: should job status updating be here?: + * - coupled with publishing status to frontend + * - or as separated subscriber + * - or part of \Ibexa\Core\Repository\ContentService\AsyncPublicationService + */ + $this->asyncPublicationService->markProcessing($message->contentId); + $this->publishStatus($message, self::STATUS_PROCESSING); } public function onCompleted(WorkerMessageHandledEvent $event): void { - $this->publishStatus($event->getEnvelope(), self::STATUS_COMPLETED); + $envelope = $event->getEnvelope(); + if (!$this->isPublishContentAsyncMessage($envelope)) { + return; + } + + /** @var PublishContentAsync $message */ + $message = $envelope->getMessage(); + + // The new published version now exists; clearing the job clears the AdminUI "in progress" + // indicator. "completed" is faked purely on the UI. + $this->asyncPublicationService->markCompleted($message->contentId); + $this->publishStatus($message, self::STATUS_COMPLETED); } public function onFailed(WorkerMessageFailedEvent $event): void @@ -74,17 +113,20 @@ public function onFailed(WorkerMessageFailedEvent $event): void return; } - $this->publishStatus($event->getEnvelope(), self::STATUS_FAILED); - } + $envelope = $event->getEnvelope(); + if (!$this->isPublishContentAsyncMessage($envelope)) { + return; + } - private function publishStatus(Envelope $envelope, string $status): void - { + /** @var PublishContentAsync $message */ $message = $envelope->getMessage(); - if (!$message instanceof PublishContentAsync) { - return; - } + $this->asyncPublicationService->markFailed($message->contentId, $event->getThrowable()->getMessage()); + $this->publishStatus($message, self::STATUS_FAILED); + } + private function publishStatus(PublishContentAsync $message, string $status): void + { try { $this->publisher->publish( sprintf(self::TOPIC_TEMPLATE, $message->contentId), @@ -107,6 +149,6 @@ private function publishStatus(Envelope $envelope, string $status): void private function isPublishContentAsyncMessage(Envelope $envelope): bool { - + return $envelope->getMessage() instanceof PublishContentAsync; } } From 8a519129b7fb7a7d069dbdf49876a68ed5a6e268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Mon, 15 Jun 2026 14:45:56 +0200 Subject: [PATCH 6/8] Handled server event deffered publishing for registered async publish job --- .../AsyncPublicationStatusSubscriber.php | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php index 7756200215..66c515e729 100644 --- a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php +++ b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php @@ -67,7 +67,7 @@ public function onQueued(SendMessageToTransportsEvent $event): void // The job is already recorded as queued by AsyncPublicationService::registerPublication(); // here we only notify the UI. - $this->publishStatus($message, self::STATUS_QUEUED); + $this->publishStatus($message, self::STATUS_QUEUED, true); } public function onProcessing(WorkerMessageReceivedEvent $event): void @@ -125,18 +125,19 @@ public function onFailed(WorkerMessageFailedEvent $event): void $this->publishStatus($message, self::STATUS_FAILED); } - private function publishStatus(PublishContentAsync $message, string $status): void + private function publishStatus(PublishContentAsync $message, string $status, bool $deffered = false): void { try { - $this->publisher->publish( - sprintf(self::TOPIC_TEMPLATE, $message->contentId), - [ - 'contentId' => $message->contentId, - 'versionNo' => $message->versionNo, - 'status' => $status, - ], - self::EVENT_TYPE, - ); + $topic = sprintf(self::TOPIC_TEMPLATE, $message->contentId); + $data = [ + 'contentId' => $message->contentId, + 'versionNo' => $message->versionNo, + 'status' => $status, + ]; + + $deffered + ? $this->publisher->publishDeferred($topic, $data, self::EVENT_TYPE) + : $this->publisher->publish($topic, $data, self::EVENT_TYPE); } catch (\Throwable $e) { $this->logger->error('Mercure: failed to publish async publication status: {error}', [ 'error' => $e->getMessage(), From 020afc50925281ed2c8cd20ff6876457b981263b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Tue, 16 Jun 2026 14:47:35 +0200 Subject: [PATCH 7/8] Added success notification when async publish is completed --- .../AsyncPublicationStatusSubscriber.php | 17 +++++++++++++++++ .../scripts/admin.async.publication.status.js | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php index 66c515e729..aada0b4bfc 100644 --- a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php +++ b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php @@ -104,6 +104,7 @@ public function onCompleted(WorkerMessageHandledEvent $event): void // indicator. "completed" is faked purely on the UI. $this->asyncPublicationService->markCompleted($message->contentId); $this->publishStatus($message, self::STATUS_COMPLETED); + $this->publishCompletedNotification($message); } public function onFailed(WorkerMessageFailedEvent $event): void @@ -148,6 +149,22 @@ private function publishStatus(PublishContentAsync $message, string $status, boo } } + public function publishCompletedNotification(PublishContentAsync $message): void + { + $contentId = $message->contentId; + + try { + $this->publisher->publish(sprintf('/async-publication/%d', $contentId), [ + 'versionNo' => $message->versionNo, + ], 'async_version_published'); + } catch (\Throwable $e) { + $this->logger->error('Mercure: failed to publish async_version_published notification: {error}', [ + 'error' => $e->getMessage(), + 'contentId' => $contentId, + ]); + } + } + private function isPublishContentAsyncMessage(Envelope $envelope): bool { return $envelope->getMessage() instanceof PublishContentAsync; diff --git a/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js b/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js index e398316a8d..ca481ee1cf 100644 --- a/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js +++ b/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js @@ -39,6 +39,7 @@ if (mercureClient) { mercureClient.on('async_publication_status', () => {}); + mercureClient.on('async_version_published', () => {}); return true; } @@ -59,6 +60,16 @@ renderBadge(container, status); }); }); + + doc.body.addEventListener('ibexa-mercure:async_version_published', (event) => { + const { versionNo } = event.detail; + + if (global.ibexa.helpers && global.ibexa.helpers.notification) { + global.ibexa.helpers.notification.showSuccessNotification( + `New version ${versionNo} of this content has been published. You may see outdated version.` + ); + } + }); } if (doc.readyState === 'loading') { From 4fdb42a75ef57e1f72d1941f72db1cb1b77b9ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Nowak?= Date: Fri, 19 Jun 2026 12:27:29 +0200 Subject: [PATCH 8/8] Adjusted changes to introducing queued publications for multiple content versions --- .../AsyncPublicationStatusSubscriber.php | 17 +++++++++++++---- src/lib/Tab/LocationView/VersionsTab.php | 3 +-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php index aada0b4bfc..496779bc0f 100644 --- a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php +++ b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php @@ -9,6 +9,7 @@ namespace Ibexa\Bundle\AdminUi\EventSubscriber; use Ibexa\Bundle\Core\Message\PublishContentAsync; +use Ibexa\Core\Repository\ContentService\AsyncPublicationDispatcher; use Ibexa\Core\Repository\ContentService\AsyncPublicationService; use Ibexa\Mercure\Publisher\MercurePublisher; use Psr\Log\LoggerInterface; @@ -18,6 +19,7 @@ use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; use Symfony\Component\Messenger\Event\WorkerMessageHandledEvent; use Symfony\Component\Messenger\Event\WorkerMessageReceivedEvent; +use Symfony\Component\Messenger\Stamp\TransportMessageIdStamp; /** * Single orchestration point for the async content publication lifecycle: reacting to the Symfony @@ -40,6 +42,7 @@ final class AsyncPublicationStatusSubscriber implements EventSubscriberInterface public function __construct( private readonly AsyncPublicationService $asyncPublicationService, + private readonly AsyncPublicationDispatcher $dispatcher, private readonly MercurePublisher $publisher, private readonly LoggerInterface $logger, ) { @@ -58,7 +61,7 @@ public static function getSubscribedEvents(): array public function onQueued(SendMessageToTransportsEvent $event): void { $envelope = $event->getEnvelope(); - if (!$this->isPublishContentAsyncMessage($envelope)) { + if (!$this->isPublishContentAsyncMessage($envelope) || null === $envelope->last(TransportMessageIdStamp::class)) { return; } @@ -86,7 +89,7 @@ public function onProcessing(WorkerMessageReceivedEvent $event): void * - or as separated subscriber * - or part of \Ibexa\Core\Repository\ContentService\AsyncPublicationService */ - $this->asyncPublicationService->markProcessing($message->contentId); + $this->asyncPublicationService->markProcessing($message->contentId, $message->versionNo); $this->publishStatus($message, self::STATUS_PROCESSING); } @@ -102,9 +105,12 @@ public function onCompleted(WorkerMessageHandledEvent $event): void // The new published version now exists; clearing the job clears the AdminUI "in progress" // indicator. "completed" is faked purely on the UI. - $this->asyncPublicationService->markCompleted($message->contentId); + $this->asyncPublicationService->markCompleted($message->contentId, $message->versionNo); $this->publishStatus($message, self::STATUS_COMPLETED); $this->publishCompletedNotification($message); + + // This content's in-flight slot is now free; release its next queued version (if any). + $this->dispatcher->dispatchQueued(); } public function onFailed(WorkerMessageFailedEvent $event): void @@ -122,8 +128,11 @@ public function onFailed(WorkerMessageFailedEvent $event): void /** @var PublishContentAsync $message */ $message = $envelope->getMessage(); - $this->asyncPublicationService->markFailed($message->contentId, $event->getThrowable()->getMessage()); + $this->asyncPublicationService->markFailed($message->contentId, $message->versionNo, $event->getThrowable()->getMessage()); $this->publishStatus($message, self::STATUS_FAILED); + + // Terminal failure frees this content's in-flight slot; release its next queued version (if any). + $this->dispatcher->dispatchQueued(); } private function publishStatus(PublishContentAsync $message, string $status, bool $deffered = false): void diff --git a/src/lib/Tab/LocationView/VersionsTab.php b/src/lib/Tab/LocationView/VersionsTab.php index b3be0ba850..0feab7631f 100644 --- a/src/lib/Tab/LocationView/VersionsTab.php +++ b/src/lib/Tab/LocationView/VersionsTab.php @@ -144,8 +144,7 @@ public function getTemplateParameters(array $contextParameters = []): array /** @var array $asyncPublicationStatusMap */ $asyncPublicationStatusMap = []; if ($asyncPublicationEnabled) { - $asyncPublicationJob = $this->asyncPublicationService->getPublicationForContent($contentInfo->id); - if ($asyncPublicationJob !== null) { + foreach ($this->asyncPublicationService->getPublicationsForContent($contentInfo->id) as $asyncPublicationJob) { $asyncPublicationStatusMap[$asyncPublicationJob->versionNo] = $asyncPublicationJob->status->value; } }