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
diff --git a/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php
new file mode 100644
index 0000000000..496779bc0f
--- /dev/null
+++ b/src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php
@@ -0,0 +1,181 @@
+ 'onQueued',
+ WorkerMessageReceivedEvent::class => 'onProcessing',
+ WorkerMessageHandledEvent::class => 'onCompleted',
+ WorkerMessageFailedEvent::class => 'onFailed',
+ ];
+ }
+
+ public function onQueued(SendMessageToTransportsEvent $event): void
+ {
+ $envelope = $event->getEnvelope();
+ if (!$this->isPublishContentAsyncMessage($envelope) || null === $envelope->last(TransportMessageIdStamp::class)) {
+ 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, true);
+ }
+
+ public function onProcessing(WorkerMessageReceivedEvent $event): void
+ {
+ $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, $message->versionNo);
+ $this->publishStatus($message, self::STATUS_PROCESSING);
+ }
+
+ public function onCompleted(WorkerMessageHandledEvent $event): void
+ {
+ $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, $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
+ {
+ // Stay "processing" while Messenger will still retry the message.
+ if ($event->willRetry()) {
+ return;
+ }
+
+ $envelope = $event->getEnvelope();
+ if (!$this->isPublishContentAsyncMessage($envelope)) {
+ return;
+ }
+
+ /** @var PublishContentAsync $message */
+ $message = $envelope->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
+ {
+ try {
+ $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(),
+ 'contentId' => $message->contentId,
+ 'versionNo' => $message->versionNo,
+ 'status' => $status,
+ ]);
+ }
+ }
+
+ 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/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..ca481ee1cf
--- /dev/null
+++ b/src/bundle/Resources/public/js/scripts/admin.async.publication.status.js
@@ -0,0 +1,80 @@
+(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', () => {});
+ mercureClient.on('async_version_published', () => {});
+
+ 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);
+ });
+ });
+
+ 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') {
+ 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/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/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);
+ }
+}
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..0feab7631f 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,17 @@ public function getTemplateParameters(array $contextParameters = []): array
'archived_version_restore'
);
+ $asyncPublicationEnabled = (bool) ($this->repositoryConfigurationProvider
+ ->getRepositoryConfig()['async_content_publish'] ?? false);
+
+ /** @var array $asyncPublicationStatusMap */
+ $asyncPublicationStatusMap = [];
+ if ($asyncPublicationEnabled) {
+ foreach ($this->asyncPublicationService->getPublicationsForContent($contentInfo->id) as $asyncPublicationJob) {
+ $asyncPublicationStatusMap[$asyncPublicationJob->versionNo] = $asyncPublicationJob->status->value;
+ }
+ }
+
$parameters = [
'versions_dataset' => $versionsDataset,
'published_versions' => $versionsDataset->getPublishedVersions(),
@@ -144,6 +159,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/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');
});
}
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),
);
}