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/ConflictsBuilder.php b/Classes/Application/SyncWorkspace/ConflictsBuilder.php deleted file mode 100644 index 138ed415fc..0000000000 --- a/Classes/Application/SyncWorkspace/ConflictsBuilder.php +++ /dev/null @@ -1,292 +0,0 @@ - - */ - private array $itemsByAffectedNodeAggregateId = []; - - public function __construct( - private ContentRepository $contentRepository, - private NodeLabelGeneratorInterface $nodeLabelGenerator, - private WorkspaceName $workspaceName, - private ?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->items[] = $conflict; - - if ($nodeAggregateId) { - $this->itemsByAffectedNodeAggregateId[$nodeAggregateId->value] = $conflict; - } - } - - public function build(): Conflicts - { - return new Conflicts(...$this->items); - } - - private function createConflictFromCommandThatFailedDuringRebase( - CommandThatFailedDuringRebase $commandThatFailedDuringRebase - ): Conflict { - $nodeAggregateId = $this->extractNodeAggregateIdFromCommand( - $commandThatFailedDuringRebase->command - ); - $subgraph = $this->acquireSubgraphFromCommand( - $commandThatFailedDuringRebase->command, - $nodeAggregateId - ); - $affectedSite = $nodeAggregateId - ? $subgraph?->findClosestNode( - $nodeAggregateId, - FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_SITE) - ) - : null; - $affectedDocument = $nodeAggregateId - ? $subgraph?->findClosestNode( - $nodeAggregateId, - FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_DOCUMENT) - ) - : null; - $affectedNode = $nodeAggregateId - ? $subgraph?->findNodeById($nodeAggregateId) - : null; - - return new Conflict( - affectedSite: $affectedSite - ? $this->createIconLabelForNode($affectedSite) - : null, - affectedDocument: $affectedDocument - ? $this->createIconLabelForNode($affectedDocument) - : null, - affectedNode: $affectedNode - ? $this->createIconLabelForNode($affectedNode) - : null, - typeOfChange: $this->createTypeOfChangeFromCommand( - $commandThatFailedDuringRebase->command - ), - reasonForConflict: $this->createReasonForConflictFromException( - $commandThatFailedDuringRebase->exception - ) - ); - } - - private function extractNodeAggregateIdFromCommand(CommandInterface $command): ?NodeAggregateId - { - return match (true) { - $command instanceof MoveNodeAggregate, - $command instanceof SetNodeProperties, - $command instanceof SetSerializedNodeProperties, - $command instanceof CreateNodeAggregateWithNode, - $command instanceof CreateNodeAggregateWithNodeAndSerializedProperties, - $command instanceof TagSubtree, - $command instanceof DisableNodeAggregate, - $command instanceof UntagSubtree, - $command instanceof EnableNodeAggregate, - $command instanceof RemoveNodeAggregate, - $command instanceof ChangeNodeAggregateType, - $command instanceof CreateNodeVariant => - $command->nodeAggregateId, - $command instanceof SetNodeReferences, - $command instanceof SetSerializedNodeReferences => - $command->sourceNodeAggregateId, - default => null - }; - } - - private function acquireSubgraphFromCommand( - CommandInterface $command, - ?NodeAggregateId $nodeAggregateIdForDimensionFallback - ): ?ContentSubgraphInterface { - try { - $contentGraph = $this->contentRepository->getContentGraph($this->workspaceName); - } catch (WorkspaceDoesNotExist) { - return null; - } - - $dimensionSpacePoint = match (true) { - $command instanceof MoveNodeAggregate => - $command->dimensionSpacePoint, - $command instanceof SetNodeProperties, - $command instanceof SetSerializedNodeProperties, - $command instanceof CreateNodeAggregateWithNode, - $command instanceof CreateNodeAggregateWithNodeAndSerializedProperties => - $command->originDimensionSpacePoint->toDimensionSpacePoint(), - $command instanceof SetNodeReferences, - $command instanceof SetSerializedNodeReferences => - $command->sourceOriginDimensionSpacePoint->toDimensionSpacePoint(), - $command instanceof TagSubtree, - $command instanceof DisableNodeAggregate, - $command instanceof UntagSubtree, - $command instanceof EnableNodeAggregate, - $command instanceof RemoveNodeAggregate => - $command->coveredDimensionSpacePoint, - $command instanceof ChangeNodeAggregateType => - null, - $command instanceof CreateNodeVariant => - $command->targetOrigin->toDimensionSpacePoint(), - default => null - }; - - if ($dimensionSpacePoint === null) { - if ($nodeAggregateIdForDimensionFallback === null) { - return null; - } - - $nodeAggregate = $contentGraph - ->findNodeAggregateById( - $nodeAggregateIdForDimensionFallback - ); - - if ($nodeAggregate) { - $dimensionSpacePoint = $this->extractValidDimensionSpacePointFromNodeAggregate( - $nodeAggregate - ); - } - } - - if ($dimensionSpacePoint === null) { - return null; - } - - return $contentGraph->getSubgraph( - $dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); - } - - private function extractValidDimensionSpacePointFromNodeAggregate( - NodeAggregate $nodeAggregate - ): ?DimensionSpacePoint { - $result = null; - - foreach ($nodeAggregate->coveredDimensionSpacePoints as $coveredDimensionSpacePoint) { - if ($this->preferredDimensionSpacePoint?->equals($coveredDimensionSpacePoint)) { - return $coveredDimensionSpacePoint; - } - $result ??= $coveredDimensionSpacePoint; - } - - return $result; - } - - private function createIconLabelForNode(Node $node): IconLabel - { - $nodeType = $this->nodeTypeManager->getNodeType($node->nodeTypeName); - - return new IconLabel( - icon: $nodeType?->getConfiguration('ui.icon') ?? 'questionmark', - label: $this->nodeLabelGenerator->getLabel($node) - ); - } - - private function createTypeOfChangeFromCommand( - CommandInterface $command - ): ?TypeOfChange { - return match (true) { - $command instanceof CreateNodeAggregateWithNode, - $command instanceof CreateNodeAggregateWithNodeAndSerializedProperties, - $command instanceof CreateNodeVariant => - TypeOfChange::NODE_HAS_BEEN_CREATED, - $command instanceof SetNodeProperties, - $command instanceof SetSerializedNodeProperties, - $command instanceof SetNodeReferences, - $command instanceof SetSerializedNodeReferences, - $command instanceof TagSubtree, - $command instanceof DisableNodeAggregate, - $command instanceof UntagSubtree, - $command instanceof EnableNodeAggregate, - $command instanceof ChangeNodeAggregateType => - TypeOfChange::NODE_HAS_BEEN_CHANGED, - $command instanceof MoveNodeAggregate => - TypeOfChange::NODE_HAS_BEEN_MOVED, - $command instanceof RemoveNodeAggregate => - TypeOfChange::NODE_HAS_BEEN_DELETED, - default => null - }; - } - - private function createReasonForConflictFromException( - \Throwable $exception - ): ?ReasonForConflict { - return match ($exception::class) { - NodeAggregateCurrentlyDoesNotExist::class => - ReasonForConflict::NODE_HAS_BEEN_DELETED, - default => null - }; - } -} 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/ContentRepository/Service/WorkspaceService.php b/Classes/ContentRepository/Service/WorkspaceService.php index 43cb89155f..d1b9834a3c 100644 --- a/Classes/ContentRepository/Service/WorkspaceService.php +++ b/Classes/ContentRepository/Service/WorkspaceService.php @@ -1,4 +1,5 @@ contentRepositoryRegistry->get($contentRepositoryId); + $contentGraph = $contentRepository->getContentGraph($workspaceName); $pendingChanges = $this->workspacePublishingService->pendingWorkspaceChanges($contentRepositoryId, $workspaceName); /** @var array{contextPath:string,documentContextPath:string,typeOfChange:int}[] $unpublishedNodes */ $unpublishedNodes = []; foreach ($pendingChanges as $change) { - if ($change->removalAttachmentPoint) { + if ($change->removalAttachmentPoint && $change->originDimensionSpacePoint !== null) { $nodeAddress = NodeAddress::create( $contentRepositoryId, $workspaceName, @@ -79,20 +81,31 @@ public function getPublishableNodeInfo(WorkspaceName $workspaceName, ContentRepo 'typeOfChange' => $this->getTypeOfChange($change) ]; } else { - $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( - $change->originDimensionSpacePoint->toDimensionSpacePoint(), - VisibilityConstraints::withoutRestrictions() - ); - $node = $subgraph->findNodeById($change->nodeAggregateId); - - if ($node instanceof Node) { - $documentNode = $subgraph->findClosestNode($node->aggregateId, FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_DOCUMENT)); - if ($documentNode instanceof Node) { - $unpublishedNodes[] = [ - 'contextPath' => NodeAddress::fromNode($node)->toJson(), - 'documentContextPath' => NodeAddress::fromNode($documentNode)->toJson(), - 'typeOfChange' => $this->getTypeOfChange($change) - ]; + if ($change->originDimensionSpacePoint !== null) { + $originDimensionSpacePoints = [$change->originDimensionSpacePoint]; + } else { + // If originDimensionSpacePoint is null, we have a change to the nodeAggregate. All nodes in the + // occupied dimensionspacepoints shall be marked as changed. + $originDimensionSpacePoints = $contentGraph + ->findNodeAggregateById($change->nodeAggregateId) + ?->occupiedDimensionSpacePoints ?: []; + } + + foreach ($originDimensionSpacePoints as $originDimensionSpacePoint) { + $subgraph = $contentGraph->getSubgraph( + $originDimensionSpacePoint->toDimensionSpacePoint(), + VisibilityConstraints::withoutRestrictions() + ); + $node = $subgraph->findNodeById($change->nodeAggregateId); + if ($node instanceof Node) { + $documentNode = $subgraph->findClosestNode($node->aggregateId, FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_DOCUMENT)); + if ($documentNode instanceof Node) { + $unpublishedNodes[] = [ + 'contextPath' => NodeAddress::fromNode($node)->toJson(), + 'documentContextPath' => NodeAddress::fromNode($documentNode)->toJson(), + 'typeOfChange' => $this->getTypeOfChange($change) + ]; + } } } } 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/Domain/Model/Changes/Property.php b/Classes/Domain/Model/Changes/Property.php index dbff01dffc..06cd3e5814 100644 --- a/Classes/Domain/Model/Changes/Property.php +++ b/Classes/Domain/Model/Changes/Property.php @@ -19,6 +19,7 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesForName; use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Command\ChangeNodeAggregateType; use Neos\ContentRepository\Core\Feature\NodeTypeChange\Dto\NodeAggregateTypeChangeChildConstraintConflictResolutionStrategy; @@ -239,8 +240,12 @@ private function handleNodeReferenceChange(Node $subject, string $propertyName): $subject->workspaceName, $subject->aggregateId, $subject->originDimensionSpacePoint, - ReferenceName::fromString($propertyName), - NodeReferencesToWrite::fromNodeAggregateIds(NodeAggregateIds::fromArray($destinationNodeAggregateIds)) + NodeReferencesToWrite::create( + NodeReferencesForName::fromTargets( + ReferenceName::fromString($propertyName), + NodeAggregateIds::fromArray($destinationNodeAggregateIds) + ) + ) ) ); } diff --git a/Classes/Domain/NodeCreation/NodeCreationCommands.php b/Classes/Domain/NodeCreation/NodeCreationCommands.php index ad225e5fcb..52115aad6e 100644 --- a/Classes/Domain/NodeCreation/NodeCreationCommands.php +++ b/Classes/Domain/NodeCreation/NodeCreationCommands.php @@ -21,6 +21,7 @@ use Neos\ContentRepository\Core\Feature\NodeModification\Command\SetNodeProperties; use Neos\ContentRepository\Core\Feature\NodeModification\Dto\PropertyValuesToWrite; use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; /** @@ -107,6 +108,14 @@ public function withInitialPropertyValues(PropertyValuesToWrite $newInitialPrope ); } + public function withInitialReferences(NodeReferencesToWrite $newInitialReferences): self + { + return new self( + $this->first->withReferences($newInitialReferences), + ...$this->additionalCommands + ); + } + public function withAdditionalCommands( CreateNodeAggregateWithNode|SetNodeProperties|DisableNodeAggregate|EnableNodeAggregate|SetNodeReferences|CopyNodesRecursively ...$additionalCommands ): self { diff --git a/Classes/Domain/Service/NodePropertyConverterService.php b/Classes/Domain/Service/NodePropertyConverterService.php index 2688c2dbed..b8c7231084 100644 --- a/Classes/Domain/Service/NodePropertyConverterService.php +++ b/Classes/Domain/Service/NodePropertyConverterService.php @@ -135,7 +135,7 @@ private function getReference(Node $node, string $referenceName): array|string|n private function getProperty(Node $node, string $propertyName): mixed { if ($propertyName === '_hidden') { - return $node->tags->contain(SubtreeTag::fromString('disabled')); + return $node->tags->withoutInherited()->contain(SubtreeTag::fromString('disabled')); } $propertyValue = $node->getProperty($propertyName); diff --git a/Classes/Infrastructure/ContentRepository/ConflictsFactory.php b/Classes/Infrastructure/ContentRepository/ConflictsFactory.php new file mode 100644 index 0000000000..a9268c8e8d --- /dev/null +++ b/Classes/Infrastructure/ContentRepository/ConflictsFactory.php @@ -0,0 +1,263 @@ +nodeTypeManager = $contentRepository->getNodeTypeManager(); + + $this->workspace = $contentRepository->findWorkspaceByName($workspaceName); + } + + public function fromWorkspaceRebaseFailed( + WorkspaceRebaseFailed $workspaceRebaseFailed + ): Conflicts { + /** @var array */ + $conflictsByKey = []; + + foreach ($workspaceRebaseFailed->conflictingEvents as $conflictingEvent) { + $conflict = $this->createConflict($conflictingEvent); + if (!array_key_exists($conflict->key, $conflictsByKey)) { + // deduplicate if the conflict affects the same node + $conflictsByKey[$conflict->key] = $conflict; + } + } + + return new Conflicts(...$conflictsByKey); + } + + private function createConflict( + ConflictingEvent $conflictingEvent + ): Conflict { + $nodeAggregateId = $conflictingEvent->getAffectedNodeAggregateId(); + $subgraph = $this->acquireSubgraph( + $conflictingEvent->getEvent(), + $nodeAggregateId + ); + $affectedSite = $nodeAggregateId + ? $subgraph?->findClosestNode( + $nodeAggregateId, + FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_SITE) + ) + : null; + $affectedDocument = $nodeAggregateId + ? $subgraph?->findClosestNode( + $nodeAggregateId, + FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_DOCUMENT) + ) + : null; + $affectedNode = $nodeAggregateId + ? $subgraph?->findNodeById($nodeAggregateId) + : null; + + return new Conflict( + key: $affectedNode + ? $affectedNode->aggregateId->value + : Algorithms::generateUUID(), + affectedSite: $affectedSite + ? $this->createIconLabelForNode($affectedSite) + : null, + affectedDocument: $affectedDocument + ? $this->createIconLabelForNode($affectedDocument) + : null, + affectedNode: $affectedNode + ? $this->createIconLabelForNode($affectedNode) + : null, + typeOfChange: $this->createTypeOfChange( + $conflictingEvent->getEvent() + ), + reasonForConflict: $this->createReasonForConflictFromException( + $conflictingEvent->getException() + ) + ); + } + + private function acquireSubgraph( + EventInterface $event, + ?NodeAggregateId $nodeAggregateIdForDimensionFallback + ): ?ContentSubgraphInterface { + if ($this->workspace === null) { + return null; + } + + $dimensionSpacePoint = match ($event::class) { + NodeAggregateWasMoved::class => + // TODO it seems the event lost some information here from the intention + self::firstDimensionSpacePoint($event->succeedingSiblingsForCoverage->toDimensionSpacePointSet()), + NodePropertiesWereSet::class, + NodeAggregateWithNodeWasCreated::class => + $event->originDimensionSpacePoint->toDimensionSpacePoint(), + NodeReferencesWereSet::class => + // TODO it seems the event lost some information here from the intention + self::firstDimensionSpacePoint($event->affectedSourceOriginDimensionSpacePoints->toDimensionSpacePointSet()), + SubtreeWasTagged::class, + SubtreeWasUntagged::class => + // TODO it seems the event lost some information here from the intention + self::firstDimensionSpacePoint($event->affectedDimensionSpacePoints), + NodeAggregateWasRemoved::class => + // TODO it seems the event lost some information here from the intention + self::firstDimensionSpacePoint($event->affectedCoveredDimensionSpacePoints), + NodeAggregateTypeWasChanged::class => + null, + NodePeerVariantWasCreated::class => + $event->peerOrigin->toDimensionSpacePoint(), + NodeGeneralizationVariantWasCreated::class => + $event->generalizationOrigin->toDimensionSpacePoint(), + default => null + }; + + if ($dimensionSpacePoint === null) { + if ($nodeAggregateIdForDimensionFallback === null) { + return null; + } + + $nodeAggregate = $this->contentRepository + ->getContentGraph($this->workspace->workspaceName) + ->findNodeAggregateById($nodeAggregateIdForDimensionFallback); + + if ($nodeAggregate) { + $dimensionSpacePoint = $this->extractValidDimensionSpacePointFromNodeAggregate( + $nodeAggregate + ); + } + } + + if ($dimensionSpacePoint === null) { + return null; + } + + return $this->contentRepository + ->getContentGraph($this->workspace->workspaceName) + ->getSubgraph( + $dimensionSpacePoint, + VisibilityConstraints::withoutRestrictions() + ); + } + + private function extractValidDimensionSpacePointFromNodeAggregate( + NodeAggregate $nodeAggregate + ): ?DimensionSpacePoint { + $result = null; + + foreach ($nodeAggregate->coveredDimensionSpacePoints as $coveredDimensionSpacePoint) { + if ($this->preferredDimensionSpacePoint?->equals($coveredDimensionSpacePoint)) { + return $coveredDimensionSpacePoint; + } + $result ??= $coveredDimensionSpacePoint; + } + + return $result; + } + + private function createIconLabelForNode(Node $node): IconLabel + { + $nodeType = $this->nodeTypeManager->getNodeType($node->nodeTypeName); + + return new IconLabel( + icon: $nodeType?->getConfiguration('ui.icon') ?? 'questionmark', + label: $this->nodeLabelGenerator->getLabel($node), + ); + } + + private function createTypeOfChange( + EventInterface $event + ): ?TypeOfChange { + return match ($event::class) { + NodeAggregateWithNodeWasCreated::class, + NodePeerVariantWasCreated::class, + NodeGeneralizationVariantWasCreated::class => + TypeOfChange::NODE_HAS_BEEN_CREATED, + NodePropertiesWereSet::class, + NodeReferencesWereSet::class, + SubtreeWasTagged::class, + SubtreeWasUntagged::class, + NodeAggregateTypeWasChanged::class => + TypeOfChange::NODE_HAS_BEEN_CHANGED, + NodeAggregateWasMoved::class => + TypeOfChange::NODE_HAS_BEEN_MOVED, + NodeAggregateWasRemoved::class => + TypeOfChange::NODE_HAS_BEEN_DELETED, + default => null + }; + } + + private function createReasonForConflictFromException( + \Throwable $exception + ): ?ReasonForConflict { + return match ($exception::class) { + NodeAggregateCurrentlyDoesNotExist::class => + ReasonForConflict::NODE_HAS_BEEN_DELETED, + default => null + }; + } + + private static function firstDimensionSpacePoint(DimensionSpacePointSet $dimensionSpacePointSet): ?DimensionSpacePoint + { + foreach ($dimensionSpacePointSet->points as $point) { + return $point; + } + return null; + } +} diff --git a/Classes/Infrastructure/ContentRepository/CreationDialog/PromotedElementsCreationHandlerFactory.php b/Classes/Infrastructure/ContentRepository/CreationDialog/PromotedElementsCreationHandlerFactory.php index 88066dab9e..eea2b8355e 100644 --- a/Classes/Infrastructure/ContentRepository/CreationDialog/PromotedElementsCreationHandlerFactory.php +++ b/Classes/Infrastructure/ContentRepository/CreationDialog/PromotedElementsCreationHandlerFactory.php @@ -5,8 +5,7 @@ namespace Neos\Neos\Ui\Infrastructure\ContentRepository\CreationDialog; use Neos\ContentRepository\Core\ContentRepository; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Command\SetNodeReferences; -use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesToWrite; +use Neos\ContentRepository\Core\Feature\NodeReferencing\Dto\NodeReferencesForName; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Node\ReferenceName; @@ -39,7 +38,7 @@ public function handle(NodeCreationCommands $commands, NodeCreationElements $ele return $commands; } $propertyValues = $commands->first->initialPropertyValues; - $setReferencesCommands = []; + $initialReferences = $commands->first->references; foreach ($elements as $elementName => $elementValue) { // handle properties if ($nodeType->hasProperty($elementName)) { @@ -56,16 +55,12 @@ public function handle(NodeCreationCommands $commands, NodeCreationElements $ele if ($nodeType->hasReference($elementName)) { assert($elementValue instanceof NodeAggregateIds); $referenceConfiguration = $nodeType->getReferences()[$elementName]; - if ( - ($referenceConfiguration['ui']['showInCreationDialog'] ?? false) === true - ) { - // a promoted element - $setReferencesCommands[] = SetNodeReferences::create( - $commands->first->workspaceName, - $commands->first->nodeAggregateId, - $commands->first->originDimensionSpacePoint, - ReferenceName::fromString($elementName), - NodeReferencesToWrite::fromNodeAggregateIds($elementValue) + if (($referenceConfiguration['ui']['showInCreationDialog'] ?? false) === true) { + $initialReferences = $initialReferences->withReference( + NodeReferencesForName::fromTargets( + ReferenceName::fromString($elementName), + $elementValue + ) ); } } @@ -73,7 +68,7 @@ public function handle(NodeCreationCommands $commands, NodeCreationElements $ele return $commands ->withInitialPropertyValues($propertyValues) - ->withAdditionalCommands(...$setReferencesCommands); + ->withInitialReferences($initialReferences); } }; } 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/docker-compose.neos-dev-instance.yaml b/Tests/IntegrationTests/docker-compose.neos-dev-instance.yaml index b0dd656f7f..fa822f2292 100644 --- a/Tests/IntegrationTests/docker-compose.neos-dev-instance.yaml +++ b/Tests/IntegrationTests/docker-compose.neos-dev-instance.yaml @@ -14,12 +14,15 @@ services: # Enable GD PHP_EXTENSION_GD: 1 COMPOSER_CACHE_DIR: /home/circleci/.composer/cache + DB_HOST: db db: image: mariadb:10.11 environment: MYSQL_DATABASE: neos MYSQL_ROOT_PASSWORD: not_a_real_password + ports: + - 13309:3306 command: ['mysqld', '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci'] volumes: composer_cache: diff --git a/Tests/IntegrationTests/pageModel.js b/Tests/IntegrationTests/pageModel.js index 1a3c31e6a5..c75de226d5 100644 --- a/Tests/IntegrationTests/pageModel.js +++ b/Tests/IntegrationTests/pageModel.js @@ -59,7 +59,7 @@ export class PublishDropDown { static publishDropdownDiscardAll = ReactSelector('PublishDropDown ContextDropDownContents').find('button').withText('Discard all'); - static publishDropdownPublishAll = ReactSelector('PublishDropDown ShallowDropDownContents').find('button').withText('Publish all'); + static publishDropdownPublishAll = ReactSelector('PublishDropDown ContextDropDownContents').find('button').withText('Publish all'); static async discardAll() { const $discardAllBtn = Selector(this.publishDropdownDiscardAll); @@ -107,6 +107,7 @@ export class PublishDropDown { timeout: 30000 }); await t.click($acknowledgeBtn); + await t.wait(2000); } } diff --git a/Tests/IntegrationTests/start-neos-dev-instance.sh b/Tests/IntegrationTests/start-neos-dev-instance.sh index c018e135a8..0733f3f266 100644 --- a/Tests/IntegrationTests/start-neos-dev-instance.sh +++ b/Tests/IntegrationTests/start-neos-dev-instance.sh @@ -41,6 +41,7 @@ dc exec -T php bash <<-'BASH' ./flow flow:cache:warmup ./flow doctrine:migrate ./flow user:create --username=admin --password=admin --first-name=John --last-name=Doe --roles=Administrator || true + ./flow user:create --username=editor --password=editor --first-name=Some --last-name=FooBarEditor --roles=Editor || true BASH echo "" @@ -68,7 +69,11 @@ dc exec -T php bash <<-BASH if ./flow site:list | grep -q 'Node name'; then ./flow site:prune '*' fi - ./flow site:import --package-key=Neos.TestSite + ./flow cr:setup + ./flow cr:setup --content-repository onedimension + ./flow cr:setup --content-repository twodimensions + ./flow cr:import --content-repository onedimension Packages/Sites/Neos.Test.OneDimension/Resources/Private/Content + ./flow site:create neos-test-onedimension Neos.Test.OneDimension Neos.TestNodeTypes:Document.HomePage ./flow resource:publish BASH @@ -85,5 +90,5 @@ dc exec -T php bash <<-'BASH' # enable changes of the Neos.TestNodeTypes outside of the container to appear in the container via sym link to mounted volume rm -rf /usr/src/app/TestDistribution/Packages/Application/Neos.TestNodeTypes - ln -s /usr/src/neos-ui/Tests/IntegrationTests/SharedNodeTypesPackage/ /usr/src/app/TestDistribution/Packages/Application/Neos.TestNodeTypes + ln -s /usr/src/neos-ui/Tests/IntegrationTests/TestDistribution/DistributionPackages/Neos.TestNodeTypes /usr/src/app/TestDistribution/Packages/Application/Neos.TestNodeTypes BASH 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) => ( diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/DimensionSwitcher/DimensionSelector.js b/packages/neos-ui/src/Containers/PrimaryToolbar/DimensionSwitcher/DimensionSelector.js index f2be3beb4a..6a2360ca40 100644 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/DimensionSwitcher/DimensionSelector.js +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/DimensionSwitcher/DimensionSelector.js @@ -16,7 +16,7 @@ const searchOptions = (searchTerm, processedSelectBoxOptions) => })) export default class DimensionSelector extends PureComponent { static propTypes = { - icon: PropTypes.string.isRequired, + icon: PropTypes.string, dimensionLabel: PropTypes.string.isRequired, presets: PropTypes.object.isRequired, activePreset: PropTypes.string.isRequired,