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), ); }