diff --git a/Classes/Application/PublishChangesInDocument.php b/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommand.php similarity index 64% rename from Classes/Application/PublishChangesInDocument.php rename to Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommand.php index 85069b6606..f5c06a6616 100644 --- a/Classes/Application/PublishChangesInDocument.php +++ b/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommand.php @@ -12,30 +12,33 @@ declare(strict_types=1); -namespace Neos\Neos\Ui\Application; +namespace Neos\Neos\Ui\Application\PublishChangesInDocument; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** - * The application layer level command DTO to communicate publication of all changes recorded for a given document + * The application layer level command DTO to communicate publication of + * all changes recorded for a given document * * @internal for communication within the Neos UI only */ #[Flow\Proxy(false)] -final readonly class PublishChangesInDocument +final readonly class PublishChangesInDocumentCommand { public function __construct( public ContentRepositoryId $contentRepositoryId, public WorkspaceName $workspaceName, public NodeAggregateId $documentId, + public ?DimensionSpacePoint $preferredDimensionSpacePoint, ) { } /** - * @param array $values + * @param array{contentRepositoryId:string,workspaceName:string,documentId:string,preferredDimensionSpacePoint?:array} $values */ public static function fromArray(array $values): self { @@ -43,6 +46,9 @@ public static function fromArray(array $values): self ContentRepositoryId::fromString($values['contentRepositoryId']), WorkspaceName::fromString($values['workspaceName']), NodeAggregateId::fromString($values['documentId']), + isset($values['preferredDimensionSpacePoint']) && !empty($values['preferredDimensionSpacePoint']) + ? DimensionSpacePoint::fromLegacyDimensionArray($values['preferredDimensionSpacePoint']) + : null, ); } } diff --git a/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php b/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php new file mode 100644 index 0000000000..d090fdb815 --- /dev/null +++ b/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php @@ -0,0 +1,96 @@ +workspacePublishingService->publishChangesInDocument( + $command->contentRepositoryId, + $command->workspaceName, + $command->documentId + ); + + $workspace = $this->contentRepositoryRegistry->get($command->contentRepositoryId)->findWorkspaceByName( + $command->workspaceName + ); + + return new PublishSucceeded( + numberOfAffectedChanges: $publishingResult->numberOfPublishedChanges, + baseWorkspaceName: $workspace?->baseWorkspaceName?->value + ); + } catch (NodeAggregateCurrentlyDoesNotExist $e) { + throw new \RuntimeException( + $this->getLabel('NodeNotPublishedMissingParentNode'), + 1705053430, + $e + ); + } catch (NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint $e) { + throw new \RuntimeException( + $this->getLabel('NodeNotPublishedParentNodeNotInCurrentDimension'), + 1705053432, + $e + ); + } catch (WorkspaceRebaseFailed $e) { + $conflictsFactory = new ConflictsFactory( + contentRepository: $this->contentRepositoryRegistry + ->get($command->contentRepositoryId), + nodeLabelGenerator: $this->nodeLabelGenerator, + workspaceName: $command->workspaceName, + preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint + ); + + return new ConflictsOccurred( + conflicts: $conflictsFactory->fromWorkspaceRebaseFailed($e) + ); + } + } +} diff --git a/Classes/Application/PublishChangesInSite.php b/Classes/Application/PublishChangesInSite/PublishChangesInSiteCommand.php similarity index 64% rename from Classes/Application/PublishChangesInSite.php rename to Classes/Application/PublishChangesInSite/PublishChangesInSiteCommand.php index f645520cf4..f177482c41 100644 --- a/Classes/Application/PublishChangesInSite.php +++ b/Classes/Application/PublishChangesInSite/PublishChangesInSiteCommand.php @@ -12,30 +12,33 @@ declare(strict_types=1); -namespace Neos\Neos\Ui\Application; +namespace Neos\Neos\Ui\Application\PublishChangesInSite; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** - * The application layer level command DTO to communicate publication of all changes recorded for a given site + * The application layer level command DTO to communicate publication of + * all changes recorded for a given site * * @internal for communication within the Neos UI only */ #[Flow\Proxy(false)] -final readonly class PublishChangesInSite +final readonly class PublishChangesInSiteCommand { public function __construct( public ContentRepositoryId $contentRepositoryId, public WorkspaceName $workspaceName, public NodeAggregateId $siteId, + public ?DimensionSpacePoint $preferredDimensionSpacePoint, ) { } /** - * @param array $values + * @param array{contentRepositoryId:string,workspaceName:string,siteId:string,preferredDimensionSpacePoint?:array} $values */ public static function fromArray(array $values): self { @@ -43,6 +46,9 @@ public static function fromArray(array $values): self ContentRepositoryId::fromString($values['contentRepositoryId']), WorkspaceName::fromString($values['workspaceName']), NodeAggregateId::fromString($values['siteId']), + isset($values['preferredDimensionSpacePoint']) && !empty($values['preferredDimensionSpacePoint']) + ? DimensionSpacePoint::fromLegacyDimensionArray($values['preferredDimensionSpacePoint']) + : null, ); } } diff --git a/Classes/Application/PublishChangesInSite/PublishChangesInSiteCommandHandler.php b/Classes/Application/PublishChangesInSite/PublishChangesInSiteCommandHandler.php new file mode 100644 index 0000000000..2326a5cadb --- /dev/null +++ b/Classes/Application/PublishChangesInSite/PublishChangesInSiteCommandHandler.php @@ -0,0 +1,76 @@ +workspacePublishingService->publishChangesInSite( + $command->contentRepositoryId, + $command->workspaceName, + $command->siteId + ); + + $workspace = $this->contentRepositoryRegistry->get($command->contentRepositoryId)->findWorkspaceByName( + $command->workspaceName + ); + + return new PublishSucceeded( + numberOfAffectedChanges: $publishingResult->numberOfPublishedChanges, + baseWorkspaceName: $workspace?->baseWorkspaceName?->value + ); + } catch (WorkspaceRebaseFailed $e) { + $conflictsFactory = new ConflictsFactory( + contentRepository: $this->contentRepositoryRegistry + ->get($command->contentRepositoryId), + nodeLabelGenerator: $this->nodeLabelGenerator, + workspaceName: $command->workspaceName, + preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint + ); + + return new ConflictsOccurred( + conflicts: $conflictsFactory->fromWorkspaceRebaseFailed($e) + ); + } + } +} diff --git a/Classes/Application/SyncWorkspace/Conflict.php b/Classes/Application/Shared/Conflict.php similarity index 86% rename from Classes/Application/SyncWorkspace/Conflict.php rename to Classes/Application/Shared/Conflict.php index 5f96024af0..faf13b690a 100644 --- a/Classes/Application/SyncWorkspace/Conflict.php +++ b/Classes/Application/Shared/Conflict.php @@ -12,8 +12,9 @@ declare(strict_types=1); -namespace Neos\Neos\Ui\Application\SyncWorkspace; +namespace Neos\Neos\Ui\Application\Shared; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\Flow\Annotations as Flow; /** @@ -25,6 +26,7 @@ final readonly class Conflict implements \JsonSerializable { public function __construct( + public string $key, public ?IconLabel $affectedSite, public ?IconLabel $affectedDocument, public ?IconLabel $affectedNode, diff --git a/Classes/Application/Shared/Conflicts.php b/Classes/Application/Shared/Conflicts.php new file mode 100644 index 0000000000..6521088caf --- /dev/null +++ b/Classes/Application/Shared/Conflicts.php @@ -0,0 +1,42 @@ +items = array_values($items); + } + + public function jsonSerialize(): mixed + { + return $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Classes/Application/Shared/ConflictsOccurred.php b/Classes/Application/Shared/ConflictsOccurred.php new file mode 100644 index 0000000000..fcf1817f5a --- /dev/null +++ b/Classes/Application/Shared/ConflictsOccurred.php @@ -0,0 +1,34 @@ + get_object_vars($this) + ]; + } +} diff --git a/Classes/Application/SyncWorkspace/ReasonForConflict.php b/Classes/Application/Shared/ReasonForConflict.php similarity index 91% rename from Classes/Application/SyncWorkspace/ReasonForConflict.php rename to Classes/Application/Shared/ReasonForConflict.php index ccaf361dde..3979852e28 100644 --- a/Classes/Application/SyncWorkspace/ReasonForConflict.php +++ b/Classes/Application/Shared/ReasonForConflict.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\Neos\Ui\Application\SyncWorkspace; +namespace Neos\Neos\Ui\Application\Shared; /** * @internal for communication within the Neos UI only diff --git a/Classes/Application/SyncWorkspace/TypeOfChange.php b/Classes/Application/Shared/TypeOfChange.php similarity index 93% rename from Classes/Application/SyncWorkspace/TypeOfChange.php rename to Classes/Application/Shared/TypeOfChange.php index 379ba5f119..cd474d30a5 100644 --- a/Classes/Application/SyncWorkspace/TypeOfChange.php +++ b/Classes/Application/Shared/TypeOfChange.php @@ -12,7 +12,7 @@ declare(strict_types=1); -namespace Neos\Neos\Ui\Application\SyncWorkspace; +namespace Neos\Neos\Ui\Application\Shared; /** * @internal for communication within the Neos UI only diff --git a/Classes/Application/SyncWorkspace/Conflicts.php b/Classes/Application/SyncWorkspace/Conflicts.php deleted file mode 100644 index e53c5ef9e3..0000000000 --- a/Classes/Application/SyncWorkspace/Conflicts.php +++ /dev/null @@ -1,60 +0,0 @@ -items = $items; - } - - public static function builder( - ContentRepository $contentRepository, - NodeLabelGeneratorInterface $nodeLabelGenerator, - WorkspaceName $workspaceName, - ?DimensionSpacePoint $preferredDimensionSpacePoint, - ): ConflictsBuilder { - return new ConflictsBuilder( - contentRepository: $contentRepository, - nodeLabelGenerator: $nodeLabelGenerator, - workspaceName: $workspaceName, - preferredDimensionSpacePoint: $preferredDimensionSpacePoint - ); - } - - public function jsonSerialize(): mixed - { - return $this->items; - } - - public function count(): int - { - return count($this->items); - } -} diff --git a/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php b/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php index 48656206f1..3539ee95d7 100644 --- a/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php +++ b/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php @@ -19,6 +19,8 @@ use Neos\Flow\Annotations as Flow; use Neos\Neos\Domain\NodeLabel\NodeLabelGeneratorInterface; use Neos\Neos\Domain\Service\WorkspacePublishingService; +use Neos\Neos\Ui\Application\Shared\ConflictsOccurred; +use Neos\Neos\Ui\Infrastructure\ContentRepository\ConflictsFactory; /** * The application layer level command handler to for rebasing the workspace @@ -37,16 +39,18 @@ final class SyncWorkspaceCommandHandler #[Flow\Inject] protected NodeLabelGeneratorInterface $nodeLabelGenerator; - public function handle(SyncWorkspaceCommand $command): void - { + public function handle( + SyncWorkspaceCommand $command + ): SyncingSucceeded|ConflictsOccurred { try { $this->workspacePublishingService->rebaseWorkspace( $command->contentRepositoryId, $command->workspaceName, $command->rebaseErrorHandlingStrategy ); + return new SyncingSucceeded(); } catch (WorkspaceRebaseFailed $e) { - $conflictsBuilder = Conflicts::builder( + $conflictsFactory = new ConflictsFactory( contentRepository: $this->contentRepositoryRegistry ->get($command->contentRepositoryId), nodeLabelGenerator: $this->nodeLabelGenerator, @@ -54,13 +58,8 @@ public function handle(SyncWorkspaceCommand $command): void preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint ); - foreach ($e->commandsThatFailedDuringRebase as $commandThatFailedDuringRebase) { - $conflictsBuilder->addCommandThatFailedDuringRebase($commandThatFailedDuringRebase); - } - - throw new ConflictsOccurred( - $conflictsBuilder->build(), - 1712832228 + return new ConflictsOccurred( + conflicts: $conflictsFactory->fromWorkspaceRebaseFailed($e) ); } } diff --git a/Classes/Application/SyncWorkspace/ConflictsOccurred.php b/Classes/Application/SyncWorkspace/SyncingSucceeded.php similarity index 59% rename from Classes/Application/SyncWorkspace/ConflictsOccurred.php rename to Classes/Application/SyncWorkspace/SyncingSucceeded.php index e62b9f9029..52f5a0b288 100644 --- a/Classes/Application/SyncWorkspace/ConflictsOccurred.php +++ b/Classes/Application/SyncWorkspace/SyncingSucceeded.php @@ -14,18 +14,19 @@ namespace Neos\Neos\Ui\Application\SyncWorkspace; +use Neos\Flow\Annotations as Flow; + /** + * The application layer level result DTO to signal that a rebase operation + * has succeeded + * * @internal for communication within the Neos UI only */ -final class ConflictsOccurred extends \Exception +#[Flow\Proxy(false)] +final readonly class SyncingSucceeded implements \JsonSerializable { - public function __construct( - public readonly Conflicts $conflicts, - int $code - ) { - parent::__construct( - sprintf('%s conflict(s) occurred during rebase.', count($conflicts)), - $code - ); + public function jsonSerialize(): mixed + { + return ['success' => true]; } } diff --git a/Classes/Controller/BackendServiceController.php b/Classes/Controller/BackendServiceController.php index a346851e46..c77cb7d799 100644 --- a/Classes/Controller/BackendServiceController.php +++ b/Classes/Controller/BackendServiceController.php @@ -18,14 +18,13 @@ use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\WorkspaceIsNotEmptyException; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateCurrentlyDoesNotExist; -use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Eel\FlowQuery\FlowQuery; use Neos\Eel\FlowQuery\Operations\GetOperation; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Log\ThrowableStorageInterface; use Neos\Flow\Mvc\ActionRequest; use Neos\Flow\Mvc\ActionResponse; use Neos\Flow\Mvc\Controller\ActionController; @@ -40,11 +39,12 @@ use Neos\Neos\Ui\Application\DiscardAllChanges; use Neos\Neos\Ui\Application\DiscardChangesInDocument; use Neos\Neos\Ui\Application\DiscardChangesInSite; -use Neos\Neos\Ui\Application\PublishChangesInDocument; -use Neos\Neos\Ui\Application\PublishChangesInSite; +use Neos\Neos\Ui\Application\PublishChangesInDocument\PublishChangesInDocumentCommand; +use Neos\Neos\Ui\Application\PublishChangesInDocument\PublishChangesInDocumentCommandHandler; +use Neos\Neos\Ui\Application\PublishChangesInSite\PublishChangesInSiteCommand; +use Neos\Neos\Ui\Application\PublishChangesInSite\PublishChangesInSiteCommandHandler; use Neos\Neos\Ui\Application\ReloadNodes\ReloadNodesQuery; use Neos\Neos\Ui\Application\ReloadNodes\ReloadNodesQueryHandler; -use Neos\Neos\Ui\Application\SyncWorkspace\ConflictsOccurred; use Neos\Neos\Ui\Application\SyncWorkspace\SyncWorkspaceCommand; use Neos\Neos\Ui\Application\SyncWorkspace\SyncWorkspaceCommandHandler; use Neos\Neos\Ui\ContentRepository\Service\NeosUiNodeService; @@ -144,6 +144,18 @@ class BackendServiceController extends ActionController */ protected $workspacePublishingService; + /** + * @Flow\Inject + * @var PublishChangesInSiteCommandHandler + */ + protected $publishChangesInSiteCommandHandler; + + /** + * @Flow\Inject + * @var PublishChangesInDocumentCommandHandler + */ + protected $publishChangesInDocumentCommandHandler; + /** * @Flow\Inject * @var SyncWorkspaceCommandHandler @@ -156,6 +168,14 @@ class BackendServiceController extends ActionController */ protected $reloadNodesQueryHandler; + /** + * Cant be named here $throwableStorage see https://github.com/neos/flow-development-collection/issues/2928 + * + * @Flow\Inject + * @var ThrowableStorageInterface + */ + protected $throwableStorage2; + /** * Set the controller context on the feedback collection after the controller * has been initialized @@ -198,7 +218,7 @@ public function changeAction(array $changes): void /** * Publish all changes in the current site * - * @phpstan-param array $command + * @phpstan-param array{workspaceName:string,siteId:string,preferredDimensionSpacePoint?:array} $command */ public function publishChangesInSiteAction(array $command): void { @@ -209,19 +229,14 @@ public function publishChangesInSiteAction(array $command): void $command['siteId'] = NodeAddress::fromJsonString( $command['siteId'] )->aggregateId->value; - $command = PublishChangesInSite::fromArray($command); - $publishingResult = $this->workspacePublishingService->publishChangesInSite( - $command->contentRepositoryId, - $command->workspaceName, - $command->siteId, - ); - $this->view->assign('value', [ - 'success' => [ - 'numberOfAffectedChanges' => $publishingResult->numberOfPublishedChanges, - 'baseWorkspaceName' => $publishingResult->targetWorkspaceName->value - ] - ]); + $command = PublishChangesInSiteCommand::fromArray($command); + + $result = $this->publishChangesInSiteCommandHandler + ->handle($command); + + $this->view->assign('value', $result); } catch (\Exception $e) { + $this->throwableStorage2->logThrowable($e); $this->view->assign('value', [ 'error' => [ 'class' => $e::class, @@ -236,7 +251,7 @@ public function publishChangesInSiteAction(array $command): void /** * Publish all changes in the current document * - * @phpstan-param array $command + * @phpstan-param array{workspaceName:string,documentId:string,preferredDimensionSpacePoint?:array} $command */ public function publishChangesInDocumentAction(array $command): void { @@ -247,35 +262,14 @@ public function publishChangesInDocumentAction(array $command): void $command['documentId'] = NodeAddress::fromJsonString( $command['documentId'] )->aggregateId->value; - $command = PublishChangesInDocument::fromArray($command); + $command = PublishChangesInDocumentCommand::fromArray($command); - try { - $publishingResult = $this->workspacePublishingService->publishChangesInDocument( - $command->contentRepositoryId, - $command->workspaceName, - $command->documentId, - ); + $result = $this->publishChangesInDocumentCommandHandler + ->handle($command); - $this->view->assign('value', [ - 'success' => [ - 'numberOfAffectedChanges' => $publishingResult->numberOfPublishedChanges, - 'baseWorkspaceName' => $publishingResult->targetWorkspaceName->value, - ] - ]); - } catch (NodeAggregateCurrentlyDoesNotExist $e) { - throw new \RuntimeException( - $this->getLabel('NodeNotPublishedMissingParentNode'), - 1705053430, - $e - ); - } catch (NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint $e) { - throw new \RuntimeException( - $this->getLabel('NodeNotPublishedParentNodeNotInCurrentDimension'), - 1705053432, - $e - ); - } + $this->view->assign('value', $result); } catch (\Exception $e) { + $this->throwableStorage2->logThrowable($e); $this->view->assign('value', [ 'error' => [ 'class' => $e::class, @@ -311,6 +305,7 @@ public function discardAllChangesAction(array $command): void ] ]); } catch (\Exception $e) { + $this->throwableStorage2->logThrowable($e); $this->view->assign('value', [ 'error' => [ 'class' => $e::class, @@ -350,6 +345,7 @@ public function discardChangesInSiteAction(array $command): void ] ]); } catch (\Exception $e) { + $this->throwableStorage2->logThrowable($e); $this->view->assign('value', [ 'error' => [ 'class' => $e::class, @@ -389,6 +385,7 @@ public function discardChangesInDocumentAction(array $command): void ] ]); } catch (\Exception $e) { + $this->throwableStorage2->logThrowable($e); $this->view->assign('value', [ 'error' => [ 'class' => $e::class, @@ -741,15 +738,9 @@ public function syncWorkspaceAction(string $targetWorkspaceName, bool $force, ?a : RebaseErrorHandlingStrategy::STRATEGY_FAIL ); - $this->syncWorkspaceCommandHandler->handle($command); + $result = $this->syncWorkspaceCommandHandler->handle($command); - $this->view->assign('value', [ - 'success' => true - ]); - } catch (ConflictsOccurred $e) { - $this->view->assign('value', [ - 'conflicts' => $e->conflicts - ]); + $this->view->assign('value', $result); } catch (\Exception $e) { $this->view->assign('value', [ 'error' => [ diff --git a/Classes/Application/SyncWorkspace/ConflictsBuilder.php b/Classes/Infrastructure/ContentRepository/ConflictsFactory.php similarity index 81% rename from Classes/Application/SyncWorkspace/ConflictsBuilder.php rename to Classes/Infrastructure/ContentRepository/ConflictsFactory.php index 138ed415fc..ab3fdf31f3 100644 --- a/Classes/Application/SyncWorkspace/ConflictsBuilder.php +++ b/Classes/Infrastructure/ContentRepository/ConflictsFactory.php @@ -1,7 +1,7 @@ - */ - private array $itemsByAffectedNodeAggregateId = []; + private ?Workspace $workspace; public function __construct( - private ContentRepository $contentRepository, - private NodeLabelGeneratorInterface $nodeLabelGenerator, - private WorkspaceName $workspaceName, - private ?DimensionSpacePoint $preferredDimensionSpacePoint, + private readonly ContentRepository $contentRepository, + private readonly NodeLabelGeneratorInterface $nodeLabelGenerator, + WorkspaceName $workspaceName, + private readonly ?DimensionSpacePoint $preferredDimensionSpacePoint, ) { $this->nodeTypeManager = $contentRepository->getNodeTypeManager(); - } - - public function addCommandThatFailedDuringRebase( - CommandThatFailedDuringRebase $commandThatFailedDuringRebase - ): void { - $nodeAggregateId = $this->extractNodeAggregateIdFromCommand( - $commandThatFailedDuringRebase->command - ); - - if ($nodeAggregateId && isset($this->itemsByAffectedNodeAggregateId[$nodeAggregateId->value])) { - return; - } - $conflict = $this->createConflictFromCommandThatFailedDuringRebase( - $commandThatFailedDuringRebase - ); + $this->workspace = $contentRepository->findWorkspaceByName($workspaceName); + } - $this->items[] = $conflict; + public function fromWorkspaceRebaseFailed( + WorkspaceRebaseFailed $workspaceRebaseFailed + ): Conflicts { + /** @var array */ + $conflictsByKey = []; - if ($nodeAggregateId) { - $this->itemsByAffectedNodeAggregateId[$nodeAggregateId->value] = $conflict; + foreach ($workspaceRebaseFailed->commandsThatFailedDuringRebase as $commandThatFailedDuringRebase) { + $conflict = $this->createConflictFromCommandThatFailedDuringRebase($commandThatFailedDuringRebase); + if (array_key_exists($conflict->key, $conflictsByKey)) { + // deduplicate if the conflict affects the same node + $conflictsByKey[$conflict->key] = $conflict; + } } - } - public function build(): Conflicts - { - return new Conflicts(...$this->items); + return new Conflicts(...$conflictsByKey); } private function createConflictFromCommandThatFailedDuringRebase( @@ -127,6 +118,9 @@ private function createConflictFromCommandThatFailedDuringRebase( : null; return new Conflict( + key: $affectedNode + ? $affectedNode->aggregateId->value + : Algorithms::generateUUID(), affectedSite: $affectedSite ? $this->createIconLabelForNode($affectedSite) : null, @@ -172,9 +166,7 @@ private function acquireSubgraphFromCommand( CommandInterface $command, ?NodeAggregateId $nodeAggregateIdForDimensionFallback ): ?ContentSubgraphInterface { - try { - $contentGraph = $this->contentRepository->getContentGraph($this->workspaceName); - } catch (WorkspaceDoesNotExist) { + if ($this->workspace === null) { return null; } @@ -207,10 +199,9 @@ private function acquireSubgraphFromCommand( return null; } - $nodeAggregate = $contentGraph - ->findNodeAggregateById( - $nodeAggregateIdForDimensionFallback - ); + $nodeAggregate = $this->contentRepository + ->getContentGraph($this->workspace->workspaceName) + ->findNodeAggregateById($nodeAggregateIdForDimensionFallback); if ($nodeAggregate) { $dimensionSpacePoint = $this->extractValidDimensionSpacePointFromNodeAggregate( @@ -223,10 +214,12 @@ private function acquireSubgraphFromCommand( return null; } - return $contentGraph->getSubgraph( - $dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); + return $this->contentRepository + ->getContentGraph($this->workspace->workspaceName) + ->getSubgraph( + $dimensionSpacePoint, + VisibilityConstraints::withoutRestrictions() + ); } private function extractValidDimensionSpacePointFromNodeAggregate( @@ -250,7 +243,7 @@ private function createIconLabelForNode(Node $node): IconLabel return new IconLabel( icon: $nodeType?->getConfiguration('ui.icon') ?? 'questionmark', - label: $this->nodeLabelGenerator->getLabel($node) + label: $this->nodeLabelGenerator->getLabel($node), ); } diff --git a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js index b2db46a047..d6eacfb783 100644 --- a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js +++ b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js @@ -11,153 +11,306 @@ import { fixture`Syncing` .afterEach(() => checkPropTypes()); -fixture.skip`TODO Tests are flaky and create catchup errors rendering following tests also kaput: https://github.com/neos/neos-ui/pull/3769#pullrequestreview-2332466270`; - const contentIframeSelector = Selector('[name="neos-content-main"]', {timeout: 2000}); test('Syncing: Create a conflict state between two editors and choose "Discard all" as a resolution strategy during rebase', async t => { - await prepareConflictBetweenAdminAndEditor(t); - await chooseDiscardAllAndFinishSynchronization(t); - await assertThatSynchronizationWasSuccessful(t); + await prepareContentElementConflictBetweenAdminAndEditor(t); + await chooseDiscardAllAsResolutionStrategy(t); + await confirmAndPerformDiscardAll(t); + await finishSynchronization(t); + + await assertThatWeAreOnPage(t, 'Home'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #1'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #2'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #3'); }); test('Syncing: Create a conflict state between two editors and choose "Drop conflicting changes" as a resolution strategy during rebase', async t => { - await prepareConflictBetweenAdminAndEditor(t); - await chooseDropConflictingChangesAndFinishSynchronization(t); - await assertThatSynchronizationWasSuccessful(t); + await prepareContentElementConflictBetweenAdminAndEditor(t); + await chooseDropConflictingChangesAsResolutionStrategy(t); + await confirmDropConflictingChanges(t); + await finishSynchronization(t); + + await assertThatWeAreOnPage(t, 'Home'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #1'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #2'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #3'); }); -async function prepareConflictBetweenAdminAndEditor(t) { - // - // Login as "editor" once, to initialize a content stream for their workspace - // in case there isn't one already - // - await switchToRole(t, editorUserOnOneDimensionTestSite); - await Page.waitForIframeLoading(); - await t.wait(2000); +test('Syncing: Create a conflict state between two editors, start and cancel resolution, then restart and choose "Drop conflicting changes" as a resolution strategy during rebase', async t => { + await prepareContentElementConflictBetweenAdminAndEditor(t); + await cancelResolutionDuringStrategyChoice(t); + await startSynchronization(t); + await assertThatConflictResolutionHasStarted(t); + await chooseDropConflictingChangesAsResolutionStrategy(t); + await confirmDropConflictingChanges(t); + await finishSynchronization(t); + + await assertThatWeAreOnPage(t, 'Home'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #1'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #2'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #3'); +}); + +test('Syncing: Create a conflict state between two editors and choose "Drop conflicting changes" as a resolution strategy, then cancel and choose "Discard all" as a resolution strategy during rebase', async t => { + await prepareContentElementConflictBetweenAdminAndEditor(t); + await chooseDropConflictingChangesAsResolutionStrategy(t); + await cancelDropConflictingChanges(t); + await chooseDiscardAllAsResolutionStrategy(t); + await confirmAndPerformDiscardAll(t); + await finishSynchronization(t); + + await assertThatWeAreOnPage(t, 'Home'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #1'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #2'); + await assertThatWeCannotSeePageInTree(t, 'Sync Demo #3'); +}); + +test('Publish + Syncing: Create a conflict state between two editors, then try to publish and choose "Drop conflicting changes" as a resolution strategy during automatic rebase', async t => { + await prepareDocumentConflictBetweenAdminAndEditor(t); + await startPublishAll(t); + await assertThatConflictResolutionHasStarted(t); + await chooseDropConflictingChangesAsResolutionStrategy(t); + await confirmDropConflictingChanges(t); + await finishPublish(t); + + await assertThatWeAreOnPage(t, 'Home'); + await assertThatWeCannotSeePageInTree(t, 'This page will be deleted during sync'); +}); + +test('Publish + Syncing: Create a conflict state between two editors, then try to publish the document only and choose "Drop conflicting changes" as a resolution strategy during automatic rebase', async t => { + await prepareDocumentConflictBetweenAdminAndEditor(t); + await startPublishDocument(t); + await assertThatConflictResolutionHasStarted(t); + await chooseDropConflictingChangesAsResolutionStrategy(t); + await confirmDropConflictingChanges(t); + await finishPublish(t); + + await assertThatWeAreOnPage(t, 'Home'); + await assertThatWeCannotSeePageInTree(t, 'This page will be deleted during sync'); +}); + +async function prepareContentElementConflictBetweenAdminAndEditor(t) { + await loginAsEditorOnceToInitializeAContentStreamForTheirWorkspaceIfNeeded(t); // // Login as "admin" // - await switchToRole(t, adminUserOnOneDimensionTestSite); - await PublishDropDown.discardAll(); + await as(t, adminUserOnOneDimensionTestSite, async () => { + await PublishDropDown.discardAll(); - // - // Create a hierarchy of document nodes - // - async function createDocumentNode(pageTitleToCreate) { - await t - .click(Selector('#neos-PageTree-AddNode')) - .click(ReactSelector('InsertModeSelector').find('#into')) - .click(ReactSelector('NodeTypeItem').find('button>span>span').withText('Page_Test')) - .typeText(Selector('#neos-NodeCreationDialog-Body input'), pageTitleToCreate) - .click(Selector('#neos-NodeCreationDialog-CreateNew')); - await Page.waitForIframeLoading(); - } - await createDocumentNode('Sync Demo #1'); - await createDocumentNode('Sync Demo #2'); - await createDocumentNode('Sync Demo #3'); + // + // Create a hierarchy of document nodes + // + await createDocumentNode(t, 'Home', 'into', 'Sync Demo #1'); + await createDocumentNode(t, 'Sync Demo #1', 'into', 'Sync Demo #2'); + await createDocumentNode(t, 'Sync Demo #2', 'into', 'Sync Demo #3'); - // - // Publish everything - // - await PublishDropDown.publishAll(); + // + // Publish everything + // + await PublishDropDown.publishAll(); + }); // // Login as "editor" // - await switchToRole(t, editorUserOnOneDimensionTestSite); + await as(t, editorUserOnOneDimensionTestSite, async () => { + // + // Sync changes from "admin" + // + await t.wait(2000); + await t.eval(() => location.reload(true)); + await waitForReact(30000); + await Page.waitForIframeLoading(); + await startSynchronization(t); + await t.wait(1000); - // - // Sync changes from "admin" - // - await t.click(Selector('#neos-workspace-rebase')); - await t.click(Selector('#neos-SyncWorkspace-Confirm')); - await t.wait(1000); + // + // Assert that all 3 documents are now visible in the document tree + // + await t.expect(Page.treeNode.withExactText('Sync Demo #1').exists) + .ok('[🗋 Sync Demo #1] cannot be found in the document tree of user "editor".'); + await t.expect(Page.treeNode.withExactText('Sync Demo #2').exists) + .ok('[🗋 Sync Demo #2] cannot be found in the document tree of user "editor".'); + await t.expect(Page.treeNode.withExactText('Sync Demo #3').exists) + .ok('[🗋 Sync Demo #3] cannot be found in the document tree of user "editor".'); + }); - // - // Assert that all 3 documents are now visible in the document tree - // - await t.expect(Page.treeNode.withExactText('Sync Demo #1').exists) - .ok('[🗋 Sync Demo #1] cannot be found in the document tree of user "editor".'); - await t.expect(Page.treeNode.withExactText('Sync Demo #2').exists) - .ok('[🗋 Sync Demo #2] cannot be found in the document tree of user "editor".'); - await t.expect(Page.treeNode.withExactText('Sync Demo #3').exists) - .ok('[🗋 Sync Demo #3] cannot be found in the document tree of user "editor".'); // // Login as "admin" again // - await switchToRole(t, adminUserOnOneDimensionTestSite); + await as(t, adminUserOnOneDimensionTestSite, async () => { + // + // Create a headline node in [🗋 Sync Demo #3] + // + await Page.goToPage('Sync Demo #3'); + await t + .switchToIframe(contentIframeSelector) + .click(Selector('.neos-contentcollection')) + .click(Selector('#neos-InlineToolbar-AddNode')) + .switchToMainWindow() + .click(Selector('button#into')) + .click(ReactSelector('NodeTypeItem').withProps({nodeType: {label: 'Headline_Test'}})) + .switchToIframe(contentIframeSelector) + .typeText(Selector('.test-headline h1'), 'Hello from Page "Sync Demo #3"!') + .wait(2000) + .switchToMainWindow(); + }); - // - // Create a headline node in [🗋 Sync Demo #3] - // - await Page.goToPage('Sync Demo #3'); - await t - .switchToIframe(contentIframeSelector) - .click(Selector('.neos-contentcollection')) - .click(Selector('#neos-InlineToolbar-AddNode')) - .switchToMainWindow() - .click(Selector('button#into')) - .click(ReactSelector('NodeTypeItem').withProps({nodeType: {label: 'Headline_Test'}})) - .switchToIframe(contentIframeSelector) - .typeText(Selector('.test-headline h1'), 'Hello from Page "Sync Demo #3"!') - .wait(2000) - .switchToMainWindow(); // // Login as "editor" again // - await switchToRole(t, editorUserOnOneDimensionTestSite); + await as(t, editorUserOnOneDimensionTestSite, async () => { + // + // Delete page [🗋 Sync Demo #1] + // + await deleteDocumentNode(t, 'Sync Demo #1'); - // - // Delete page [🗋 Sync Demo #1] - // - await Page.goToPage('Sync Demo #1'); - await t.click(Selector('#neos-PageTree-DeleteSelectedNode')); - await t.click(Selector('#neos-DeleteNodeModal-Confirm')); - await Page.waitForIframeLoading(); + // + // Publish everything + // + await PublishDropDown.publishAll(); + }); - // - // Publish everything - // - await PublishDropDown.publishAll(); // // Login as "admin" again and visit [🗋 Sync Demo #3] // + await as(t, adminUserOnOneDimensionTestSite, async () => { + await Page.goToPage('Sync Demo #3'); + + // + // Sync changes from "editor" + // + await startSynchronization(t); + await assertThatConflictResolutionHasStarted(t); + }); +} + +async function prepareDocumentConflictBetweenAdminAndEditor(t) { + await loginAsEditorOnceToInitializeAContentStreamForTheirWorkspaceIfNeeded(t); + + await as(t, adminUserOnOneDimensionTestSite, async () => { + await PublishDropDown.discardAll(); + await createDocumentNode(t, 'Home', 'into', 'This page will be deleted during sync'); + await PublishDropDown.publishAll(); + + await t + .switchToIframe(contentIframeSelector) + .click(Selector('.neos-contentcollection')) + .click(Selector('#neos-InlineToolbar-AddNode')) + .switchToMainWindow() + .click(Selector('button#into')) + .click(ReactSelector('NodeTypeItem').withProps({nodeType: {label: 'Headline_Test'}})) + .switchToIframe(contentIframeSelector) + .doubleClick(Selector('.test-headline h1')) + .typeText(Selector('.test-headline h1'), 'This change will not be published.') + .wait(2000) + .switchToMainWindow(); + }); + + await as(t, editorUserOnOneDimensionTestSite, async () => { + await t.wait(2000); + await t.eval(() => location.reload(true)); + await waitForReact(30000); + await Page.waitForIframeLoading(); + await startSynchronization(t); + await t.wait(1000); + await finishSynchronization(t); + + await t.expect(Page.treeNode.withExactText('This page will be deleted during sync').exists) + .ok('[🗋 This page will be deleted during sync] cannot be found in the document tree of user "editor".'); + + await deleteDocumentNode(t, 'This page will be deleted during sync'); + await PublishDropDown.publishAll(); + }); + await switchToRole(t, adminUserOnOneDimensionTestSite); - await Page.goToPage('Sync Demo #3'); + await Page.goToPage('This page will be deleted during sync'); +} - // - // Sync changes from "editor" - // - await t.click(Selector('#neos-workspace-rebase')); - await t.click(Selector('#neos-SyncWorkspace-Confirm')); - await t.expect(Selector('#neos-SelectResolutionStrategy-SelectBox').exists) - .ok('Select box for resolution strategy slection is not available', { - timeout: 30000 - }); +let editHasLoggedInAtLeastOnce = false; +async function loginAsEditorOnceToInitializeAContentStreamForTheirWorkspaceIfNeeded(t) { + if (editHasLoggedInAtLeastOnce) { + return; + } + + await as(t, editorUserOnOneDimensionTestSite, async () => { + await Page.waitForIframeLoading(); + await t.wait(2000); + editHasLoggedInAtLeastOnce = true; + }); +} + +async function as(t, role, asyncCallback) { + await switchToRole(t, role); + await asyncCallback(); } async function switchToRole(t, role) { + // We need to add a time buffer here, otherwise `t.useRole` might interrupt + // some long-running background process, errororing like this: + // > Error: NetworkError when attempting to fetch resource. + await t.wait(2000); await t.useRole(role); await waitForReact(30000); await Page.goToPage('Home'); } -async function chooseDiscardAllAndFinishSynchronization(t) { - // - // Choose "Discard All" as resolution strategy - // +async function createDocumentNode(t, referencePageTitle, insertMode, pageTitleToCreate) { + await Page.goToPage(referencePageTitle); + await t + .click(Selector('#neos-PageTree-AddNode')) + .click(ReactSelector('InsertModeSelector').find('#' + insertMode)) + .click(ReactSelector('NodeTypeItem').find('button>span>span').withText('Page_Test')) + .typeText(Selector('#neos-NodeCreationDialog-Body input'), pageTitleToCreate) + .click(Selector('#neos-NodeCreationDialog-CreateNew')); + await Page.waitForIframeLoading(); +} + +async function deleteDocumentNode(t, pageTitleToDelete) { + await Page.goToPage(pageTitleToDelete); + await t.click(Selector('#neos-PageTree-DeleteSelectedNode')); + await t.click(Selector('#neos-DeleteNodeModal-Confirm')); + await Page.waitForIframeLoading(); +} + +async function startPublishAll(t) { + await t.click(PublishDropDown.publishDropdown) + await t.click(PublishDropDown.publishDropdownPublishAll); + await t.click(Selector('#neos-PublishDialog-Confirm')); +} + +async function startPublishDocument(t) { + await t.click(Selector('#neos-PublishDropDown-Publish')) + await t.click(Selector('#neos-PublishDialog-Confirm')); +} + +async function finishPublish(t) { + await assertThatPublishingHasFinishedWithoutError(t); + await t.click(Selector('#neos-PublishDialog-Acknowledge')); + await t.wait(2000); +} + +async function startSynchronization(t) { + await t.click(Selector('#neos-workspace-rebase')); + await t.click(Selector('#neos-SyncWorkspace-Confirm')); +} + +async function cancelResolutionDuringStrategyChoice(t) { + await t.click(Selector('#neos-SelectResolutionStrategy-Cancel')); +} + +async function chooseDiscardAllAsResolutionStrategy(t) { await t.click(Selector('#neos-SelectResolutionStrategy-SelectBox')); await t.click(Selector('[role="button"]').withText('Discard workspace "admin-admington"')); await t.click(Selector('#neos-SelectResolutionStrategy-Accept')); +} - // - // Go through discard workflow - // +async function confirmAndPerformDiscardAll(t) { await t.click(Selector('#neos-DiscardDialog-Confirm')); await t.expect(Selector('#neos-DiscardDialog-Acknowledge').exists) .ok('Acknowledge button for "Discard all" is not available.', { @@ -165,55 +318,65 @@ async function chooseDiscardAllAndFinishSynchronization(t) { }); // For reasons unknown, we have to press the acknowledge button really // hard for testcafe to realize our intent... - await t.wait(200); + await t.wait(500); await t.click(Selector('#neos-DiscardDialog-Acknowledge')); - - // - // Synchronization should restart automatically, - // so we must wait for it to succeed - // - await t.expect(Selector('#neos-SyncWorkspace-Acknowledge').exists) - .ok('Acknowledge button for "Sync Workspace" is not available.', { - timeout: 30000 - }); - await t.click(Selector('#neos-SyncWorkspace-Acknowledge')); } -async function chooseDropConflictingChangesAndFinishSynchronization(t) { - // - // Choose "Drop conflicting changes" as resolution strategy - // +async function chooseDropConflictingChangesAsResolutionStrategy(t) { await t.click(Selector('#neos-SelectResolutionStrategy-SelectBox')); await t.click(Selector('[role="button"]').withText('Drop conflicting changes')); await t.click(Selector('#neos-SelectResolutionStrategy-Accept')); +} - // - // Confirm the strategy - // +async function confirmDropConflictingChanges(t) { await t.click(Selector('#neos-ResolutionStrategyConfirmation-Confirm')); +} + +async function cancelDropConflictingChanges(t) { + await t.click(Selector('#neos-ResolutionStrategyConfirmation-Cancel')); +} + +async function finishSynchronization(t) { + await assertThatSynchronizationHasFinishedWithoutError(t); + await t.click(Selector('#neos-SyncWorkspace-Acknowledge')); +} + +async function assertThatConflictResolutionHasStarted(t) { + await t.expect(Selector('#neos-SelectResolutionStrategy-SelectBox').exists) + .ok('Select box for resolution strategy slection is not available', { + timeout: 30000 + }); +} + +async function assertThatSynchronizationHasFinishedWithoutError(t) { await t.expect(Selector('#neos-SyncWorkspace-Acknowledge').exists) .ok('Acknowledge button for "Sync Workspace" is not available.', { timeout: 30000 }); - await t.click(Selector('#neos-SyncWorkspace-Acknowledge')); + await t.expect(Selector('#neos-SyncWorkspace-Retry').exists) + .notOk('An error occurred during "Sync Workspace".', { + timeout: 30000 + }); } -async function assertThatSynchronizationWasSuccessful(t) { - // - // Assert that we have been redirected to the home page by checking if - // the currently focused document tree node is "Home". - // +async function assertThatPublishingHasFinishedWithoutError(t) { + await t.expect(Selector('#neos-PublishDialog-Acknowledge').exists) + .ok('Acknowledge button for "Publishing" is not available.', { + timeout: 30000 + }); + await t.expect(Selector('#neos-PublishDialog-Retry').exists) + .notOk('An error occurred during "Publishing".', { + timeout: 30000 + }); +} + +async function assertThatWeAreOnPage(t, pageTitle) { await t .expect(Selector('[role="treeitem"] [role="button"][class*="isFocused"]').textContent) - .eql('Home'); + .eql(pageTitle); +} - // - // Assert that all 3 documents are not visible anymore in the document tree - // - await t.expect(Page.treeNode.withExactText('Sync Demo #1').exists) - .notOk('[🗋 Sync Demo #1] can still be found in the document tree of user "admin".'); - await t.expect(Page.treeNode.withExactText('Sync Demo #2').exists) - .notOk('[🗋 Sync Demo #2] can still be found in the document tree of user "admin".'); - await t.expect(Page.treeNode.withExactText('Sync Demo #3').exists) - .notOk('[🗋 Sync Demo #3] can still be found in the document tree of user "admin".'); +async function assertThatWeCannotSeePageInTree(t, pageTitle) { + await t.expect(Page.treeNode.withExactText(pageTitle).exists) + .notOk(`[🗋 ${pageTitle}] can still be found in the document tree of user "admin".`); } diff --git a/Tests/IntegrationTests/pageModel.js b/Tests/IntegrationTests/pageModel.js index c018ad505f..c75de226d5 100644 --- a/Tests/IntegrationTests/pageModel.js +++ b/Tests/IntegrationTests/pageModel.js @@ -107,6 +107,7 @@ export class PublishDropDown { timeout: 30000 }); await t.click($acknowledgeBtn); + await t.wait(2000); } } diff --git a/packages/neos-ui-backend-connector/src/Endpoints/index.ts b/packages/neos-ui-backend-connector/src/Endpoints/index.ts index 1bcfed2c08..aa2130b77d 100644 --- a/packages/neos-ui-backend-connector/src/Endpoints/index.ts +++ b/packages/neos-ui-backend-connector/src/Endpoints/index.ts @@ -69,7 +69,7 @@ export default (routes: Routes) => { })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); - const publishChangesInSite = (siteId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + const publishChangesInSite = (siteId: NodeContextPath, workspaceName: WorkspaceName, preferredDimensionSpacePoint: null|DimensionCombination) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ url: routes.ui.service.publishChangesInSite, method: 'POST', credentials: 'include', @@ -78,12 +78,12 @@ export default (routes: Routes) => { 'Content-Type': 'application/json' }, body: JSON.stringify({ - command: {siteId, workspaceName} + command: {siteId, workspaceName, preferredDimensionSpacePoint} }) })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); - const publishChangesInDocument = (documentId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + const publishChangesInDocument = (documentId: NodeContextPath, workspaceName: WorkspaceName, preferredDimensionSpacePoint: null|DimensionCombination) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ url: routes.ui.service.publishChangesInDocument, method: 'POST', credentials: 'include', @@ -92,7 +92,7 @@ export default (routes: Routes) => { 'Content-Type': 'application/json' }, body: JSON.stringify({ - command: {documentId, workspaceName} + command: {documentId, workspaceName, preferredDimensionSpacePoint} }) })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); diff --git a/packages/neos-ui-redux-store/src/CR/Nodes/index.ts b/packages/neos-ui-redux-store/src/CR/Nodes/index.ts index 8ffc425d35..91dfa69abc 100644 --- a/packages/neos-ui-redux-store/src/CR/Nodes/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Nodes/index.ts @@ -470,10 +470,16 @@ export const reducer = (state: State = defaultState, action: InitAction | EditPr if (!newNode) { throw new Error('This error should never be thrown, it\'s a way to fool TypeScript'); } - const mergedNode = defaultsDeep({}, newNode, draft.byContextPath[contextPath]); - // Force overwrite of children + const oldNode = state.byContextPath[contextPath]; + const mergedNode = defaultsDeep({}, newNode, oldNode); if (newNode.children !== undefined) { + // Force overwrite of children mergedNode.children = newNode.children; + } else if (!oldNode) { + // newNode only adds meta info, but oldNode is gone from the store. + // In order to avoid zombie nodes occupying the store, we'll leave + // the node alone in this case. + return; } // Force overwrite of matchesCurrentDimensions if (newNode.matchesCurrentDimensions !== undefined) { diff --git a/packages/neos-ui-redux-store/src/CR/Publishing/index.ts b/packages/neos-ui-redux-store/src/CR/Publishing/index.ts index 21fea2cac6..7191709d60 100644 --- a/packages/neos-ui-redux-store/src/CR/Publishing/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Publishing/index.ts @@ -25,6 +25,7 @@ export enum PublishingScope { export enum PublishingPhase { START, ONGOING, + CONFLICTS, SUCCESS, ERROR } @@ -35,6 +36,7 @@ export type State = null | { process: | { phase: PublishingPhase.START } | { phase: PublishingPhase.ONGOING } + | { phase: PublishingPhase.CONFLICTS } | { phase: PublishingPhase.ERROR; error: null | AnyError; @@ -51,6 +53,8 @@ export enum actionTypes { STARTED = '@neos/neos-ui/CR/Publishing/STARTED', CANCELLED = '@neos/neos-ui/CR/Publishing/CANCELLED', CONFIRMED = '@neos/neos-ui/CR/Publishing/CONFIRMED', + CONFLICTS_OCCURRED = '@neos/neos-ui/CR/Publishing/CONFLICTS_OCCURRED', + CONFLICTS_RESOLVED = '@neos/neos-ui/CR/Publishing/CONFLICTS_RESOLVED', FAILED = '@neos/neos-ui/CR/Publishing/FAILED', RETRIED = '@neos/neos-ui/CR/Publishing/RETRIED', SUCEEDED = '@neos/neos-ui/CR/Publishing/SUCEEDED', @@ -74,6 +78,16 @@ const cancel = () => createAction(actionTypes.CANCELLED); */ const confirm = () => createAction(actionTypes.CONFIRMED); +/** + * Signal that conflicts have occurred during the publish/discard operation + */ +const conflicts = () => createAction(actionTypes.CONFLICTS_OCCURRED); + +/** + * Signal that conflicts have been resolved during the publish/discard operation + */ +const resolveConflicts = () => createAction(actionTypes.CONFLICTS_RESOLVED); + /** * Signal that the ongoing publish/discard workflow has failed */ @@ -108,6 +122,8 @@ export const actions = { start, cancel, confirm, + conflicts, + resolveConflicts, fail, retry, succeed, @@ -145,6 +161,20 @@ export const reducer = (state: State = defaultState, action: Action): State => { phase: PublishingPhase.ONGOING } }; + case actionTypes.CONFLICTS_OCCURRED: + return { + ...state, + process: { + phase: PublishingPhase.CONFLICTS + } + }; + case actionTypes.CONFLICTS_RESOLVED: + return { + ...state, + process: { + phase: PublishingPhase.ONGOING + } + }; case actionTypes.FAILED: return { ...state, diff --git a/packages/neos-ui-redux-store/src/CR/Syncing/index.ts b/packages/neos-ui-redux-store/src/CR/Syncing/index.ts index 2c41a72cd8..ea6b159677 100644 --- a/packages/neos-ui-redux-store/src/CR/Syncing/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Syncing/index.ts @@ -32,6 +32,7 @@ export enum ReasonForConflict { } export type Conflict = { + key: string; affectedNode: null | { icon: string; label: string; @@ -49,6 +50,7 @@ export type Conflict = { }; export type State = null | { + autoAcknowledge: boolean; process: | { phase: SyncingPhase.START } | { phase: SyncingPhase.ONGOING } @@ -177,12 +179,24 @@ export const reducer = (state: State = defaultState, action: Action): State => { if (state === null) { if (action.type === actionTypes.STARTED) { return { + autoAcknowledge: false, process: { phase: SyncingPhase.START } }; } + if (action.type === actionTypes.CONFLICTS_DETECTED) { + return { + autoAcknowledge: true, + process: { + phase: SyncingPhase.CONFLICT, + conflicts: action.payload.conflicts, + strategy: null + } + }; + } + return null; } @@ -191,12 +205,14 @@ export const reducer = (state: State = defaultState, action: Action): State => { return null; case actionTypes.CONFIRMED: return { + ...state, process: { phase: SyncingPhase.ONGOING } }; case actionTypes.CONFLICTS_DETECTED: return { + ...state, process: { phase: SyncingPhase.CONFLICT, conflicts: action.payload.conflicts, @@ -206,6 +222,7 @@ export const reducer = (state: State = defaultState, action: Action): State => { case actionTypes.RESOLUTION_STARTED: if (state.process.phase === SyncingPhase.CONFLICT) { return { + ...state, process: { ...state.process, phase: SyncingPhase.RESOLVING, @@ -217,6 +234,7 @@ export const reducer = (state: State = defaultState, action: Action): State => { case actionTypes.RESOLUTION_CANCELLED: if (state.process.phase === SyncingPhase.RESOLVING) { return { + ...state, process: { ...state.process, phase: SyncingPhase.CONFLICT @@ -226,12 +244,14 @@ export const reducer = (state: State = defaultState, action: Action): State => { return state; case actionTypes.RESOLUTION_CONFIRMED: return { + ...state, process: { phase: SyncingPhase.ONGOING } }; case actionTypes.FAILED: return { + ...state, process: { phase: SyncingPhase.ERROR, error: action.payload.error @@ -239,12 +259,18 @@ export const reducer = (state: State = defaultState, action: Action): State => { }; case actionTypes.RETRIED: return { + ...state, process: { phase: SyncingPhase.ONGOING } }; case actionTypes.SUCEEDED: + if (state.autoAcknowledge) { + return null; + } + return { + ...state, process: { phase: SyncingPhase.SUCCESS } diff --git a/packages/neos-ui-sagas/src/Publish/index.ts b/packages/neos-ui-sagas/src/Publish/index.ts index 63220b1339..df2c098ee4 100644 --- a/packages/neos-ui-sagas/src/Publish/index.ts +++ b/packages/neos-ui-sagas/src/Publish/index.ts @@ -10,15 +10,17 @@ import {put, call, select, takeEvery, take, race, all} from 'redux-saga/effects'; import {AnyError} from '@neos-project/neos-ui-error'; -import {NodeContextPath, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {DimensionCombination, NodeContextPath, WorkspaceName} from '@neos-project/neos-ts-interfaces'; import {actionTypes, actions, selectors} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; import {FeedbackEnvelope} from '@neos-project/neos-ui-redux-store/src/ServerFeedback'; import {PublishingMode, PublishingScope} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; +import {Conflict} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import backend, {Routes} from '@neos-project/neos-ui-backend-connector'; import {makeReloadNodes} from '../CR/NodeOperations/reloadNodes'; import {updateWorkspaceInfo} from '../CR/Workspaces'; +import {makeResolveConflicts, makeSyncPersonalWorkspace} from '../Sync'; const handleWindowBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); @@ -32,6 +34,7 @@ type PublishingResponse = numberOfAffectedChanges: number; } } + | { conflicts: Conflict[] } | { error: AnyError }; export function * watchPublishing({routes}: {routes: Routes}) { @@ -67,6 +70,8 @@ export function * watchPublishing({routes}: {routes: Routes}) { }; const reloadAfterPublishing = makeReloadAfterPublishing({routes}); + const syncPersonalWorkspace = makeSyncPersonalWorkspace({routes}); + const resolveConflicts = makeResolveConflicts({syncPersonalWorkspace}); yield takeEvery(actionTypes.CR.Publishing.STARTED, function * publishingWorkflow(action: ReturnType) { const confirmed = yield * waitForConfirmation(); @@ -83,25 +88,58 @@ export function * watchPublishing({routes}: {routes: Routes}) { const {ancestorIdSelector} = SELECTORS_BY_SCOPE[scope]; const workspaceName: WorkspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); + const dimensionSpacePoint: null|DimensionCombination = yield select(selectors.CR.ContentDimensions.active); const ancestorId: NodeContextPath = ancestorIdSelector ? yield select(ancestorIdSelector) : null; + function * attemptToPublishOrDiscard(): Generator { + const result: PublishingResponse = scope === PublishingScope.ALL + ? yield call(endpoint as any, workspaceName) + : yield call(endpoint!, ancestorId, workspaceName, dimensionSpacePoint); + + if ('success' in result) { + yield put(actions.CR.Publishing.succeed(result.success.numberOfAffectedChanges)); + yield * reloadAfterPublishing(); + } else if ('conflicts' in result) { + yield put(actions.CR.Publishing.conflicts()); + const conflictsWereResolved: boolean = + yield * resolveConflicts(result.conflicts); + + if (conflictsWereResolved) { + yield put(actions.CR.Publishing.resolveConflicts()); + + // + // It may happen that after conflicts are resolved, the + // document we're trying to publish no longer exists. + // + // We need to finish the publishing operation in this + // case, otherwise it'll lead to an error. + // + const publishingShouldContinue = scope === PublishingScope.DOCUMENT + ? Boolean(yield select(selectors.CR.Nodes.byContextPathSelector(ancestorId))) + : true; + + if (publishingShouldContinue) { + yield * attemptToPublishOrDiscard(); + } else { + yield put(actions.CR.Publishing.succeed(0)); + } + } else { + yield put(actions.CR.Publishing.cancel()); + yield call(updateWorkspaceInfo); + } + } else if ('error' in result) { + yield put(actions.CR.Publishing.fail(result.error)); + } else { + yield put(actions.CR.Publishing.fail(null)); + } + } + do { try { window.addEventListener('beforeunload', handleWindowBeforeUnload); - const result: PublishingResponse = scope === PublishingScope.ALL - ? yield call(endpoint as any, workspaceName) - : yield call(endpoint, ancestorId, workspaceName); - - if ('success' in result) { - yield put(actions.CR.Publishing.succeed(result.success.numberOfAffectedChanges)); - yield * reloadAfterPublishing(); - } else if ('error' in result) { - yield put(actions.CR.Publishing.fail(result.error)); - } else { - yield put(actions.CR.Publishing.fail(null)); - } + yield * attemptToPublishOrDiscard(); } catch (error) { yield put(actions.CR.Publishing.fail(error as AnyError)); } finally { @@ -126,6 +164,13 @@ function * waitForConfirmation() { } function * waitForRetry() { + const isOngoing: boolean = yield select( + (state: GlobalState) => state.cr.publishing !== null + ); + if (!isOngoing) { + return false; + } + const {retried}: { acknowledged: ReturnType; retried: ReturnType; diff --git a/packages/neos-ui-sagas/src/Sync/index.ts b/packages/neos-ui-sagas/src/Sync/index.ts index c9ad7ea86f..a6f2fca4ab 100644 --- a/packages/neos-ui-sagas/src/Sync/index.ts +++ b/packages/neos-ui-sagas/src/Sync/index.ts @@ -57,7 +57,7 @@ function * waitForConfirmation() { return Boolean(confirmed); } -const makeSyncPersonalWorkspace = (deps: { +export const makeSyncPersonalWorkspace = (deps: { routes: Routes }) => { const refreshAfterSyncing = makeRefreshAfterSyncing(deps); @@ -89,26 +89,43 @@ const makeSyncPersonalWorkspace = (deps: { return syncPersonalWorkspace; } -const makeResolveConflicts = (deps: { +export const makeResolveConflicts = (deps: { syncPersonalWorkspace: ReturnType }) => { const discardAll = makeDiscardAll(deps); function * resolveConflicts(conflicts: Conflict[]): any { - yield put(actions.CR.Syncing.resolve(conflicts)); + while (true) { + yield put(actions.CR.Syncing.resolve(conflicts)); + + const {started}: { + cancelled: null | ReturnType; + started: null | ReturnType; + } = yield race({ + cancelled: take(actionTypes.CR.Syncing.CANCELLED), + started: take(actionTypes.CR.Syncing.RESOLUTION_STARTED) + }); + + if (started) { + const {payload: {strategy}} = started; - yield takeEvery>( - actionTypes.CR.Syncing.RESOLUTION_STARTED, - function * resolve({payload: {strategy}}) { if (strategy === ResolutionStrategy.FORCE) { if (yield * waitForResolutionConfirmation()) { yield * deps.syncPersonalWorkspace(true); + return true; } - } else if (strategy === ResolutionStrategy.DISCARD_ALL) { + + continue; + } + + if (strategy === ResolutionStrategy.DISCARD_ALL) { yield * discardAll(); + return true; } } - ); + + return false; + } } return resolveConflicts; diff --git a/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx b/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx index b8844bfd4d..7997c5916f 100644 --- a/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx @@ -96,6 +96,9 @@ const PublishingDialog: React.FC = (props) => { /> ); + case PublishingPhase.CONFLICTS: + return null; + case PublishingPhase.ERROR: case PublishingPhase.SUCCESS: return ( diff --git a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx index d66e971f2d..9f1a3a5338 100644 --- a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx @@ -277,7 +277,7 @@ export const ResultDialog: React.FC<{ = (props) => { return (
    - {props.conflicts.map((conflict, i) => ( + {props.conflicts.map((conflict) => (