diff --git a/Classes/Application/ChangeTargetWorkspace.php b/Classes/Application/ChangeTargetWorkspace.php new file mode 100644 index 0000000000..18f34e5301 --- /dev/null +++ b/Classes/Application/ChangeTargetWorkspace.php @@ -0,0 +1,37 @@ + $values + */ + public static function fromArray(array $values): self + { + return new self( + ContentRepositoryId::fromString($values['contentRepositoryId']), + WorkspaceName::fromString($values['workspaceName']), + NodeAggregateId::fromString($values['documentId']), + ); + } +} diff --git a/Classes/Application/DiscardChangesInSite.php b/Classes/Application/DiscardChangesInSite.php new file mode 100644 index 0000000000..749bd61e6e --- /dev/null +++ b/Classes/Application/DiscardChangesInSite.php @@ -0,0 +1,48 @@ + $values + */ + public static function fromArray(array $values): self + { + return new self( + ContentRepositoryId::fromString($values['contentRepositoryId']), + WorkspaceName::fromString($values['workspaceName']), + NodeAggregateId::fromString($values['siteId']), + ); + } +} diff --git a/Classes/Application/PublishChangesInDocument.php b/Classes/Application/PublishChangesInDocument.php new file mode 100644 index 0000000000..85069b6606 --- /dev/null +++ b/Classes/Application/PublishChangesInDocument.php @@ -0,0 +1,48 @@ + $values + */ + public static function fromArray(array $values): self + { + return new self( + ContentRepositoryId::fromString($values['contentRepositoryId']), + WorkspaceName::fromString($values['workspaceName']), + NodeAggregateId::fromString($values['documentId']), + ); + } +} diff --git a/Classes/Application/PublishChangesInSite.php b/Classes/Application/PublishChangesInSite.php new file mode 100644 index 0000000000..f645520cf4 --- /dev/null +++ b/Classes/Application/PublishChangesInSite.php @@ -0,0 +1,48 @@ + $values + */ + public static function fromArray(array $values): self + { + return new self( + ContentRepositoryId::fromString($values['contentRepositoryId']), + WorkspaceName::fromString($values['workspaceName']), + NodeAggregateId::fromString($values['siteId']), + ); + } +} diff --git a/Classes/Application/SyncWorkspace.php b/Classes/Application/SyncWorkspace.php new file mode 100644 index 0000000000..bed891b4ad --- /dev/null +++ b/Classes/Application/SyncWorkspace.php @@ -0,0 +1,36 @@ +findNodeById($nodeAddress->nodeAggregateId); } + + public function deserializeNodeAddress(string $serializedNodeAddress, ContentRepositoryId $contentRepositoryId): NodeAddress + { + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + return NodeAddressFactory::create($contentRepository)->createFromUriString($serializedNodeAddress); + } } diff --git a/Classes/ContentRepository/Service/WorkspaceService.php b/Classes/ContentRepository/Service/WorkspaceService.php index fc4cffe266..54f1903c99 100644 --- a/Classes/ContentRepository/Service/WorkspaceService.php +++ b/Classes/ContentRepository/Service/WorkspaceService.php @@ -13,7 +13,6 @@ use Neos\ContentRepository\Core\ContentRepository; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\Neos\Domain\Service\NodeTypeNameFactory; @@ -24,9 +23,9 @@ use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\Flow\Annotations as Flow; use Neos\Neos\Domain\Service\UserService as DomainUserService; +use Neos\Neos\PendingChangesProjection\Change; use Neos\Neos\PendingChangesProjection\ChangeFinder; use Neos\Neos\Service\UserService; -use Neos\Neos\Ui\Domain\Model\Feedback\Operations\RemoveNode; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; /** @@ -35,6 +34,11 @@ */ class WorkspaceService { + private const NODE_HAS_BEEN_CREATED = 0b0001; + private const NODE_HAS_BEEN_CHANGED = 0b0010; + private const NODE_HAS_BEEN_MOVED = 0b0100; + private const NODE_HAS_BEEN_DELETED = 0b1000; + use NodeTypeWithFallbackProvider; #[Flow\Inject] @@ -55,7 +59,7 @@ class WorkspaceService /** * Get all publishable node context paths for a workspace * - * @return array> + * @return array> */ public function getPublishableNodeInfo(WorkspaceName $workspaceName, ContentRepositoryId $contentRepositoryId): array { @@ -88,7 +92,8 @@ public function getPublishableNodeInfo(WorkspaceName $workspaceName, ContentRepo $unpublishedNodes[] = [ 'contextPath' => $nodeAddress->serializeForUri(), - 'documentContextPath' => $documentNodeAddress->serializeForUri() + 'documentContextPath' => $documentNodeAddress->serializeForUri(), + 'typeOfChange' => $this->getTypeOfChange($change) ]; } else { $subgraph = $contentRepository->getContentGraph()->getSubgraph( @@ -106,7 +111,8 @@ public function getPublishableNodeInfo(WorkspaceName $workspaceName, ContentRepo $unpublishedNodes[] = [ 'contextPath' => $nodeAddressFactory->createFromNode($node)->serializeForUri(), 'documentContextPath' => $nodeAddressFactory->createFromNode($documentNode) - ->serializeForUri() + ->serializeForUri(), + 'typeOfChange' => $this->getTypeOfChange($change) ]; } } @@ -156,48 +162,24 @@ public function getAllowedTargetWorkspaces(ContentRepository $contentRepository) return $workspacesArray; } - /** @return list */ - public function predictRemoveNodeFeedbackFromDiscardIndividualNodesFromWorkspaceCommand( - DiscardIndividualNodesFromWorkspace $command, - ContentRepository $contentRepository - ): array { - $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($command->workspaceName); - if (is_null($workspace)) { - return []; + private function getTypeOfChange(Change $change): int + { + $result = 0; + + if ($change->created) { + $result = $result | self::NODE_HAS_BEEN_CREATED; } - $changeFinder = $contentRepository->projectionState(ChangeFinder::class); - $changes = $changeFinder->findByContentStreamId($workspace->currentContentStreamId); + if ($change->changed) { + $result = $result | self::NODE_HAS_BEEN_CHANGED; + } - $handledNodes = []; - $result = []; - foreach ($changes as $change) { - if ($change->created) { - foreach ($command->nodesToDiscard as $nodeToDiscard) { - if (in_array($nodeToDiscard, $handledNodes)) { - continue; - } + if ($change->moved) { + $result = $result | self::NODE_HAS_BEEN_MOVED; + } - if ( - $nodeToDiscard->nodeAggregateId->equals($change->nodeAggregateId) - && $nodeToDiscard->dimensionSpacePoint->equals($change->originDimensionSpacePoint) - ) { - $subgraph = $contentRepository->getContentGraph() - ->getSubgraph( - $workspace->currentContentStreamId, - $nodeToDiscard->dimensionSpacePoint, - VisibilityConstraints::withoutRestrictions() - ); - - $childNode = $subgraph->findNodeById($nodeToDiscard->nodeAggregateId); - $parentNode = $subgraph->findParentNode($nodeToDiscard->nodeAggregateId); - if ($childNode && $parentNode) { - $result[] = new RemoveNode($childNode, $parentNode); - $handledNodes[] = $nodeToDiscard; - } - } - } - } + if ($change->deleted) { + $result = $result | self::NODE_HAS_BEEN_DELETED; } return $result; diff --git a/Classes/Controller/BackendServiceController.php b/Classes/Controller/BackendServiceController.php index f787ae5c81..1d3e644388 100644 --- a/Classes/Controller/BackendServiceController.php +++ b/Classes/Controller/BackendServiceController.php @@ -1,4 +1,5 @@ $command */ - public function publishAllAction(): void + public function publishChangesInSiteAction(array $command): void { + /** @todo send from UI */ $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $command['contentRepositoryId'] = $contentRepositoryId->value; + $command['siteId'] = $this->nodeService->deserializeNodeAddress( + $command['siteId'], + $contentRepositoryId + )->nodeAggregateId->value; + $command = PublishChangesInSite::fromArray($command); - $currentAccount = $this->securityContext->getAccount(); - $workspaceName = WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()); - $this->publishingService->publishWorkspace($contentRepository, $workspaceName); + try { + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + $workspace->publishChangesInSite($command->siteId); - $success = new Success(); - $success->setMessage(sprintf('Published.')); + $success = new Success(); + $success->setMessage(sprintf('Published.')); - $updateWorkspaceInfo = new UpdateWorkspaceInfo($contentRepositoryId, $workspaceName); - $this->feedbackCollection->add($success); - $this->feedbackCollection->add($updateWorkspaceInfo); + $this->feedbackCollection->add($success); + } catch (\Exception $e) { + $error = new Error(); + $error->setMessage($e->getMessage()); + + $this->feedbackCollection->add($error); + } $this->view->assign('value', $this->feedbackCollection); } /** - * Publish nodes + * Publish all changes in the current document * - * @psalm-param list $nodeContextPaths + * @phpstan-param array $command */ - public function publishAction(array $nodeContextPaths, string $targetWorkspaceName): void + public function publishChangesInDocumentAction(array $command): void { + /** @todo send from UI */ + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $command['contentRepositoryId'] = $contentRepositoryId->value; + $command['documentId'] = $this->nodeService->deserializeNodeAddress( + $command['documentId'], + $contentRepositoryId + )->nodeAggregateId->value; + $command = PublishChangesInDocument::fromArray($command); + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $nodeAddressFactory = NodeAddressFactory::create($contentRepository); + $baseWorkspaceName = $contentRepository->getWorkspaceFinder()->findOneByName( + $command->workspaceName + )->baseWorkspaceName; try { - $currentAccount = $this->securityContext->getAccount(); - $workspaceName = WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()); - - $nodeIdentifiersToPublish = []; - foreach ($nodeContextPaths as $contextPath) { - $nodeAddress = $nodeAddressFactory->createFromUriString($contextPath); - $nodeIdentifiersToPublish[] = new NodeIdToPublishOrDiscard( - $nodeAddress->nodeAggregateId, - $nodeAddress->dimensionSpacePoint - ); - } try { - $contentRepository->handle( - PublishIndividualNodesFromWorkspace::create( - $workspaceName, - NodeIdsToPublishOrDiscard::create(...$nodeIdentifiersToPublish) - ) - )->block(); + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + $publishingResult = $workspace->publishChangesInDocument($command->documentId); } catch (NodeAggregateCurrentlyDoesNotExist $e) { throw new NodeAggregateCurrentlyDoesNotExist( 'Node could not be published, probably because of a missing parentNode. Please check that the parentNode has been published.', @@ -240,15 +242,15 @@ public function publishAction(array $nodeContextPaths, string $targetWorkspaceNa } $success = new Success(); - $success->setMessage(sprintf( - 'Published %d change(s) to %s.', - count($nodeContextPaths), - $targetWorkspaceName - )); + $success->setMessage( + sprintf( + 'Published %d change(s) to %s.', + $publishingResult->numberOfPublishedChanges, + $baseWorkspaceName->value + ) + ); - $updateWorkspaceInfo = new UpdateWorkspaceInfo($contentRepositoryId, $workspaceName); $this->feedbackCollection->add($success); - $this->feedbackCollection->add($updateWorkspaceInfo); } catch (\Exception $e) { $error = new Error(); $error->setMessage($e->getMessage()); @@ -260,50 +262,69 @@ public function publishAction(array $nodeContextPaths, string $targetWorkspaceNa } /** - * Discard nodes + * Discard all changes in the given site * - * @psalm-param list $nodeContextPaths + * @phpstan-param array $command */ - public function discardAction(array $nodeContextPaths): void + public function discardChangesInSiteAction(array $command): void { + /** @todo send from UI */ $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $nodeAddressFactory = NodeAddressFactory::create($contentRepository); + $command['contentRepositoryId'] = $contentRepositoryId->value; + $command['siteId'] = $this->nodeService->deserializeNodeAddress( + $command['siteId'], + $contentRepositoryId + )->nodeAggregateId->value; + $command = DiscardChangesInSite::fromArray($command); try { - $currentAccount = $this->securityContext->getAccount(); - $workspaceName = WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()); - - $nodeIdentifiersToDiscard = []; - foreach ($nodeContextPaths as $contextPath) { - $nodeAddress = $nodeAddressFactory->createFromUriString($contextPath); - $nodeIdentifiersToDiscard[] = new NodeIdToPublishOrDiscard( - $nodeAddress->nodeAggregateId, - $nodeAddress->dimensionSpacePoint - ); - } - - $command = DiscardIndividualNodesFromWorkspace::create( - $workspaceName, - NodeIdsToPublishOrDiscard::create(...$nodeIdentifiersToDiscard) + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName ); - $removeNodeFeedback = $this->workspaceService - ->predictRemoveNodeFeedbackFromDiscardIndividualNodesFromWorkspaceCommand( - $command, - $contentRepository - ); + $discardingResult = $workspace->discardChangesInSite($command->siteId); + + $success = new Success(); + $success->setMessage(sprintf('Discarded %d change(s).', $discardingResult->numberOfDiscardedChanges)); + + $this->feedbackCollection->add($success); + } catch (\Exception $e) { + $error = new Error(); + $error->setMessage($e->getMessage()); + + $this->feedbackCollection->add($error); + } + + $this->view->assign('value', $this->feedbackCollection); + } + + /** + * Discard all changes in the given document + * + * @phpstan-param array $command + */ + public function discardChangesInDocumentAction(array $command): void + { + /** @todo send from UI */ + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $command['contentRepositoryId'] = $contentRepositoryId->value; + $command['documentId'] = $this->nodeService->deserializeNodeAddress( + $command['documentId'], + $contentRepositoryId + )->nodeAggregateId->value; + $command = DiscardChangesInDocument::fromArray($command); - $contentRepository->handle($command)->block(); + try { + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + $discardingResult = $workspace->discardChangesInDocument($command->documentId); $success = new Success(); - $success->setMessage(sprintf('Discarded %d node(s).', count($nodeContextPaths))); + $success->setMessage(sprintf('Discarded %d change(s).', $discardingResult->numberOfDiscardedChanges)); - $updateWorkspaceInfo = new UpdateWorkspaceInfo($contentRepositoryId, $workspaceName); $this->feedbackCollection->add($success); - foreach ($removeNodeFeedback as $removeNode) { - $this->feedbackCollection->add($removeNode); - } - $this->feedbackCollection->add($updateWorkspaceInfo); } catch (\Exception $e) { $error = new Error(); $error->setMessage($e->getMessage()); @@ -328,21 +349,33 @@ public function changeBaseWorkspaceAction(string $targetWorkspaceName, string $d $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $nodeAddressFactory = NodeAddressFactory::create($contentRepository); - $nodeAddress = $nodeAddressFactory->createFromUriString($documentNode); $currentAccount = $this->securityContext->getAccount(); $userWorkspaceName = WorkspaceNameBuilder::fromAccountIdentifier( $currentAccount->getAccountIdentifier() ); - $command = ChangeBaseWorkspace::create($userWorkspaceName, WorkspaceName::fromString($targetWorkspaceName)); + /** @todo send from UI */ + $command = new ChangeTargetWorkspace( + $contentRepositoryId, + $userWorkspaceName, + WorkspaceName::fromString($targetWorkspaceName), + $nodeAddressFactory->createFromUriString($documentNode) + ); + try { - $contentRepository->handle($command)->block(); + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + $workspace->changeBaseWorkspace($command->targetWorkspaceName); } catch (WorkspaceIsNotEmptyException $exception) { $error = new Error(); - $error->setMessage('Your personal workspace currently contains unpublished changes.' + $error->setMessage( + 'Your personal workspace currently contains unpublished changes.' . ' In order to switch to a different target workspace you need to either publish' - . ' or discard pending changes first.'); + . ' or discard pending changes first.' + ); $this->feedbackCollection->add($error); $this->view->assign('value', $this->feedbackCollection); @@ -358,12 +391,12 @@ public function changeBaseWorkspaceAction(string $targetWorkspaceName, string $d $subgraph = $contentRepository->getContentGraph() ->getSubgraph( - $command->newContentStreamId, - $nodeAddress->dimensionSpacePoint, + $workspace->getCurrentContentStreamId(), + $command->documentNode->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions() ); - $documentNode = $subgraph->findNodeById($nodeAddress->nodeAggregateId); + $documentNode = $subgraph->findNodeById($command->documentNode->nodeAggregateId); $success = new Success(); $success->setMessage(sprintf('Switched base workspace to %s.', $targetWorkspaceName)); @@ -383,11 +416,14 @@ public function changeBaseWorkspaceAction(string $targetWorkspaceName, string $d $redirectNode = $subgraph->findParentNode($redirectNode->nodeAggregateId); // get parent always returns Node if (!$redirectNode) { - throw new \Exception(sprintf( - 'Wasn\'t able to locate any valid node in rootline of node %s in the workspace %s.', - $documentNode->nodeAggregateId->value, - $targetWorkspaceName - ), 1458814469); + throw new \Exception( + sprintf( + 'Wasn\'t able to locate any valid node in rootline of node %s in the workspace %s.', + $documentNode->nodeAggregateId->value, + $targetWorkspaceName + ), + 1458814469 + ); } } } @@ -573,10 +609,15 @@ public function flowQueryAction(array $chain): string $finisher = array_pop($chain); $payload = $createContext['payload'] ?? []; - $flowQuery = new FlowQuery(array_map( - fn ($envelope) => $this->nodeService->findNodeBySerializedNodeAddress($envelope['$node'], $contentRepositoryId), - $payload - )); + $flowQuery = new FlowQuery( + array_map( + fn ($envelope) => $this->nodeService->findNodeBySerializedNodeAddress( + $envelope['$node'], + $contentRepositoryId + ), + $payload + ) + ); foreach ($chain as $operation) { $flowQuery = $flowQuery->__call($operation['type'], $operation['payload']); @@ -616,7 +657,11 @@ public function generateUriPathSegmentAction(string $contextNode, string $text): $nodeAddressFactory = NodeAddressFactory::create($contentRepository); $contextNodeAddress = $nodeAddressFactory->createFromUriString($contextNode); - $subgraph = $contentRepository->getContentGraph()->getSubgraph($contextNodeAddress->contentStreamId, $contextNodeAddress->dimensionSpacePoint, VisibilityConstraints::withoutRestrictions()); + $subgraph = $contentRepository->getContentGraph()->getSubgraph( + $contextNodeAddress->contentStreamId, + $contextNodeAddress->dimensionSpacePoint, + VisibilityConstraints::withoutRestrictions() + ); $contextNode = $subgraph->findNodeById($contextNodeAddress->nodeAggregateId); $slug = $this->nodeUriPathSegmentGenerator->generateUriPathSegment($contextNode, $text); @@ -632,14 +677,24 @@ public function generateUriPathSegmentAction(string $contextNode, string $text): public function rebaseWorkspaceAction(string $targetWorkspaceName): void { $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $targetWorkspaceName = WorkspaceName::fromString($targetWorkspaceName); + + /** @todo send from UI */ + $command = new SyncWorkspace( + contentRepositoryId: $contentRepositoryId, + workspaceName: $targetWorkspaceName, + rebaseErrorHandlingStrategy: RebaseErrorHandlingStrategy::STRATEGY_FAIL + ); - $command = RebaseWorkspace::create(WorkspaceName::fromString($targetWorkspaceName)); try { - $contentRepository->handle($command)->block(); + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + $workspace->rebase($command->rebaseErrorHandlingStrategy); } catch (\Exception $exception) { $error = new Error(); - $error->setMessage($error->getMessage()); + $error->setMessage($exception->getMessage()); $this->feedbackCollection->add($error); $this->view->assign('value', $this->feedbackCollection); @@ -648,7 +703,7 @@ public function rebaseWorkspaceAction(string $targetWorkspaceName): void $success = new Success(); $success->setMessage( - $this->getLabel('workspaceSynchronizationApplied', ['workspaceName' => $targetWorkspaceName]) + $this->getLabel('workspaceSynchronizationApplied', ['workspaceName' => $targetWorkspaceName->value]) ); $this->feedbackCollection->add($success); diff --git a/Classes/Domain/Model/Changes/Remove.php b/Classes/Domain/Model/Changes/Remove.php index b7b441d901..69bb2a409e 100644 --- a/Classes/Domain/Model/Changes/Remove.php +++ b/Classes/Domain/Model/Changes/Remove.php @@ -18,6 +18,7 @@ use Neos\ContentRepository\Core\SharedModel\Node\NodeVariantSelectionStrategy; use Neos\ContentRepository\Core\Feature\NodeRemoval\Command\RemoveNodeAggregate; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregatesTypeIsAmbiguous; +use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId; use Neos\Flow\Annotations as Flow; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Fusion\Cache\ContentCacheFlusher; @@ -72,8 +73,6 @@ public function apply(): void // otherwise we cannot find the parent nodes anymore. $this->updateWorkspaceInfo(); - $subgraph = $this->contentRepositoryRegistry->subgraphForNode($this->subject); - $closestDocumentParentNode = $subgraph->findClosestNode($this->subject->nodeAggregateId, FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_DOCUMENT)); $workspace = $this->contentRepositoryRegistry->get($this->subject->subgraphIdentity->contentRepositoryId) ->getWorkspaceFinder()->findOneByCurrentContentStreamId($subject->subgraphIdentity->contentStreamId); if (!$workspace) { @@ -88,8 +87,9 @@ public function apply(): void $subject->subgraphIdentity->dimensionSpacePoint, NodeVariantSelectionStrategy::STRATEGY_ALL_SPECIALIZATIONS, ); - if ($closestDocumentParentNode !== null) { - $command = $command->withRemovalAttachmentPoint($closestDocumentParentNode->nodeAggregateId); + $removalAttachmentPoint = $this->getRemovalAttachmentPoint(); + if ($removalAttachmentPoint !== null) { + $command = $command->withRemovalAttachmentPoint($removalAttachmentPoint); } $contentRepository = $this->contentRepositoryRegistry->get($subject->subgraphIdentity->contentRepositoryId); @@ -104,4 +104,17 @@ public function apply(): void $this->feedbackCollection->add($updateParentNodeInfo); } } + + private function getRemovalAttachmentPoint(): ?NodeAggregateId + { + $subgraph = $this->contentRepositoryRegistry->subgraphForNode($this->subject); + + if ($this->subject->nodeType?->isOfType(NodeTypeNameFactory::NAME_DOCUMENT)) { + $closestSiteNode = $subgraph->findClosestNode($this->subject->nodeAggregateId, FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_SITE)); + return $closestSiteNode?->nodeAggregateId; + } + + $closestDocumentParentNode = $subgraph->findClosestNode($this->subject->nodeAggregateId, FindClosestNodeFilter::create(nodeTypes: NodeTypeNameFactory::NAME_DOCUMENT)); + return $closestDocumentParentNode?->nodeAggregateId; + } } diff --git a/Classes/Infrastructure/MVC/RoutesProvider.php b/Classes/Infrastructure/MVC/RoutesProvider.php index 0df110bc71..f916133b9c 100644 --- a/Classes/Infrastructure/MVC/RoutesProvider.php +++ b/Classes/Infrastructure/MVC/RoutesProvider.php @@ -32,10 +32,14 @@ public function getRoutes(UriBuilder $uriBuilder): array $routes['ui']['service'] = [ 'change' => $helper->buildUiServiceRoute('change'), - 'publish' => - $helper->buildUiServiceRoute('publish'), - 'discard' => - $helper->buildUiServiceRoute('discard'), + 'publishChangesInSite' => + $helper->buildUiServiceRoute('publishChangesInSite'), + 'publishChangesInDocument' => + $helper->buildUiServiceRoute('publishChangesInDocument'), + 'discardChangesInSite' => + $helper->buildUiServiceRoute('discardChangesInSite'), + 'discardChangesInDocument' => + $helper->buildUiServiceRoute('discardChangesInDocument'), 'changeBaseWorkspace' => $helper->buildUiServiceRoute('changeBaseWorkspace'), 'rebaseWorkspace' => diff --git a/Classes/Service/PublishingService.php b/Classes/Service/PublishingService.php deleted file mode 100644 index d102fe8370..0000000000 --- a/Classes/Service/PublishingService.php +++ /dev/null @@ -1,46 +0,0 @@ -handle( - RebaseWorkspace::create( - $workspaceName - ) - )->block(); - - $contentRepository->handle( - PublishWorkspace::create( - $workspaceName - ) - )->block(); - } -} diff --git a/Configuration/Routes.Service.yaml b/Configuration/Routes.Service.yaml index e98270928e..6c3e905b0a 100644 --- a/Configuration/Routes.Service.yaml +++ b/Configuration/Routes.Service.yaml @@ -7,19 +7,35 @@ httpMethods: ['POST'] - - name: 'Publish' - uriPattern: 'publish' + name: 'Publish all changes in site' + uriPattern: 'publish-changes-in-site' defaults: '@controller': 'BackendService' - '@action': 'publish' + '@action': 'publishChangesInSite' httpMethods: ['POST'] - - name: 'Discard' - uriPattern: 'discard' + name: 'Publish all changes in document' + uriPattern: 'publish-changes-in-document' defaults: '@controller': 'BackendService' - '@action': 'discard' + '@action': 'publishChangesInDocument' + httpMethods: ['POST'] + +- + name: 'Discard all changes in site' + uriPattern: 'discard-changes-in-site' + defaults: + '@controller': 'BackendService' + '@action': 'discardChangesInSite' + httpMethods: ['POST'] + +- + name: 'Discard all changes in document' + uriPattern: 'discard-changes-in-document' + defaults: + '@controller': 'BackendService' + '@action': 'discardChangesInDocument' httpMethods: ['POST'] - diff --git a/Tests/IntegrationTests/TestDistribution/composer.json b/Tests/IntegrationTests/TestDistribution/composer.json index 428860f439..548df53d7c 100644 --- a/Tests/IntegrationTests/TestDistribution/composer.json +++ b/Tests/IntegrationTests/TestDistribution/composer.json @@ -33,7 +33,7 @@ "extra": { "patches": { "neos/neos-development-collection": { - "TASK: Remove declaration of ui nodeCreationHandlers": "https://github.com/neos/neos-development-collection/pull/4630.patch" + "Publishing Bonanza": "https://github.com/neos/neos-development-collection/pull/4943.patch" } } }, diff --git a/packages/neos-ui-backend-connector/src/Endpoints/index.ts b/packages/neos-ui-backend-connector/src/Endpoints/index.ts index 9ebf9dd4e7..f17557916c 100644 --- a/packages/neos-ui-backend-connector/src/Endpoints/index.ts +++ b/packages/neos-ui-backend-connector/src/Endpoints/index.ts @@ -9,8 +9,10 @@ export interface Routes { ui: { service: { change: string; - publish: string; - discard: string; + publishChangesInSite: string; + publishChangesInDocument: string; + discardChangesInSite: string; + discardChangesInDocument: string; changeBaseWorkspace: string; rebaseWorkspace: string; copyNodes: string; @@ -65,8 +67,8 @@ export default (routes: Routes) => { })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); - const publish = (nodeContextPaths: NodeContextPath[], targetWorkspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ - url: routes.ui.service.publish, + const publishChangesInSite = (siteId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + url: routes.ui.service.publishChangesInSite, method: 'POST', credentials: 'include', headers: { @@ -74,15 +76,41 @@ export default (routes: Routes) => { 'Content-Type': 'application/json' }, body: JSON.stringify({ - nodeContextPaths, - targetWorkspaceName + command: {siteId, workspaceName} + }) + })).then(response => fetchWithErrorHandling.parseJson(response)) + .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); + + const publishChangesInDocument = (documentId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + url: routes.ui.service.publishChangesInDocument, + method: 'POST', + credentials: 'include', + headers: { + 'X-Flow-Csrftoken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + command: {documentId, workspaceName} }) })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); - const discard = (nodeContextPaths: NodeContextPath[]) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ - url: routes.ui.service.discard, + const discardChangesInSite = (siteId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + url: routes.ui.service.discardChangesInSite, + method: 'POST', + credentials: 'include', + headers: { + 'X-Flow-Csrftoken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + command: {siteId, workspaceName} + }) + })).then(response => fetchWithErrorHandling.parseJson(response)) + .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); + const discardChangesInDocument = (documentId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + url: routes.ui.service.discardChangesInDocument, method: 'POST', credentials: 'include', headers: { @@ -90,7 +118,7 @@ export default (routes: Routes) => { 'Content-Type': 'application/json' }, body: JSON.stringify({ - nodeContextPaths + command: {documentId, workspaceName} }) })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); @@ -652,8 +680,10 @@ export default (routes: Routes) => { return { loadImageMetadata, change, - publish, - discard, + publishChangesInSite, + publishChangesInDocument, + discardChangesInSite, + discardChangesInDocument, changeBaseWorkspace, rebaseWorkspace, copyNodes, 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 2c97d1400f..97618f7d74 100644 --- a/packages/neos-ui-redux-store/src/CR/Nodes/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Nodes/index.ts @@ -73,6 +73,7 @@ export enum actionTypes { SET_DOCUMENT_NODE = '@neos/neos-ui/CR/Nodes/SET_DOCUMENT_NODE', SET_STATE = '@neos/neos-ui/CR/Nodes/SET_STATE', RELOAD_STATE = '@neos/neos-ui/CR/Nodes/RELOAD_STATE', + RELOAD_STATE_FINISHED = '@neos/neos-ui/CR/Nodes/RELOAD_STATE_FINISHED', COPY = '@neos/neos-ui/CR/Nodes/COPY', COPY_MULTIPLE = '@neos/neos-ui/CR/Nodes/COPY_MULTIPLE', CUT = '@neos/neos-ui/CR/Nodes/CUT', @@ -219,6 +220,11 @@ const reloadState = ((payload: { ); }); +/** + * Signals that the node state has been fully reloaded + */ +const finishReloadState = () => createAction(actionTypes.RELOAD_STATE_FINISHED); + // This data may be coming from the Guest frame, so we need to re-create it at host/ // Otherwise we get all "can't execute code from a freed script" errors in Edge, // when the guest frame has been navigated away and old guest frame document was destroyed @@ -329,6 +335,7 @@ export const actions = { setDocumentNode, setState, reloadState, + finishReloadState, copy, copyMultiple, cut, diff --git a/packages/neos-ui-redux-store/src/CR/Nodes/selectors.ts b/packages/neos-ui-redux-store/src/CR/Nodes/selectors.ts index b9615d9069..ad1c01834f 100644 --- a/packages/neos-ui-redux-store/src/CR/Nodes/selectors.ts +++ b/packages/neos-ui-redux-store/src/CR/Nodes/selectors.ts @@ -448,27 +448,37 @@ export const destructiveOperationsAreDisabledForContentTreeSelector = createSele } ); +const parentLineCombiner = (focusedNode: Node | null, nodesByContextPath: NodeMap) => { + const result = [focusedNode]; + let currentNode = focusedNode; + + while (currentNode) { + const {parent} = currentNode; + if (parent) { + currentNode = nodesByContextPath[parent] || null; + if (currentNode) { + result.push(currentNode); + } + } else { + break; + } + } + + return result; +}; + export const focusedNodeParentLineSelector = createSelector( [ focusedSelector, nodesByContextPathSelector ], - (focusedNode, nodesByContextPath) => { - const result = [focusedNode]; - let currentNode = focusedNode; - - while (currentNode) { - const {parent} = currentNode; - if (parent) { - currentNode = nodesByContextPath[parent] || null; - if (currentNode) { - result.push(currentNode); - } - } else { - break; - } - } + parentLineCombiner +); - return result; - } +export const documentNodeParentLineSelector = createSelector( + [ + documentNodeSelector, + nodesByContextPathSelector + ], + parentLineCombiner ); diff --git a/packages/neos-ui-redux-store/src/CR/Workspaces/index.spec.js b/packages/neos-ui-redux-store/src/CR/Workspaces/index.spec.js index ab1610930e..a63f540a42 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.spec.js +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.spec.js @@ -4,10 +4,12 @@ import {actionTypes as system} from '../../System/index'; test(`should export actionTypes`, () => { expect(actionTypes).not.toBe(undefined); expect(typeof (actionTypes.UPDATE)).toBe('string'); - expect(typeof (actionTypes.PUBLISH)).toBe('string'); - expect(typeof (actionTypes.COMMENCE_DISCARD)).toBe('string'); + expect(typeof (actionTypes.PUBLISH_STARTED)).toBe('string'); + expect(typeof (actionTypes.PUBLISH_FINISHED)).toBe('string'); + expect(typeof (actionTypes.DISCARD_STARTED)).toBe('string'); expect(typeof (actionTypes.DISCARD_ABORTED)).toBe('string'); expect(typeof (actionTypes.DISCARD_CONFIRMED)).toBe('string'); + expect(typeof (actionTypes.DISCARD_FINISHED)).toBe('string'); expect(typeof (actionTypes.CHANGE_BASE_WORKSPACE)).toBe('string'); expect(typeof (actionTypes.REBASE_WORKSPACE)).toBe('string'); }); @@ -16,9 +18,11 @@ test(`should export action creators`, () => { expect(actions).not.toBe(undefined); expect(typeof (actions.update)).toBe('function'); expect(typeof (actions.publish)).toBe('function'); - expect(typeof (actions.commenceDiscard)).toBe('function'); + expect(typeof (actions.finishPublish)).toBe('function'); + expect(typeof (actions.discard)).toBe('function'); expect(typeof (actions.abortDiscard)).toBe('function'); expect(typeof (actions.confirmDiscard)).toBe('function'); + expect(typeof (actions.finishDiscard)).toBe('function'); expect(typeof (actions.changeBaseWorkspace)).toBe('function'); expect(typeof (actions.rebaseWorkspace)).toBe('function'); }); diff --git a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts index 6f2c58a138..259e17693a 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts @@ -8,20 +8,36 @@ import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; import * as selectors from './selectors'; +type TypeOfChange = number; + +interface PublishableNode { + contextPath: NodeContextPath; + documentContextPath: NodeContextPath; + typeOfChange: TypeOfChange; +} + export interface WorkspaceInformation { name: WorkspaceName; - publishableNodes: Array<{ - contextPath: NodeContextPath; - documentContextPath: NodeContextPath; - }>; + publishableNodes: Array; baseWorkspace: WorkspaceName; readOnly?: boolean; status?: string; } +export enum PublishDiscardMode { + PUBLISHING, + DISCARDING +} + +export enum PublishDiscardScope { + SITE, + DOCUMENT +} + export interface State extends Readonly<{ personalWorkspace: WorkspaceInformation; - toBeDiscarded: NodeContextPath[]; + mode: null | PublishDiscardMode; + scope: null | PublishDiscardScope; }> {} export const defaultState: State = { @@ -31,15 +47,18 @@ export const defaultState: State = { baseWorkspace: '', status: '' }, - toBeDiscarded: [] + mode: null, + scope: null }; export enum actionTypes { UPDATE = '@neos/neos-ui/CR/Workspaces/UPDATE', - PUBLISH = '@neos/neos-ui/CR/Workspaces/PUBLISH', - COMMENCE_DISCARD = '@neos/neos-ui/CR/Workspaces/COMMENCE_DISCARD', + PUBLISH_STARTED = '@neos/neos-ui/CR/Workspaces/PUBLISH_STARTED', + PUBLISH_FINISHED = '@neos/neos-ui/CR/Workspaces/PUBLISH_FINISHED', + DISCARD_STARTED = '@neos/neos-ui/CR/Workspaces/DISCARD_STARTED', DISCARD_ABORTED = '@neos/neos-ui/CR/Workspaces/DISCARD_ABORTED', DISCARD_CONFIRMED = '@neos/neos-ui/CR/Workspaces/DISCARD_CONFIRMED', + DISCARD_FINISHED = '@neos/neos-ui/CR/Workspaces/DISCARD_FINISHED', CHANGE_BASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/CHANGE_BASE_WORKSPACE', REBASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/REBASE_WORKSPACE' } @@ -52,16 +71,19 @@ export type Action = ActionType; const update = (data: WorkspaceInformation) => createAction(actionTypes.UPDATE, data); /** - * Publish nodes to the given workspace + * Publishes all changes in the given scope */ -const publish = (nodeContextPaths: NodeContextPath[], targetWorkspaceName: string) => createAction(actionTypes.PUBLISH, {nodeContextPaths, targetWorkspaceName}); +const publish = (scope: PublishDiscardScope) => createAction(actionTypes.PUBLISH_STARTED, {scope}); /** - * Start node discard workflow - * - * @param {String} contextPath The contexts paths of the nodes to be discarded + * Finish the ongoing publish */ -const commenceDiscard = (nodeContextPaths: NodeContextPath[]) => createAction(actionTypes.COMMENCE_DISCARD, nodeContextPaths); +const finishPublish = (publishedNodes: PublishableNode[]) => createAction(actionTypes.PUBLISH_FINISHED, {publishedNodes}); + +/** + * Discards all changes in the given scope + */ +const discard = (scope: PublishDiscardScope) => createAction(actionTypes.DISCARD_STARTED, {scope}); /** * Abort the ongoing node discard workflow @@ -73,6 +95,11 @@ const abortDiscard = () => createAction(actionTypes.DISCARD_ABORTED); */ const confirmDiscard = () => createAction(actionTypes.DISCARD_CONFIRMED); +/** + * Finish the ongoing discard + */ +const finishDiscard = (discardedNodes: PublishableNode[]) => createAction(actionTypes.DISCARD_FINISHED, {discardedNodes}); + /** * Change base workspace */ @@ -89,9 +116,11 @@ const rebaseWorkspace = (name: string) => createAction(actionTypes.REBASE_WORKSP export const actions = { update, publish, - commenceDiscard, + finishPublish, + discard, abortDiscard, confirmDiscard, + finishDiscard, changeBaseWorkspace, rebaseWorkspace }; @@ -109,16 +138,42 @@ export const reducer = (state: State = defaultState, action: InitAction | Action draft.personalWorkspace = assignIn(draft.personalWorkspace, action.payload); break; } - case actionTypes.COMMENCE_DISCARD: { - draft.toBeDiscarded = action.payload; + case actionTypes.PUBLISH_STARTED: { + draft.mode = PublishDiscardMode.PUBLISHING; + draft.scope = action.payload.scope; + break; + } + case actionTypes.DISCARD_STARTED: { + draft.mode = PublishDiscardMode.DISCARDING; + draft.scope = action.payload.scope; + break; + } + case actionTypes.PUBLISH_FINISHED: { + draft.mode = null; + draft.scope = null; + draft.personalWorkspace.publishableNodes = + state.personalWorkspace.publishableNodes.filter( + (publishableNode) => !action.payload.publishedNodes.some( + (publishedNode) => publishedNode.contextPath === publishableNode.contextPath + ) + ); break; } + case actionTypes.DISCARD_CONFIRMED: case actionTypes.DISCARD_ABORTED: { - draft.toBeDiscarded = []; + draft.mode = null; + draft.scope = null; break; } - case actionTypes.DISCARD_CONFIRMED: { - draft.toBeDiscarded = []; + case actionTypes.DISCARD_FINISHED: { + draft.mode = null; + draft.scope = null; + draft.personalWorkspace.publishableNodes = + state.personalWorkspace.publishableNodes.filter( + (publishableNode) => !action.payload.discardedNodes.some( + (discardedNode) => discardedNode.contextPath === publishableNode.contextPath + ) + ); break; } } diff --git a/packages/neos-ui-redux-store/src/CR/index.ts b/packages/neos-ui-redux-store/src/CR/index.ts index ee7e9d673f..0af8742af1 100644 --- a/packages/neos-ui-redux-store/src/CR/index.ts +++ b/packages/neos-ui-redux-store/src/CR/index.ts @@ -6,7 +6,7 @@ import * as Workspaces from './Workspaces'; const all = {ContentDimensions, Nodes, Workspaces}; -function typedKeys(o: T) : Array { +function typedKeys(o: T) : Array { return Object.keys(o) as Array; } diff --git a/packages/neos-ui-redux-store/src/UI/index.ts b/packages/neos-ui-redux-store/src/UI/index.ts index 8ccb2b9c74..60b5576f35 100644 --- a/packages/neos-ui-redux-store/src/UI/index.ts +++ b/packages/neos-ui-redux-store/src/UI/index.ts @@ -42,7 +42,7 @@ const all = { ContentTree }; -function typedKeys(o: T) : Array { +function typedKeys(o: T) : Array { return Object.keys(o) as Array; } diff --git a/packages/neos-ui-redux-store/src/User/index.ts b/packages/neos-ui-redux-store/src/User/index.ts index fbe1d17a3c..fad803c10f 100644 --- a/packages/neos-ui-redux-store/src/User/index.ts +++ b/packages/neos-ui-redux-store/src/User/index.ts @@ -15,7 +15,7 @@ export interface State { impersonate: Impersonate.State; } -function typedKeys(o: T) : Array { +function typedKeys(o: T) : Array { return Object.keys(o) as Array; } diff --git a/packages/neos-ui-redux-store/src/index.ts b/packages/neos-ui-redux-store/src/index.ts index 6e9b7554c1..b8fc23cd53 100644 --- a/packages/neos-ui-redux-store/src/index.ts +++ b/packages/neos-ui-redux-store/src/index.ts @@ -9,7 +9,7 @@ import * as ServerFeedback from './ServerFeedback'; const all = {Changes, CR, System, UI, User, ServerFeedback}; -function typedKeys(o: T) : Array { +function typedKeys(o: T) : Array { return Object.keys(o) as Array; } diff --git a/packages/neos-ui-sagas/src/CR/NodeOperations/reloadState.js b/packages/neos-ui-sagas/src/CR/NodeOperations/reloadState.js index b0f7a143ec..60d7ab6020 100644 --- a/packages/neos-ui-sagas/src/CR/NodeOperations/reloadState.js +++ b/packages/neos-ui-sagas/src/CR/NodeOperations/reloadState.js @@ -36,5 +36,6 @@ export default function * watchReloadState({configuration}) { merge: action?.payload?.merge })); yield put(actions.UI.PageTree.setAsLoaded(currentSiteNodeContextPath)); + yield put(actions.CR.Nodes.finishReloadState()); }); } diff --git a/packages/neos-ui-sagas/src/Publish/index.js b/packages/neos-ui-sagas/src/Publish/index.js index 976e1987ba..2e9fdd175c 100644 --- a/packages/neos-ui-sagas/src/Publish/index.js +++ b/packages/neos-ui-sagas/src/Publish/index.js @@ -1,26 +1,41 @@ import {put, call, select, takeEvery, takeLatest, take, race} from 'redux-saga/effects'; import {actionTypes, actions, selectors} from '@neos-project/neos-ui-redux-store'; +import {PublishDiscardScope} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; import backend from '@neos-project/neos-ui-backend-connector'; import {getGuestFrameDocument} from '@neos-project/neos-ui-guest-frame/src/dom'; export function * watchPublish() { - const {publish} = backend.get().endpoints; + const {publishChangesInSite, publishChangesInDocument} = backend.get().endpoints; - yield takeEvery(actionTypes.CR.Workspaces.PUBLISH, function * publishNodes(action) { - const {nodeContextPaths, targetWorkspaceName} = action.payload; + yield takeEvery(actionTypes.CR.Workspaces.PUBLISH_STARTED, function * publishNodes(action) { + const {scope} = action.payload; + const workspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); - if (nodeContextPaths.length > 0) { - yield put(actions.UI.Remote.startPublishing()); + yield put(actions.UI.Remote.startPublishing()); - try { - const feedback = yield call(publish, nodeContextPaths, targetWorkspaceName); - yield put(actions.UI.Remote.finishPublishing()); - yield put(actions.ServerFeedback.handleServerFeedback(feedback)); - } catch (error) { - console.error('Failed to publish', error); + let feedback = null; + let publishedNodes = []; + try { + if (scope === PublishDiscardScope.SITE) { + const siteId = yield select(selectors.CR.Nodes.siteNodeContextPathSelector); + publishedNodes = yield select(selectors.CR.Workspaces.publishableNodesSelector); + feedback = yield call(publishChangesInSite, siteId, workspaceName); + } else if (scope === PublishDiscardScope.DOCUMENT) { + const documentId = yield select(selectors.CR.Nodes.documentNodeContextPathSelector); + publishedNodes = yield select(selectors.CR.Workspaces.publishableNodesInDocumentSelector); + feedback = yield call(publishChangesInDocument, documentId, workspaceName); } + } catch (error) { + console.error('Failed to publish', error); + } + + if (feedback !== null) { + yield put(actions.ServerFeedback.handleServerFeedback(feedback)); } + + yield put(actions.CR.Workspaces.finishPublish(publishedNodes)); + yield put(actions.UI.Remote.finishPublishing()); }); } @@ -60,10 +75,8 @@ export function * watchRebaseWorkspace() { }); } -export function * discardIfConfirmed() { - const {discard} = backend.get().endpoints; - yield takeLatest(actionTypes.CR.Workspaces.COMMENCE_DISCARD, function * waitForConfirmation() { - const state = yield select(); +export function * discardIfConfirmed({routes}) { + yield takeLatest(actionTypes.CR.Workspaces.DISCARD_STARTED, function * waitForConfirmation(action) { const waitForNextAction = yield race([ take(actionTypes.CR.Workspaces.DISCARD_ABORTED), take(actionTypes.CR.Workspaces.DISCARD_CONFIRMED) @@ -75,29 +88,90 @@ export function * discardIfConfirmed() { } if (nextAction.type === actionTypes.CR.Workspaces.DISCARD_CONFIRMED) { - yield put(actions.UI.Remote.startDiscarding()); - const nodesToBeDiscarded = state?.cr?.workspaces?.toBeDiscarded; + yield * discard(action.payload.scope, routes); + } + }); +} - try { - const currentContentCanvasContextPath = yield select(selectors.CR.Nodes.documentNodeContextPathSelector); +function * discard(scope, routes) { + const {discardChangesInSite, discardChangesInDocument} = backend.get().endpoints; + const workspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); + + yield put(actions.UI.Remote.startDiscarding()); + + let feedback = null; + let discardedNodes = []; + try { + if (scope === PublishDiscardScope.SITE) { + const siteId = yield select(selectors.CR.Nodes.siteNodeContextPathSelector); + discardedNodes = yield select(selectors.CR.Workspaces.publishableNodesSelector); + feedback = yield call(discardChangesInSite, siteId, workspaceName); + } else if (scope === PublishDiscardScope.DOCUMENT) { + const documentId = yield select(selectors.CR.Nodes.documentNodeContextPathSelector); + discardedNodes = yield select(selectors.CR.Workspaces.publishableNodesInDocumentSelector); + feedback = yield call(discardChangesInDocument, documentId, workspaceName); + } + } catch (error) { + console.error('Failed to discard', error); + } + + if (feedback !== null) { + yield put(actions.ServerFeedback.handleServerFeedback(feedback)); + } + + yield * reloadAfterDiscard(discardedNodes, routes); + + yield put(actions.CR.Workspaces.finishDiscard(discardedNodes)); + yield put(actions.UI.Remote.finishDiscarding()); +} - const feedback = yield call(discard, nodesToBeDiscarded); - yield put(actions.UI.Remote.finishDiscarding()); - yield put(actions.ServerFeedback.handleServerFeedback(feedback)); +const NODE_HAS_BEEN_CREATED = 0b0001; - // Check if the currently focused document node has been removed - const contentCanvasNodeIsStillThere = Boolean(yield select(selectors.CR.Nodes.byContextPathSelector(currentContentCanvasContextPath))); +function * reloadAfterDiscard(discardedNodes, routes) { + const currentContentCanvasContextPath = yield select(selectors.CR.Nodes.documentNodeContextPathSelector); + const currentDocumentParentLine = yield select(selectors.CR.Nodes.documentNodeParentLineSelector); - // If not, reload the document - if (contentCanvasNodeIsStillThere) { - getGuestFrameDocument().location.reload(); + const avilableAncestorDocumentNode = currentDocumentParentLine.reduce((prev, cur) => { + if (prev === null) { + const hasBeenRemovedByDiscard = discardedNodes.some((discardedNode) => { + if (discardedNode.contextPath !== cur.contextPath) { + return false; } - // Reload the page tree - yield put(actions.CR.Nodes.reloadState()); - } catch (error) { - console.error('Failed to discard', error); + return Boolean(discardedNode.typeOfChange & NODE_HAS_BEEN_CREATED); + }); + + if (!hasBeenRemovedByDiscard) { + return cur; } } - }); + + return prev; + }, null); + + if (avilableAncestorDocumentNode === null) { + // We're doomed - there's no document left to navigate to + // In this (rather unlikely) case, we leave the UI and navigate + // to whatever default entry module is configured: + window.location.href = routes?.core?.modules?.defaultModule; + return; + } + + // Reload all nodes aaand... + yield put(actions.CR.Nodes.reloadState({ + documentNodeContextPath: avilableAncestorDocumentNode.contextPath + })); + // wait for it. + yield take(actionTypes.CR.Nodes.RELOAD_STATE_FINISHED); + + // Check if the currently focused document node has been removed + const contentCanvasNodeIsStillThere = Boolean(yield select(selectors.CR.Nodes.byContextPathSelector(currentContentCanvasContextPath))); + + if (contentCanvasNodeIsStillThere) { + // If it's still there, reload the document + getGuestFrameDocument().location.reload(); + } else { + // If it's gone navigate to the next available ancestor document + yield put(actions.UI.ContentCanvas.setSrc(avilableAncestorDocumentNode.uri)); + } } diff --git a/packages/neos-ui/src/Containers/Modals/DiscardDialog/index.js b/packages/neos-ui/src/Containers/Modals/DiscardDialog/index.js index 6eb1e472f7..df309ab8fa 100644 --- a/packages/neos-ui/src/Containers/Modals/DiscardDialog/index.js +++ b/packages/neos-ui/src/Containers/Modals/DiscardDialog/index.js @@ -5,19 +5,35 @@ import {connect} from 'react-redux'; import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; import I18n from '@neos-project/neos-ui-i18n'; -import {actions} from '@neos-project/neos-ui-redux-store'; +import {actions, selectors} from '@neos-project/neos-ui-redux-store'; +import {PublishDiscardScope, PublishDiscardMode} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; import style from './style.module.css'; -@connect(state => ({ - nodesToBeDiscarded: state?.cr?.workspaces?.toBeDiscarded -}), { +const {publishableNodesSelector, publishableNodesInDocumentSelector} = selectors.CR.Workspaces; + +@connect(state => { + const mode = state?.cr?.workspaces?.mode; + + let numberOfChangesToBeDiscarded = 0; + if (mode === PublishDiscardMode.DISCARDING) { + const scope = state?.cr?.workspaces?.scope; + + if (scope === PublishDiscardScope.SITE) { + numberOfChangesToBeDiscarded = publishableNodesSelector(state).length; + } else if (scope === PublishDiscardScope.DOCUMENT) { + numberOfChangesToBeDiscarded = publishableNodesInDocumentSelector(state).length; + } + } + + return {numberOfChangesToBeDiscarded}; +}, { confirm: actions.CR.Workspaces.confirmDiscard, abort: actions.CR.Workspaces.abortDiscard }) export default class DiscardDialog extends PureComponent { static propTypes = { - nodesToBeDiscarded: PropTypes.array, + numberOfChangesToBeDiscarded: PropTypes.number, confirm: PropTypes.func.isRequired, abort: PropTypes.func.isRequired }; @@ -75,11 +91,10 @@ export default class DiscardDialog extends PureComponent { } render() { - const {nodesToBeDiscarded} = this.props; - if (nodesToBeDiscarded.length === 0) { + const {numberOfChangesToBeDiscarded: numberOfChanges} = this.props; + if (numberOfChanges === 0) { return null; } - const numberOfChanges = nodesToBeDiscarded.length; return ( ({ i18nRegistry: globalRegistry.get('i18n') @@ -45,35 +46,31 @@ export default class PublishDropDown extends PureComponent { personalWorkspaceName: PropTypes.string.isRequired, baseWorkspace: PropTypes.string.isRequired, neos: PropTypes.object.isRequired, - publishAction: PropTypes.func.isRequired, - discardAction: PropTypes.func.isRequired, + publish: PropTypes.func.isRequired, + discard: PropTypes.func.isRequired, changeBaseWorkspaceAction: PropTypes.func.isRequired, routes: PropTypes.object, i18nRegistry: PropTypes.object.isRequired }; handlePublishClick = () => { - const {publishableNodesInDocument, publishAction, baseWorkspace} = this.props; - - publishAction(publishableNodesInDocument.map(node => node?.contextPath), baseWorkspace); + const {publish} = this.props; + publish(PublishDiscardScope.DOCUMENT); } handlePublishAllClick = () => { - const {publishableNodes, publishAction, baseWorkspace} = this.props; - - publishAction(publishableNodes.map(node => node?.contextPath), baseWorkspace); + const {publish} = this.props; + publish(PublishDiscardScope.SITE); } handleDiscardClick = () => { - const {publishableNodesInDocument, discardAction} = this.props; - - discardAction(publishableNodesInDocument.map(node => node?.contextPath)); + const {discard} = this.props; + discard(PublishDiscardScope.DOCUMENT); } handleDiscardAllClick = () => { - const {publishableNodes, discardAction} = this.props; - - discardAction(publishableNodes.map(node => node?.contextPath)); + const {discard} = this.props; + discard(PublishDiscardScope.SITE); } render() {