Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "*"
Expand Down
17 changes: 17 additions & 0 deletions dependencies.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
181 changes: 181 additions & 0 deletions src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

Check warning on line 1 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Run code style check (8.3)

Found violation(s) of type: AdamWojs/phpdoc_force_fqcn_fixer

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

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;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Event\SendMessageToTransportsEvent;
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
* 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.
*
* 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
{
private const string TOPIC_TEMPLATE = '/async-publication/%d';
private const string EVENT_TYPE = 'async_publication_status';

private const string STATUS_QUEUED = 'queued';
private const string STATUS_PROCESSING = 'processing';
private const string STATUS_COMPLETED = 'completed';
private const string STATUS_FAILED = 'failed';

public function __construct(
private readonly AsyncPublicationService $asyncPublicationService,
private readonly AsyncPublicationDispatcher $dispatcher,
private readonly MercurePublisher $publisher,

Check failure on line 46 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Property Ibexa\Bundle\AdminUi\EventSubscriber\AsyncPublicationStatusSubscriber::$publisher has unknown class Ibexa\Mercure\Publisher\MercurePublisher as its type.

Check failure on line 46 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Parameter $publisher of method Ibexa\Bundle\AdminUi\EventSubscriber\AsyncPublicationStatusSubscriber::__construct() has invalid type Ibexa\Mercure\Publisher\MercurePublisher.

Check failure on line 46 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Property Ibexa\Bundle\AdminUi\EventSubscriber\AsyncPublicationStatusSubscriber::$publisher has unknown class Ibexa\Mercure\Publisher\MercurePublisher as its type.

Check failure on line 46 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Parameter $publisher of method Ibexa\Bundle\AdminUi\EventSubscriber\AsyncPublicationStatusSubscriber::__construct() has invalid type Ibexa\Mercure\Publisher\MercurePublisher.
private readonly LoggerInterface $logger,
) {
}

public static function getSubscribedEvents(): array
{
return [
SendMessageToTransportsEvent::class => '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)

Check failure on line 149 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Call to method publishDeferred() on an unknown class Ibexa\Mercure\Publisher\MercurePublisher.

Check failure on line 149 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Call to method publishDeferred() on an unknown class Ibexa\Mercure\Publisher\MercurePublisher.
: $this->publisher->publish($topic, $data, self::EVENT_TYPE);

Check failure on line 150 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Call to method publish() on an unknown class Ibexa\Mercure\Publisher\MercurePublisher.

Check failure on line 150 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Call to method publish() on an unknown class Ibexa\Mercure\Publisher\MercurePublisher.
} 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), [

Check failure on line 166 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Call to method publish() on an unknown class Ibexa\Mercure\Publisher\MercurePublisher.

Check failure on line 166 in src/bundle/EventSubscriber/AsyncPublicationStatusSubscriber.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Call to method publish() on an unknown class Ibexa\Mercure\Publisher\MercurePublisher.
'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;
}
}
58 changes: 58 additions & 0 deletions src/bundle/Mercure/AsyncPublicationTopicResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\AdminUi\Mercure;

use Ibexa\Contracts\Core\Container\ApiLoader\RepositoryConfigurationProviderInterface;
use Ibexa\Contracts\Mercure\Topic\TopicResolverInterface;
use Symfony\Component\HttpFoundation\RequestStack;

/**
* Subscribes the content view page to the per-content async publication topic so the Versions tab
* can refresh draft publication status badges live. Mirrors the editing-presence topic resolver.
*/
final class AsyncPublicationTopicResolver implements TopicResolverInterface

Check failure on line 19 in src/bundle/Mercure/AsyncPublicationTopicResolver.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Class Ibexa\Bundle\AdminUi\Mercure\AsyncPublicationTopicResolver implements unknown interface Ibexa\Contracts\Mercure\Topic\TopicResolverInterface.

Check failure on line 19 in src/bundle/Mercure/AsyncPublicationTopicResolver.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Class Ibexa\Bundle\AdminUi\Mercure\AsyncPublicationTopicResolver implements unknown interface Ibexa\Contracts\Mercure\Topic\TopicResolverInterface.
{
private const string CONTENT_VIEW_ROUTE = 'ibexa.content.view';

public function __construct(
private readonly RequestStack $requestStack,
private readonly RepositoryConfigurationProviderInterface $repositoryConfigurationProvider,
) {
}

public function resolveTopics(): array

Check failure on line 29 in src/bundle/Mercure/AsyncPublicationTopicResolver.php

View workflow job for this annotation

GitHub Actions / Tests (8.3)

Method Ibexa\Bundle\AdminUi\Mercure\AsyncPublicationTopicResolver::resolveTopics() return type has no value type specified in iterable type array.

Check failure on line 29 in src/bundle/Mercure/AsyncPublicationTopicResolver.php

View workflow job for this annotation

GitHub Actions / Tests (8.4)

Method Ibexa\Bundle\AdminUi\Mercure\AsyncPublicationTopicResolver::resolveTopics() return type has no value type specified in iterable type array.

Check warning on line 29 in src/bundle/Mercure/AsyncPublicationTopicResolver.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This method has 5 returns, which is more than the 3 allowed.

See more on https://sonarcloud.io/project/issues?id=ibexa_admin-ui&issues=AZ7LDche2asXT8Ax6det&open=AZ7LDche2asXT8Ax6det&pullRequest=1937
{
if (!$this->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);
}
}
1 change: 1 addition & 0 deletions src/bundle/Resources/config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
24 changes: 24 additions & 0 deletions src/bundle/Resources/config/services/async_publication_status.yaml
Original file line number Diff line number Diff line change
@@ -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 }
3 changes: 3 additions & 0 deletions src/bundle/Resources/encore/ibexa.js.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {

Check warning on line 37 in src/bundle/Resources/public/js/scripts/admin.async.publication.status.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function 'registerWithMercure' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=ibexa_admin-ui&issues=AZ7LDcdi2asXT8Ax6den&open=AZ7LDcdi2asXT8Ax6den&pullRequest=1937
const mercureClient = global.ibexa && global.ibexa.mercureClient;

Check warning on line 38 in src/bundle/Resources/public/js/scripts/admin.async.publication.status.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=ibexa_admin-ui&issues=AZ7LDcdi2asXT8Ax6deo&open=AZ7LDcdi2asXT8Ax6deo&pullRequest=1937

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) {

Check warning on line 67 in src/bundle/Resources/public/js/scripts/admin.async.publication.status.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=ibexa_admin-ui&issues=AZ7QemVkbsmAfWA631Vu&open=AZ7QemVkbsmAfWA631Vu&pullRequest=1937
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);

Check warning on line 80 in src/bundle/Resources/public/js/scripts/admin.async.publication.status.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=ibexa_admin-ui&issues=AZ7LDcdi2asXT8Ax6dep&open=AZ7LDcdi2asXT8Ax6dep&pullRequest=1937
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% if mercure_enabled() and async_publish_enabled() %}
{{ encore_entry_script_tags('ibexa-admin-ui-async-publication-status', null, 'ibexa') }}
{% endif %}
Loading
Loading