From 4cbf01082ea11b7cae0fe1e565cafe7475f29ff5 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 3 Apr 2024 20:53:52 +0200 Subject: [PATCH 01/29] TASK: Use WorkspaceStatus type for workspace status state, let it default to UP_TO_DATE --- packages/neos-ui-redux-store/src/CR/Workspaces/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 6f78fd8619..46626adf4d 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts @@ -1,7 +1,7 @@ import produce from 'immer'; import assignIn from 'lodash.assignin'; import {action as createAction, ActionType} from 'typesafe-actions'; -import {NodeContextPath} from '@neos-project/neos-ts-interfaces'; +import {NodeContextPath, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; @@ -27,7 +27,7 @@ export interface WorkspaceInformation { publishableNodes: Array; baseWorkspace: WorkspaceName; readOnly?: boolean; - status?: string; + status: WorkspaceStatus; } export interface State extends Readonly<{ @@ -39,7 +39,7 @@ export const defaultState: State = { name: '', publishableNodes: [], baseWorkspace: '', - status: '' + status: WorkspaceStatus.UP_TO_DATE } }; From 10b44eec01d31c242cffba8521b22a53fbfc213d Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 4 Apr 2024 15:12:51 +0200 Subject: [PATCH 02/29] TASK: Use Neos.Neos high-level Workspace API for WorkspaceInfo This includes a new data-point `totalNumberOfChanges` which gives us the number of all changes in the workspace (as opposed to just the changes per-site or per-document). This is needed to inform the `DISCARD_ALL` strategy during rebase. --- .../Operations/UpdateWorkspaceInfo.php | 22 +++++++----- Classes/Fusion/Helper/WorkspaceHelper.php | 35 ++++++++++++------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php b/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php index d226d897db..b2e08901b0 100644 --- a/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php +++ b/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php @@ -13,13 +13,13 @@ use Neos\ContentRepository\Core\Projection\Workspace\Workspace; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; -use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\Flow\Annotations as Flow; use Neos\Neos\Ui\ContentRepository\Service\WorkspaceService; use Neos\Neos\Ui\Domain\Model\AbstractFeedback; use Neos\Neos\Ui\Domain\Model\FeedbackInterface; use Neos\Flow\Mvc\Controller\ControllerContext; +use Neos\Neos\Domain\Workspace\WorkspaceProvider; /** * @internal @@ -36,9 +36,9 @@ class UpdateWorkspaceInfo extends AbstractFeedback /** * @Flow\Inject - * @var ContentRepositoryRegistry + * @var WorkspaceProvider */ - protected $contentRepositoryRegistry; + protected $workspaceProvider; /** * UpdateWorkspaceInfo constructor. @@ -119,17 +119,21 @@ public function serializePayload(ControllerContext $controllerContext) return null; } - $contentRepository = $this->contentRepositoryRegistry->get($this->contentRepositoryId); - $workspace = $contentRepository->getWorkspaceFinder()->findOneByName($this->workspaceName); + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $this->contentRepositoryId, + $this->workspaceName + ); + $totalNumberOfChanges = $workspace->countAllChanges(); - return $workspace ? [ + return [ 'name' => $this->workspaceName->value, + 'totalNumberOfChanges' => $totalNumberOfChanges, 'publishableNodes' => $this->workspaceService->getPublishableNodeInfo( $this->workspaceName, $this->contentRepositoryId ), - 'baseWorkspace' => $workspace->baseWorkspaceName->value, - 'status' => $workspace->status - ] : []; + 'baseWorkspace' => $workspace->getCurrentBaseWorkspaceName()?->value, + 'status' => $workspace->getCurrentStatus() + ]; } } diff --git a/Classes/Fusion/Helper/WorkspaceHelper.php b/Classes/Fusion/Helper/WorkspaceHelper.php index 82a74accf0..797a8d78b9 100644 --- a/Classes/Fusion/Helper/WorkspaceHelper.php +++ b/Classes/Fusion/Helper/WorkspaceHelper.php @@ -17,6 +17,7 @@ use Neos\Flow\Annotations as Flow; use Neos\Flow\Security\Context; use Neos\Neos\Domain\Service\WorkspaceNameBuilder; +use Neos\Neos\Domain\Workspace\WorkspaceProvider; use Neos\Neos\Ui\ContentRepository\Service\WorkspaceService; /** @@ -43,28 +44,36 @@ class WorkspaceHelper implements ProtectedContextAwareInterface */ protected $securityContext; + /** + * @Flow\Inject + * @var WorkspaceProvider + */ + protected $workspaceProvider; + /** * @return array */ public function getPersonalWorkspace(ContentRepositoryId $contentRepositoryId): array { - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $currentAccount = $this->securityContext->getAccount(); // todo use \Neos\Neos\Service\UserService::getPersonalWorkspaceName instead? $personalWorkspaceName = WorkspaceNameBuilder::fromAccountIdentifier($currentAccount->getAccountIdentifier()); - $personalWorkspace = $contentRepository->getWorkspaceFinder()->findOneByName($personalWorkspaceName); - return !is_null($personalWorkspace) - ? [ - 'name' => $personalWorkspace->workspaceName, - 'publishableNodes' => $this->workspaceService->getPublishableNodeInfo($personalWorkspaceName, $contentRepositoryId), - 'baseWorkspace' => $personalWorkspace->baseWorkspaceName, - // TODO: FIX readonly flag! - //'readOnly' => !$this->domainUserService->currentUserCanPublishToWorkspace($baseWorkspace) - 'readOnly' => false, - 'status' => $personalWorkspace->status->value - ] - : []; + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $contentRepositoryId, + $personalWorkspaceName + ); + + return [ + 'name' => $workspace->name, + 'totalNumberOfChanges' => $workspace->countAllChanges(), + 'publishableNodes' => $this->workspaceService->getPublishableNodeInfo($personalWorkspaceName, $contentRepositoryId), + 'baseWorkspace' => $workspace->getCurrentBaseWorkspaceName(), + // TODO: FIX readonly flag! + //'readOnly' => !$this->domainUserService->currentUserCanPublishToWorkspace($baseWorkspace) + 'readOnly' => false, + 'status' => $workspace->getCurrentStatus() + ]; } /** From 84eafcf2860d7157e19dbaedd44e92f4cb19f711 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 1 Apr 2024 16:57:03 +0200 Subject: [PATCH 03/29] TASK: Create backend mechanism to collect information about rebase conflicts Conflicts are generated from the CR-side `WorkspaceRebaseFailed` exception. A conflict consists of a `TypeOfChange` (generated from the original CR command that failed) and a `ReasonForConflict` (generated from the exception that was the cause for the command to fail). It also contains information about the affected node, the associated document and the associated site. Conflicts are squashed. Only the first conflict per NodeAggregateId will be registered. This is to reduce visual redundancy, as UI users do not need to know about every single failed constraint check. They only need to understand what change they are going to lose, if they're choosing a destructive action to resolve the rebase conflicts. Also: The SyncWorkspace command has been moved to a dedicated namespace within the Application layer. The `rebaseWorkspace` action has been renamed to `syncWorkspace` everywhere to improve naming consistency. --- .../Application/SyncWorkspace/Conflict.php | 40 +++ .../Application/SyncWorkspace/Conflicts.php | 57 ++++ .../SyncWorkspace/ConflictsBuilder.php | 294 ++++++++++++++++++ .../SyncWorkspace/ConflictsOccurred.php | 31 ++ .../Application/SyncWorkspace/IconLabel.php | 35 +++ .../SyncWorkspace/ReasonForConflict.php | 28 ++ .../SyncWorkspaceCommand.php} | 9 +- .../SyncWorkspaceCommandHandler.php | 63 ++++ .../SyncWorkspace/TypeOfChange.php | 31 ++ .../Controller/BackendServiceController.php | 75 +++-- Classes/Infrastructure/MVC/RoutesProvider.php | 4 +- Configuration/Routes.Service.yaml | 6 +- .../src/Endpoints/index.ts | 12 +- .../src/CR/Workspaces/index.spec.js | 2 +- .../src/CR/Workspaces/index.ts | 6 +- packages/neos-ui-sagas/src/Publish/index.ts | 10 +- .../Modals/SyncWorkspaceDialog/index.js | 8 +- 17 files changed, 655 insertions(+), 56 deletions(-) create mode 100644 Classes/Application/SyncWorkspace/Conflict.php create mode 100644 Classes/Application/SyncWorkspace/Conflicts.php create mode 100644 Classes/Application/SyncWorkspace/ConflictsBuilder.php create mode 100644 Classes/Application/SyncWorkspace/ConflictsOccurred.php create mode 100644 Classes/Application/SyncWorkspace/IconLabel.php create mode 100644 Classes/Application/SyncWorkspace/ReasonForConflict.php rename Classes/Application/{SyncWorkspace.php => SyncWorkspace/SyncWorkspaceCommand.php} (79%) create mode 100644 Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php create mode 100644 Classes/Application/SyncWorkspace/TypeOfChange.php diff --git a/Classes/Application/SyncWorkspace/Conflict.php b/Classes/Application/SyncWorkspace/Conflict.php new file mode 100644 index 0000000000..5f96024af0 --- /dev/null +++ b/Classes/Application/SyncWorkspace/Conflict.php @@ -0,0 +1,40 @@ +items = $items; + } + + public static function builder( + ContentRepository $contentRepository, + WorkspaceName $workspaceName, + ?DimensionSpacePoint $preferredDimensionSpacePoint, + ): ConflictsBuilder { + return new ConflictsBuilder( + contentRepository: $contentRepository, + 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 new file mode 100644 index 0000000000..a8095409c4 --- /dev/null +++ b/Classes/Application/SyncWorkspace/ConflictsBuilder.php @@ -0,0 +1,294 @@ + + */ + private array $itemsByAffectedNodeAggregateId = []; + + public function __construct( + private ContentRepository $contentRepository, + WorkspaceName $workspaceName, + private ?DimensionSpacePoint $preferredDimensionSpacePoint, + ) { + $this->workspace = $contentRepository->getWorkspaceFinder() + ->findOneByName($workspaceName); + } + + 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 { + if ($this->workspace === null) { + 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 = $this->contentRepository->getContentGraph() + ->findNodeAggregateById( + $this->workspace->currentContentStreamId, + $nodeAggregateIdForDimensionFallback + ); + + if ($nodeAggregate) { + $dimensionSpacePoint = $this->extractValidDimensionSpacePointFromNodeAggregate( + $nodeAggregate + ); + + if ($dimensionSpacePoint === null) { + return null; + } + } + } + + return $this->contentRepository->getContentGraph()->getSubgraph( + $this->workspace->currentContentStreamId, + $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 + { + return new IconLabel( + icon: $node->nodeType?->getConfiguration('ui.icon') ?? 'questionmark', + label: $node->getLabel() + ); + } + + 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/ConflictsOccurred.php b/Classes/Application/SyncWorkspace/ConflictsOccurred.php new file mode 100644 index 0000000000..e62b9f9029 --- /dev/null +++ b/Classes/Application/SyncWorkspace/ConflictsOccurred.php @@ -0,0 +1,31 @@ +value; + } +} diff --git a/Classes/Application/SyncWorkspace.php b/Classes/Application/SyncWorkspace/SyncWorkspaceCommand.php similarity index 79% rename from Classes/Application/SyncWorkspace.php rename to Classes/Application/SyncWorkspace/SyncWorkspaceCommand.php index bed891b4ad..4a39375feb 100644 --- a/Classes/Application/SyncWorkspace.php +++ b/Classes/Application/SyncWorkspace/SyncWorkspaceCommand.php @@ -12,24 +12,27 @@ declare(strict_types=1); -namespace Neos\Neos\Ui\Application; +namespace Neos\Neos\Ui\Application\SyncWorkspace; +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; use Neos\Flow\Annotations as Flow; /** - * The application layer level command DTO to communicate the intent to rebase the workspace + * The application layer level command DTO to communicate the intent to + * rebase the workspace * * @internal for communication within the Neos UI only */ #[Flow\Proxy(false)] -final readonly class SyncWorkspace +final readonly class SyncWorkspaceCommand { public function __construct( public ContentRepositoryId $contentRepositoryId, public WorkspaceName $workspaceName, + public DimensionSpacePoint $preferredDimensionSpacePoint, public RebaseErrorHandlingStrategy $rebaseErrorHandlingStrategy ) { } diff --git a/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php b/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php new file mode 100644 index 0000000000..9595e2a46f --- /dev/null +++ b/Classes/Application/SyncWorkspace/SyncWorkspaceCommandHandler.php @@ -0,0 +1,63 @@ +workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + + $workspace->rebase($command->rebaseErrorHandlingStrategy); + } catch (WorkspaceRebaseFailed $e) { + $conflictsBuilder = Conflicts::builder( + contentRepository: $this->contentRepositoryRegistry + ->get($command->contentRepositoryId), + workspaceName: $command->workspaceName, + preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint + ); + + foreach ($e->commandsThatFailedDuringRebase as $commandThatFailedDuringRebase) { + $conflictsBuilder->addCommandThatFailedDuringRebase($commandThatFailedDuringRebase); + } + + throw new ConflictsOccurred( + $conflictsBuilder->build(), + 1712832228 + ); + } + } +} diff --git a/Classes/Application/SyncWorkspace/TypeOfChange.php b/Classes/Application/SyncWorkspace/TypeOfChange.php new file mode 100644 index 0000000000..379ba5f119 --- /dev/null +++ b/Classes/Application/SyncWorkspace/TypeOfChange.php @@ -0,0 +1,31 @@ +value; + } +} diff --git a/Classes/Controller/BackendServiceController.php b/Classes/Controller/BackendServiceController.php index 98ae4fecb6..a7ec16a887 100644 --- a/Classes/Controller/BackendServiceController.php +++ b/Classes/Controller/BackendServiceController.php @@ -14,6 +14,7 @@ * source code. */ +use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; use Neos\ContentRepository\Core\Feature\WorkspaceModification\Exception\WorkspaceIsNotEmptyException; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Dto\RebaseErrorHandlingStrategy; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; @@ -42,7 +43,9 @@ use Neos\Neos\Ui\Application\PublishChangesInSite; use Neos\Neos\Ui\Application\ReloadNodes\ReloadNodesQuery; use Neos\Neos\Ui\Application\ReloadNodes\ReloadNodesQueryHandler; -use Neos\Neos\Ui\Application\SyncWorkspace; +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; use Neos\Neos\Ui\Domain\Model\Feedback\Messages\Error; use Neos\Neos\Ui\Domain\Model\Feedback\Messages\Info; @@ -134,6 +137,12 @@ class BackendServiceController extends ActionController */ protected $workspaceProvider; + /** + * @Flow\Inject + * @var SyncWorkspaceCommandHandler + */ + protected $syncWorkspaceCommandHandler; + /** * @Flow\Inject * @var ReloadNodesQueryHandler @@ -685,42 +694,48 @@ public function generateUriPathSegmentAction(string $contextNode, string $text): * Rebase user workspace to current workspace * * @param string $targetWorkspaceName + * @param bool $force + * @phpstan-param null|array $dimensionSpacePoint * @return void */ - public function rebaseWorkspaceAction(string $targetWorkspaceName): void + public function syncWorkspaceAction(string $targetWorkspaceName, bool $force, ?array $dimensionSpacePoint): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; - $targetWorkspaceName = WorkspaceName::fromString($targetWorkspaceName); - - /** @todo send from UI */ - $command = new SyncWorkspace( - contentRepositoryId: $contentRepositoryId, - workspaceName: $targetWorkspaceName, - rebaseErrorHandlingStrategy: RebaseErrorHandlingStrategy::STRATEGY_FAIL - ); - try { - $workspace = $this->workspaceProvider->provideForWorkspaceName( - $command->contentRepositoryId, - $command->workspaceName - ); - $workspace->rebase($command->rebaseErrorHandlingStrategy); - } catch (\Exception $exception) { - $error = new Error(); - $error->setMessage($exception->getMessage()); + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $targetWorkspaceName = WorkspaceName::fromString($targetWorkspaceName); + $dimensionSpacePoint = $dimensionSpacePoint + ? DimensionSpacePoint::fromLegacyDimensionArray($dimensionSpacePoint) + : null; - $this->feedbackCollection->add($error); - $this->view->assign('value', $this->feedbackCollection); - return; - } + /** @todo send from UI */ + $command = new SyncWorkspaceCommand( + contentRepositoryId: $contentRepositoryId, + workspaceName: $targetWorkspaceName, + preferredDimensionSpacePoint: $dimensionSpacePoint, + rebaseErrorHandlingStrategy: $force + ? RebaseErrorHandlingStrategy::STRATEGY_FORCE + : RebaseErrorHandlingStrategy::STRATEGY_FAIL + ); - $success = new Success(); - $success->setMessage( - $this->getLabel('workspaceSynchronizationApplied', ['workspaceName' => $targetWorkspaceName->value]) - ); - $this->feedbackCollection->add($success); + $this->syncWorkspaceCommandHandler->handle($command); - $this->view->assign('value', $this->feedbackCollection); + $this->view->assign('value', [ + 'success' => true + ]); + } catch (ConflictsOccurred $e) { + $this->view->assign('value', [ + 'conflicts' => $e->conflicts + ]); + } catch (\Exception $e) { + $this->view->assign('value', [ + 'error' => [ + 'class' => $e::class, + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ] + ]); + } } /** diff --git a/Classes/Infrastructure/MVC/RoutesProvider.php b/Classes/Infrastructure/MVC/RoutesProvider.php index 256f9736a0..60d6c3a3e9 100644 --- a/Classes/Infrastructure/MVC/RoutesProvider.php +++ b/Classes/Infrastructure/MVC/RoutesProvider.php @@ -42,8 +42,8 @@ public function getRoutes(UriBuilder $uriBuilder): array $helper->buildUiServiceRoute('discardChangesInDocument'), 'changeBaseWorkspace' => $helper->buildUiServiceRoute('changeBaseWorkspace'), - 'rebaseWorkspace' => - $helper->buildUiServiceRoute('rebaseWorkspace'), + 'syncWorkspace' => + $helper->buildUiServiceRoute('syncWorkspace'), 'copyNodes' => $helper->buildUiServiceRoute('copyNodes'), 'cutNodes' => diff --git a/Configuration/Routes.Service.yaml b/Configuration/Routes.Service.yaml index 5afcb16127..dd180309b7 100644 --- a/Configuration/Routes.Service.yaml +++ b/Configuration/Routes.Service.yaml @@ -47,11 +47,11 @@ httpMethods: ['POST'] - - name: 'Rebase Workspace' - uriPattern: 'rebase-workspace' + name: 'Sync Workspace' + uriPattern: 'sync-workspace' defaults: '@controller': 'BackendService' - '@action': 'rebaseWorkspace' + '@action': 'syncWorkspace' httpMethods: ['POST'] - name: 'Copy nodes to clipboard' diff --git a/packages/neos-ui-backend-connector/src/Endpoints/index.ts b/packages/neos-ui-backend-connector/src/Endpoints/index.ts index f4ead4c451..d45b73f8f5 100644 --- a/packages/neos-ui-backend-connector/src/Endpoints/index.ts +++ b/packages/neos-ui-backend-connector/src/Endpoints/index.ts @@ -14,7 +14,7 @@ export interface Routes { discardChangesInSite: string; discardChangesInDocument: string; changeBaseWorkspace: string; - rebaseWorkspace: string; + syncWorkspace: string; copyNodes: string; cutNodes: string; clearClipboard: string; @@ -140,8 +140,8 @@ export default (routes: Routes) => { })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); - const rebaseWorkspace = (targetWorkspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ - url: routes.ui.service.rebaseWorkspace, + const syncWorkspace = (targetWorkspaceName: WorkspaceName, force: boolean, dimensionSpacePoint: null|DimensionCombination) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + url: routes.ui.service.syncWorkspace, method: 'POST', credentials: 'include', @@ -150,7 +150,9 @@ export default (routes: Routes) => { 'Content-Type': 'application/json' }, body: JSON.stringify({ - targetWorkspaceName + targetWorkspaceName, + force, + dimensionSpacePoint }) })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); @@ -707,7 +709,7 @@ export default (routes: Routes) => { discardChangesInSite, discardChangesInDocument, changeBaseWorkspace, - rebaseWorkspace, + syncWorkspace, copyNodes, cutNodes, clearClipboard, 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 920ae74116..5e313185eb 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 @@ -12,7 +12,7 @@ test(`should export action creators`, () => { expect(actions).not.toBe(undefined); expect(typeof (actions.update)).toBe('function'); expect(typeof (actions.changeBaseWorkspace)).toBe('function'); - expect(typeof (actions.rebaseWorkspace)).toBe('function'); + expect(typeof (actions.syncWorkspace)).toBe('function'); }); test(`should export a reducer`, () => { 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 46626adf4d..1bc1130daf 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts @@ -46,7 +46,7 @@ export const defaultState: State = { export enum actionTypes { UPDATE = '@neos/neos-ui/CR/Workspaces/UPDATE', CHANGE_BASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/CHANGE_BASE_WORKSPACE', - REBASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/REBASE_WORKSPACE' + SYNC_WORKSPACE = '@neos/neos-ui/CR/Workspaces/SYNC_WORKSPACE' } export type Action = ActionType; @@ -64,7 +64,7 @@ const changeBaseWorkspace = (name: string) => createAction(actionTypes.CHANGE_BA /** * Rebase the user workspace */ -const rebaseWorkspace = (name: string) => createAction(actionTypes.REBASE_WORKSPACE, name); +const syncWorkspace = (name: string) => createAction(actionTypes.SYNC_WORKSPACE, name); // // Export the actions @@ -72,7 +72,7 @@ const rebaseWorkspace = (name: string) => createAction(actionTypes.REBASE_WORKSP export const actions = { update, changeBaseWorkspace, - rebaseWorkspace + syncWorkspace }; // diff --git a/packages/neos-ui-sagas/src/Publish/index.ts b/packages/neos-ui-sagas/src/Publish/index.ts index a0e93666ae..691bf87b78 100644 --- a/packages/neos-ui-sagas/src/Publish/index.ts +++ b/packages/neos-ui-sagas/src/Publish/index.ts @@ -10,7 +10,7 @@ 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'; @@ -160,13 +160,13 @@ export function * watchChangeBaseWorkspace() { } export function * watchRebaseWorkspace() { - const {rebaseWorkspace, getWorkspaceInfo} = backend.get().endpoints; - yield takeEvery(actionTypes.CR.Workspaces.REBASE_WORKSPACE, function * change(action: ReturnType) { + const {syncWorkspace, getWorkspaceInfo} = backend.get().endpoints; + yield takeEvery(actionTypes.CR.Workspaces.SYNC_WORKSPACE, function * change(action: ReturnType) { yield put(actions.UI.Remote.startSynchronization()); try { - const feedback: FeedbackEnvelope = yield call(rebaseWorkspace, action.payload); - yield put(actions.ServerFeedback.handleServerFeedback(feedback)); + const dimensionSpacePoint: DimensionCombination = yield select(selectors.CR.ContentDimensions.active); + yield call(syncWorkspace, action.payload, false, dimensionSpacePoint); } catch (error) { console.error('Failed to sync user workspace', error); } finally { diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js index d915a8578f..f6834f0ce9 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js @@ -22,7 +22,7 @@ import {WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; }), { confirmRebase: actions.UI.SyncWorkspaceModal.apply, abortRebase: actions.UI.SyncWorkspaceModal.cancel, - rebaseWorkspace: actions.CR.Workspaces.rebaseWorkspace + syncWorkspace: actions.CR.Workspaces.syncWorkspace }) @neos(globalRegistry => ({ i18nRegistry: globalRegistry.get('i18n') @@ -34,7 +34,7 @@ export default class SyncWorkspaceDialog extends PureComponent { personalWorkspaceName: PropTypes.string.isRequired, confirmRebase: PropTypes.func.isRequired, abortRebase: PropTypes.func.isRequired, - rebaseWorkspace: PropTypes.func.isRequired + syncWorkspace: PropTypes.func.isRequired }; handleAbort = () => { @@ -44,9 +44,9 @@ export default class SyncWorkspaceDialog extends PureComponent { } handleConfirm = () => { - const {confirmRebase, rebaseWorkspace, personalWorkspaceName} = this.props; + const {confirmRebase, syncWorkspace, personalWorkspaceName} = this.props; confirmRebase(); - rebaseWorkspace(personalWorkspaceName); + syncWorkspace(personalWorkspaceName); } renderTitle() { From 2439f5a81bd7fe22bfb8fcbd2caf9c57f6e85b77 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 3 Apr 2024 20:31:59 +0200 Subject: [PATCH 04/29] TASK: Create `Syncing` partition in redux store --- .../src/CR/Syncing/index.ts | 257 ++++++++++++++++++ packages/neos-ui-redux-store/src/CR/index.ts | 14 +- 2 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 packages/neos-ui-redux-store/src/CR/Syncing/index.ts diff --git a/packages/neos-ui-redux-store/src/CR/Syncing/index.ts b/packages/neos-ui-redux-store/src/CR/Syncing/index.ts new file mode 100644 index 0000000000..406204fa2f --- /dev/null +++ b/packages/neos-ui-redux-store/src/CR/Syncing/index.ts @@ -0,0 +1,257 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {action as createAction, ActionType} from 'typesafe-actions'; + +import type {AnyError} from '@neos-project/neos-ui-error'; + +import {TypeOfChange} from '../Workspaces'; + +export enum SyncingPhase { + START, + ONGOING, + CONFLICT, + RESOLVING, + ERROR, + SUCCESS +} + +export enum ResolutionStrategy { + FORCE, + DISCARD_ALL +} + +export enum ReasonForConflict { + NODE_HAS_BEEN_DELETED +} + +export type Conflict = { + affectedNode: null | { + icon: string; + label: string; + }, + affectedSite: null | { + icon: string; + label: string; + }, + affectedDocument: null | { + icon: string; + label: string; + }, + typeOfChange: null | TypeOfChange, + reasonForConflict: null | ReasonForConflict +}; + +export type State = null | { + process: + | { phase: SyncingPhase.START } + | { phase: SyncingPhase.ONGOING } + | { + phase: SyncingPhase.CONFLICT; + conflicts: Conflict[]; + } + | { + phase: SyncingPhase.RESOLVING; + strategy: ResolutionStrategy; + conflicts: Conflict[]; + } + | { + phase: SyncingPhase.ERROR; + error: null | AnyError; + } + | { phase: SyncingPhase.SUCCESS }; +}; + +export const defaultState: State = null; + +export enum actionTypes { + STARTED = '@neos/neos-ui/CR/Syncing/STARTED', + CANCELLED = '@neos/neos-ui/CR/Syncing/CANCELLED', + CONFIRMED = '@neos/neos-ui/CR/Syncing/CONFIRMED', + CONFLICTS_DETECTED = '@neos/neos-ui/CR/Syncing/CONFLICTS_DETECTED', + RESOLUTION_STARTED = '@neos/neos-ui/CR/Syncing/RESOLUTION_STARTED', + RESOLUTION_CANCELLED = '@neos/neos-ui/CR/Syncing/RESOLUTION_CANCELLED', + RESOLUTION_CONFIRMED = '@neos/neos-ui/CR/Syncing/RESOLUTION_CONFIRMED', + FAILED = '@neos/neos-ui/CR/Syncing/FAILED', + RETRIED = '@neos/neos-ui/CR/Syncing/RETRIED', + SUCEEDED = '@neos/neos-ui/CR/Syncing/SUCEEDED', + ACKNOWLEDGED = '@neos/neos-ui/CR/Syncing/ACKNOWLEDGED', + FINISHED = '@neos/neos-ui/CR/Syncing/FINISHED' +} + +/** + * Initiates the process of syncing (rebasing) the workspace + */ +const start = () => createAction(actionTypes.STARTED); + +/** + * Cancel the ongoing syncing (rebasing) workflow + */ +const cancel = () => createAction(actionTypes.CANCELLED); + +/** + * Confirm the ongoing syncing (rebasing) workflow + */ +const confirm = () => createAction(actionTypes.CONFIRMED); + +/** + * Signal that conflicts occurred during the ongoing syncing (rebasing) workflow + */ +const resolve = (conflicts: Conflict[]) => + createAction(actionTypes.CONFLICTS_DETECTED, {conflicts}); + +/** + * Initiates the process of resolving a conflict that occurred + * during the ongoing syncing (rebasing) workflow + */ +const selectResolutionStrategy = (strategy: ResolutionStrategy) => + createAction(actionTypes.RESOLUTION_STARTED, {strategy}); + +/** + * Cancel the ongoing resolution workflow + */ +const cancelResolution = () => createAction(actionTypes.RESOLUTION_CANCELLED); + +/** + * Confirm the ongoing resolution workflow + */ +const confirmResolution = () => createAction(actionTypes.RESOLUTION_CONFIRMED); + +/** + * Signal that the ongoing syncing (rebasing) workflow has failed + */ +const fail = (error: null | AnyError) => + createAction(actionTypes.FAILED, {error}); + +/** + * Attempt to retry a failed syncing (rebasing) workflow + */ +const retry = () => createAction(actionTypes.RETRIED); + +/** + * Signal that the ongoing syncing (rebasing) workflow succeeded + */ +const succeed = () => createAction(actionTypes.SUCEEDED); + +/** + * Acknowledge that the syncing (rebasing) operation is finished + */ +const acknowledge = () => createAction(actionTypes.ACKNOWLEDGED); + +/** + * Finish the ongoing syncing (rebasing) workflow + */ +const finish = () => createAction(actionTypes.FINISHED); + +// +// Export the actions +// +export const actions = { + start, + cancel, + confirm, + resolve, + selectResolutionStrategy, + cancelResolution, + confirmResolution, + fail, + retry, + succeed, + acknowledge, + finish +}; + +export type Action = ActionType; + +// +// Export the reducer +// +export const reducer = (state: State = defaultState, action: Action): State => { + if (state === null) { + if (action.type === actionTypes.STARTED) { + return { + process: { + phase: SyncingPhase.START + } + }; + } + + return null; + } + + switch (action.type) { + case actionTypes.CANCELLED: + return null; + case actionTypes.CONFIRMED: + return { + process: { + phase: SyncingPhase.ONGOING + } + }; + case actionTypes.CONFLICTS_DETECTED: + return { + process: { + phase: SyncingPhase.CONFLICT, + conflicts: action.payload.conflicts + } + }; + case actionTypes.RESOLUTION_STARTED: + if (state.process.phase === SyncingPhase.CONFLICT) { + return { + process: { + ...state.process, + phase: SyncingPhase.RESOLVING, + strategy: action.payload.strategy + } + }; + } + return state; + case actionTypes.RESOLUTION_CANCELLED: + if (state.process.phase === SyncingPhase.RESOLVING) { + return { + process: { + ...state.process, + phase: SyncingPhase.CONFLICT + } + }; + } + return state; + case actionTypes.RESOLUTION_CONFIRMED: + return { + process: { + phase: SyncingPhase.ONGOING + } + }; + case actionTypes.FAILED: + return { + process: { + phase: SyncingPhase.ERROR, + error: action.payload.error + } + }; + case actionTypes.RETRIED: + return { + process: { + phase: SyncingPhase.ONGOING + } + }; + case actionTypes.SUCEEDED: + return { + process: { + phase: SyncingPhase.SUCCESS + } + }; + case actionTypes.FINISHED: + return null; + default: + return state; + } +}; + +export const selectors = {}; diff --git a/packages/neos-ui-redux-store/src/CR/index.ts b/packages/neos-ui-redux-store/src/CR/index.ts index 9a1e81342b..c9fd2e61b5 100644 --- a/packages/neos-ui-redux-store/src/CR/index.ts +++ b/packages/neos-ui-redux-store/src/CR/index.ts @@ -4,6 +4,7 @@ import * as ContentDimensions from './ContentDimensions'; import * as Nodes from './Nodes'; import * as Workspaces from './Workspaces'; import * as Publishing from './Publishing'; +import * as Syncing from './Syncing'; // // Export the subreducer state shape interface @@ -13,6 +14,7 @@ export interface State { nodes: Nodes.State; workspaces: Workspaces.State; publishing: Publishing.State; + syncing: Syncing.State; } // @@ -22,7 +24,8 @@ export const actionTypes = { ContentDimensions: ContentDimensions.actionTypes, Nodes: Nodes.actionTypes, Workspaces: Workspaces.actionTypes, - Publishing: Publishing.actionTypes + Publishing: Publishing.actionTypes, + Syncing: Syncing.actionTypes } as const; // @@ -32,7 +35,8 @@ export const actions = { ContentDimensions: ContentDimensions.actions, Nodes: Nodes.actions, Workspaces: Workspaces.actions, - Publishing: Publishing.actions + Publishing: Publishing.actions, + Syncing: Syncing.actions } as const; // @@ -42,7 +46,8 @@ export const reducer = combineReducers({ contentDimensions: ContentDimensions.reducer, nodes: Nodes.reducer, workspaces: Workspaces.reducer, - publishing: Publishing.reducer + publishing: Publishing.reducer, + syncing: Syncing.reducer } as any); // TODO: when we update redux, this shouldn't be necessary https://github.com/reduxjs/redux/issues/2709#issuecomment-357328709 // @@ -52,5 +57,6 @@ export const selectors = { ContentDimensions: ContentDimensions.selectors, Nodes: Nodes.selectors, Workspaces: Workspaces.selectors, - Publishing: Publishing.selectors + Publishing: Publishing.selectors, + Syncing: Syncing.selectors } as const; From 3cb387126b96821afc8acca23529c62f36edec62 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 3 Apr 2024 20:40:50 +0200 Subject: [PATCH 05/29] TASK: Create `Sync` saga --- packages/neos-ui-sagas/src/Publish/index.ts | 21 +--- packages/neos-ui-sagas/src/Sync/index.ts | 124 ++++++++++++++++++++ packages/neos-ui-sagas/src/index.js | 1 + packages/neos-ui-sagas/src/manifest.js | 4 +- 4 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 packages/neos-ui-sagas/src/Sync/index.ts diff --git a/packages/neos-ui-sagas/src/Publish/index.ts b/packages/neos-ui-sagas/src/Publish/index.ts index 691bf87b78..a6d4e923e8 100644 --- a/packages/neos-ui-sagas/src/Publish/index.ts +++ b/packages/neos-ui-sagas/src/Publish/index.ts @@ -10,12 +10,11 @@ import {put, call, select, takeEvery, take, race, all} from 'redux-saga/effects'; import {AnyError} from '@neos-project/neos-ui-error'; -import {DimensionCombination, NodeContextPath, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {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 {WorkspaceInformation} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; import backend, {Routes} from '@neos-project/neos-ui-backend-connector'; import {makeReloadNodes} from '../CR/NodeOperations/reloadNodes'; @@ -158,21 +157,3 @@ export function * watchChangeBaseWorkspace() { } }); } - -export function * watchRebaseWorkspace() { - const {syncWorkspace, getWorkspaceInfo} = backend.get().endpoints; - yield takeEvery(actionTypes.CR.Workspaces.SYNC_WORKSPACE, function * change(action: ReturnType) { - yield put(actions.UI.Remote.startSynchronization()); - - try { - const dimensionSpacePoint: DimensionCombination = yield select(selectors.CR.ContentDimensions.active); - yield call(syncWorkspace, action.payload, false, dimensionSpacePoint); - } catch (error) { - console.error('Failed to sync user workspace', error); - } finally { - const workspaceInfo: WorkspaceInformation = yield call(getWorkspaceInfo); - yield put(actions.CR.Workspaces.update(workspaceInfo)); - yield put(actions.UI.Remote.finishSynchronization()); - } - }); -} diff --git a/packages/neos-ui-sagas/src/Sync/index.ts b/packages/neos-ui-sagas/src/Sync/index.ts new file mode 100644 index 0000000000..3673857d88 --- /dev/null +++ b/packages/neos-ui-sagas/src/Sync/index.ts @@ -0,0 +1,124 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import {put, call, takeEvery, race, take, select} from 'redux-saga/effects'; + +import {DimensionCombination, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {AnyError} from '@neos-project/neos-ui-error'; +import {actionTypes, actions, selectors} from '@neos-project/neos-ui-redux-store'; +import backend from '@neos-project/neos-ui-backend-connector'; +import {Conflict, ResolutionStrategy} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; + +// @TODO: This is a helper to gain type access to the available backend endpoints. +// It shouldn't be necessary to do this, and this hack should be removed once a +// better type API is available +import {default as Endpoints} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; +type Endpoints = ReturnType; + +export function * watchSyncing() { + yield takeEvery(actionTypes.CR.Syncing.STARTED, function * change() { + if (yield * waitForConfirmation()) { + do { + yield * syncPersonalWorkspace(false); + } while (yield * waitForRetry()); + + yield put(actions.CR.Syncing.finish()); + } + }); +} + +function * waitForConfirmation() { + const {confirmed}: { + cancelled: null | ReturnType; + confirmed: null | ReturnType; + } = yield race({ + cancelled: take(actionTypes.CR.Syncing.CANCELLED), + confirmed: take(actionTypes.CR.Syncing.CONFIRMED) + }); + + return Boolean(confirmed); +} + +type SyncWorkspaceResult = + | { success: true } + | { conflicts: Conflict[] } + | { error: AnyError }; + +function * syncPersonalWorkspace(force: boolean) { + const {syncWorkspace} = backend.get().endpoints as Endpoints; + const personalWorkspaceName: WorkspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); + const dimensionSpacePoint: null|DimensionCombination = yield select(selectors.CR.ContentDimensions.active); + + try { + const result: SyncWorkspaceResult = yield call(syncWorkspace, personalWorkspaceName, force, dimensionSpacePoint); + if ('success' in result) { + yield put(actions.CR.Syncing.succeed()); + } else if ('conflicts' in result) { + yield * resolveConflicts(result.conflicts); + } else { + yield put(actions.CR.Syncing.fail(result.error)); + } + } catch (error) { + yield put(actions.CR.Syncing.fail(error as AnyError)); + } finally { + yield * refreshAfterSyncing(); + } +} + +function * resolveConflicts(conflicts: Conflict[]): any { + yield put(actions.CR.Syncing.resolve(conflicts)); + + const {payload: {strategy}}: ReturnType< + typeof actions.CR.Syncing.selectResolutionStrategy + > = yield take(actionTypes.CR.Syncing.RESOLUTION_STARTED); + + if (yield * waitForResolutionConfirmation()) { + if (strategy === ResolutionStrategy.FORCE) { + yield * syncPersonalWorkspace(true); + } else if (strategy === ResolutionStrategy.DISCARD_ALL) { + yield * discardAll(); + } + } +} + +function * waitForResolutionConfirmation() { + const {confirmed}: { + cancelled: null | ReturnType; + confirmed: null | ReturnType; + } = yield race({ + cancelled: take(actionTypes.CR.Syncing.RESOLUTION_CANCELLED), + confirmed: take(actionTypes.CR.Syncing.RESOLUTION_CONFIRMED) + }); + + return Boolean(confirmed); +} + +function * waitForRetry() { + const {retried}: { + acknowledged: null | ReturnType; + retried: null | ReturnType; + } = yield race({ + acknowledged: take(actionTypes.CR.Syncing.ACKNOWLEDGED), + retried: take(actionTypes.CR.Syncing.RETRIED) + }); + + return Boolean(retried); +} + +function * discardAll() { + yield console.log('@TODO: Discard All'); +} + +function * refreshAfterSyncing() { + yield console.log('@TODO: Refresh after Sync'); + // const {getWorkspaceInfo} = backend.get().endpoints as Endpoints; + // const workspaceInfo = yield call(getWorkspaceInfo); + // yield put(actions.CR.Workspaces.update(workspaceInfo)); + // yield put(actions.UI.Remote.finishSynchronization()); +} diff --git a/packages/neos-ui-sagas/src/index.js b/packages/neos-ui-sagas/src/index.js index 8c1365a01b..c0a6392b6b 100644 --- a/packages/neos-ui-sagas/src/index.js +++ b/packages/neos-ui-sagas/src/index.js @@ -12,3 +12,4 @@ export * as uiInspector from './UI/Inspector/index'; export * as uiPageTree from './UI/PageTree/index'; export * as uiHotkeys from './UI/Hotkeys/index'; export * as impersonate from './UI/Impersonate/index'; +export * as sync from './Sync/index'; diff --git a/packages/neos-ui-sagas/src/manifest.js b/packages/neos-ui-sagas/src/manifest.js index 97f17ce9c8..ac9db2832d 100644 --- a/packages/neos-ui-sagas/src/manifest.js +++ b/packages/neos-ui-sagas/src/manifest.js @@ -8,6 +8,7 @@ import { crNodeOperations, crPolicies, publish, + sync, serverFeedback, uiContentCanvas, uiContentTree, @@ -48,10 +49,11 @@ manifest('main.sagas', {}, globalRegistry => { sagasRegistry.set('neos-ui/CR/Policies/watchNodeInformationChanges', {saga: crPolicies.watchNodeInformationChanges}); - sagasRegistry.set('neos-ui/Publish/watchRebaseWorkspace', {saga: publish.watchRebaseWorkspace}); sagasRegistry.set('neos-ui/Publish/watchChangeBaseWorkspace', {saga: publish.watchChangeBaseWorkspace}); sagasRegistry.set('neos-ui/Publish/discardIfConfirmed', {saga: publish.watchPublishing}); + sagasRegistry.set('neos-ui/Sync/watchSync', {saga: sync.watchSyncing}); + sagasRegistry.set('neos-ui/ServerFeedback/watchServerFeedback', {saga: serverFeedback.watchServerFeedback}); sagasRegistry.set('neos-ui/UI/ContentCanvas/watchCanvasUpdateToChangeTitle', {saga: uiContentCanvas.watchCanvasUpdateToChangeTitle}); From 74ba34be1a9b732a91c43b27bf565fbbc3bd895b Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 28 Mar 2024 15:42:14 +0100 Subject: [PATCH 06/29] TASK: Convert WorkspaceSync container to typescript --- .../Modals/SyncWorkspaceDialog/index.js | 2 +- .../WorkspaceSync/WorkspaceSync.tsx | 81 +++++++++++++++++++ .../WorkspaceSync/WorkspaceSyncIcon.js | 35 -------- .../WorkspaceSync/WorkspaceSyncIcon.tsx | 45 +++++++++++ .../PrimaryToolbar/WorkspaceSync/index.js | 73 ----------------- .../PrimaryToolbar/WorkspaceSync/index.ts | 11 +++ packages/neos-ui/src/manifest.containers.js | 2 +- 7 files changed, 139 insertions(+), 110 deletions(-) create mode 100644 packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx delete mode 100644 packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js create mode 100644 packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.tsx delete mode 100644 packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.js create mode 100644 packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.ts diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js index f6834f0ce9..9602271be7 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import Button from '@neos-project/react-ui-components/src/Button/'; import Dialog from '@neos-project/react-ui-components/src/Dialog/'; import Icon from '@neos-project/react-ui-components/src/Icon/'; -import WorkspaceSyncIcon from '../../PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon'; +import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon'; const {personalWorkspaceNameSelector, personalWorkspaceRebaseStatusSelector} = selectors.CR.Workspaces; diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx new file mode 100644 index 0000000000..219d02c4d3 --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx @@ -0,0 +1,81 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +// @ts-ignore +import {connect} from 'react-redux'; + +import {actions, selectors} from '@neos-project/neos-ui-redux-store'; +import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; +import {neos} from '@neos-project/neos-ui-decorators'; +import {I18nRegistry, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import {Button} from '@neos-project/react-ui-components'; + +import {WorkspaceSyncIcon} from './WorkspaceSyncIcon'; +import style from './style.module.css'; + +type WorkspaceSyncPropsFromReduxState = { + personalWorkspaceStatus: WorkspaceStatus; +}; + +type WorkspaceSyncHandlers = { + startSyncing: () => void; +}; + +const withReduxState = connect((state: GlobalState): WorkspaceSyncPropsFromReduxState => ({ + personalWorkspaceStatus: selectors.CR.Workspaces + .personalWorkspaceRebaseStatusSelector(state) +}), { + startSyncing: actions.CR.Syncing.start +}); + +type WorkspaceSyncPropsFromNeosGlobals = { + i18nRegistry: I18nRegistry; +}; + +const withNeosGlobals = neos((globalRegistry): WorkspaceSyncPropsFromNeosGlobals => ({ + i18nRegistry: globalRegistry.get('i18n') +})); + +type WorkspaceSyncProps = + & WorkspaceSyncPropsFromReduxState + & WorkspaceSyncPropsFromNeosGlobals + & WorkspaceSyncHandlers; + +const WorkspaceSync: React.FC = (props) => { + const handleClick = React.useCallback(() => { + props.startSyncing(); + }, []); + + if (props.personalWorkspaceStatus === WorkspaceStatus.UP_TO_DATE) { + return null; + } + + const buttonTitle = props.i18nRegistry.translate( + 'syncPersonalWorkSpace', + 'Synchronize personal workspace', {}, 'Neos.Neos.Ui', 'Main'); + return ( +
+ +
+ ); +}; + +export default withReduxState(withNeosGlobals(WorkspaceSync as any)); diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js deleted file mode 100644 index 23a67b13d0..0000000000 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable complexity */ -import React from 'react'; -import Icon from '@neos-project/react-ui-components/src/Icon/'; -import style from './style.module.css'; -import mergeClassNames from 'classnames'; -import {WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; - -const WorkspaceSyncIcon = ({personalWorkspaceStatus, onDarkBackground}) => { - const iconBadgeClassNames = mergeClassNames({ - [style.badgeIconBackground]: typeof onDarkBackground === 'undefined' || onDarkBackground === false, - [style.badgeIconOnDarkBackground]: onDarkBackground === true, - 'fa-layers-counter': true, - 'fa-layers-bottom-right': true, - 'fa-2x': true - }); - const iconLayerClassNames = mergeClassNames({ - [style.iconLayer]: true, - 'fa-layers': true, - 'fa-fw': true - }); - const iconBadge = personalWorkspaceStatus === WorkspaceStatus.OUTDATED_CONFLICT ? ( - - - - ) : null - - return ( - - - {iconBadge} - - ) -} - -export default WorkspaceSyncIcon; diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.tsx b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.tsx new file mode 100644 index 0000000000..f8ccb971ff --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.tsx @@ -0,0 +1,45 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import cx from 'classnames'; + +import {Icon} from '@neos-project/react-ui-components'; + +import style from './style.module.css'; + +export const WorkspaceSyncIcon: React.FC<{ + hasProblem?: boolean; + onDarkBackground?: boolean; +}> = ({hasProblem, onDarkBackground}) => { + const iconBadgeClassNames = cx({ + [style.badgeIconBackground]: typeof onDarkBackground === 'undefined' || onDarkBackground === false, + [style.badgeIconOnDarkBackground]: onDarkBackground === true, + 'fa-layers-counter': true, + 'fa-layers-bottom-right': true, + 'fa-2x': true + }); + const iconLayerClassNames = cx({ + [style.iconLayer]: true, + 'fa-layers': true, + 'fa-fw': true + }); + const iconBadge = hasProblem ? ( + + + + ) : null + + return ( + + + {iconBadge} + + ) +} diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.js b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.js deleted file mode 100644 index d3703d2d00..0000000000 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.js +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable complexity */ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; - -import {actions, selectors} from '@neos-project/neos-ui-redux-store'; -const {personalWorkspaceRebaseStatusSelector} = selectors.CR.Workspaces; -import {neos} from '@neos-project/neos-ui-decorators'; -import {WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; - -import Icon from '@neos-project/react-ui-components/src/Icon/'; -import Button from '@neos-project/react-ui-components/src/Button/'; -import WorkspaceSyncIcon from './WorkspaceSyncIcon'; - -import style from './style.module.css'; - -@connect(state => ({ - isOpen: state?.ui?.SyncWorkspaceModal?.isOpen, - isSyncing: state?.ui?.remote?.isSyncing, - personalWorkspaceStatus: personalWorkspaceRebaseStatusSelector(state) -}), { - openModal: actions.UI.SyncWorkspaceModal.open -}) -@neos(globalRegistry => ({ - i18nRegistry: globalRegistry.get('i18n') -})) - -export default class WorkspaceSync extends PureComponent { - static propTypes = { - isOpen: PropTypes.bool.isRequired, - isSyncing: PropTypes.bool.isRequired, - openModal: PropTypes.func.isRequired, - personalWorkspaceStatus: PropTypes.string.isRequired, - i18nRegistry: PropTypes.object.isRequired - }; - - render() { - const { - personalWorkspaceStatus, - openModal, - isOpen, - isSyncing, - i18nRegistry - } = this.props; - - if (personalWorkspaceStatus === WorkspaceStatus.UP_TO_DATE) { - return (null); - } - - const buttonLabel = i18nRegistry.translate( - 'syncPersonalWorkSpace', - 'Synchronize personal workspace', {}, 'Neos.Neos.Ui', 'Main'); - return ( -
- -
- ); - } -} diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.ts b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.ts new file mode 100644 index 0000000000..0c4a6f6a2f --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.ts @@ -0,0 +1,11 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {default as WorkspaceSync} from './WorkspaceSync'; +export {WorkspaceSyncIcon} from './WorkspaceSyncIcon'; diff --git a/packages/neos-ui/src/manifest.containers.js b/packages/neos-ui/src/manifest.containers.js index 16a3dceacb..7d9d067ed7 100644 --- a/packages/neos-ui/src/manifest.containers.js +++ b/packages/neos-ui/src/manifest.containers.js @@ -17,7 +17,7 @@ import SyncWorkspaceDialog from './Containers/Modals/SyncWorkspaceDialog/index'; import PrimaryToolbar from './Containers/PrimaryToolbar/index'; import DimensionSwitcher from './Containers/PrimaryToolbar/DimensionSwitcher/index'; import PublishDropDown from './Containers/PrimaryToolbar/PublishDropDown/index'; -import WorkspaceSync from './Containers/PrimaryToolbar/WorkspaceSync/index'; +import {WorkspaceSync} from './Containers/PrimaryToolbar/WorkspaceSync/index'; import MenuToggler from './Containers/PrimaryToolbar/MenuToggler/index'; import Brand from './Containers/PrimaryToolbar/Brand/index'; import EditPreviewDropDown from './Containers/PrimaryToolbar/EditPreviewDropDown/index'; From 46f1bfb881d5a4610ce979640528b101d495616d Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 28 Mar 2024 15:43:20 +0100 Subject: [PATCH 07/29] TASK: Convert SyncWorkspaceDialog container to typescript --- .../SyncWorkspaceDialog.tsx | 159 ++++++++++++++++++ .../Modals/SyncWorkspaceDialog/index.js | 148 ---------------- .../Modals/SyncWorkspaceDialog/index.ts | 10 ++ packages/neos-ui/src/manifest.containers.js | 2 +- 4 files changed, 170 insertions(+), 149 deletions(-) create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx delete mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.ts diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx new file mode 100644 index 0000000000..c5153bbbc7 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -0,0 +1,159 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +// @ts-ignore +import {connect} from 'react-redux'; + +import {neos} from '@neos-project/neos-ui-decorators'; +import {selectors, actions} from '@neos-project/neos-ui-redux-store'; +import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; +import {I18nRegistry, WorkspaceName, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import I18n from '@neos-project/neos-ui-i18n'; +import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; + +import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync'; + +import style from './style.module.css'; + +const LABELS_BY_WORKSPACE_STATUS = { + [WorkspaceStatus.UP_TO_DATE]: { + dialogMessage: { + id: 'Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessage', + fallback: + 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + + 'The changes lead to an error state. Please contact your administrator to resolve the problem.' + } + }, + [WorkspaceStatus.OUTDATED]: { + dialogMessage: { + id: 'Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessageOutdated', + fallback: + 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + + 'You should synchronize your personal workspace to avoid conflicts.' + } + }, + [WorkspaceStatus.OUTDATED_CONFLICT]: { + dialogMessage: { + id: 'Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessageOutdatedConflict', + fallback: + 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + + 'The changes lead to an error state. Please contact your administrator to resolve the problem.' + } + } +} + +type SyncWorkspaceDialogPropsFromReduxState = { + isOpen: boolean; + personalWorkspaceStatus: WorkspaceStatus; + personalWorkspaceName: WorkspaceName; +}; + +const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ + isOpen: Boolean(state?.cr?.syncing), + personalWorkspaceStatus: (selectors as any).CR.Workspaces + .personalWorkspaceRebaseStatusSelector(state), + personalWorkspaceName: (selectors as any).CR.Workspaces + .personalWorkspaceNameSelector(state) +}), { + confirmRebase: actions.CR.Syncing.confirm, + abortRebase: actions.CR.Syncing.cancel +}); + +type SyncWorkspaceDialogPropsFromNeosGlobals = { + i18nRegistry: I18nRegistry; +}; + +const withNeosGlobals = neos((globalRegistry): SyncWorkspaceDialogPropsFromNeosGlobals => ({ + i18nRegistry: globalRegistry.get('i18n') +})); + +type SyncWorkspaceDialogHandlers = { + confirmRebase: () => void; + abortRebase: () => void; +}; + +type SyncWorkspaceDialogProps = + & SyncWorkspaceDialogPropsFromReduxState + & SyncWorkspaceDialogPropsFromNeosGlobals + & SyncWorkspaceDialogHandlers; + +const SyncWorkspaceDialog: React.FC = (props) => { + const handleAbort = React.useCallback(() => { + props.abortRebase(); + }, []); + const handleConfirm = React.useCallback(() => { + props.confirmRebase(); + }, []); + + if (!props.isOpen) { + return null; + } + + return ( + + + , + props.personalWorkspaceStatus === WorkspaceStatus.OUTDATED ? ( + + ) : null + ]} + title={ +
+ + + + +
+ } + onRequestClose={handleAbort} + type={props.personalWorkspaceStatus === WorkspaceStatus.OUTDATED ? 'warn' : 'error'} + isOpen + autoFocus + theme={undefined as any} + style={undefined as any} + > +
+

+ +

+
+
+ ); +}; + +export default withReduxState(withNeosGlobals(SyncWorkspaceDialog as any)); diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js deleted file mode 100644 index 9602271be7..0000000000 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js +++ /dev/null @@ -1,148 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; - -import Button from '@neos-project/react-ui-components/src/Button/'; -import Dialog from '@neos-project/react-ui-components/src/Dialog/'; -import Icon from '@neos-project/react-ui-components/src/Icon/'; -import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon'; - -const {personalWorkspaceNameSelector, personalWorkspaceRebaseStatusSelector} = selectors.CR.Workspaces; - -import {selectors, actions} from '@neos-project/neos-ui-redux-store'; -import {neos} from '@neos-project/neos-ui-decorators'; - -import style from './style.module.css'; -import {WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; - -@connect(state => ({ - isOpen: state?.ui?.SyncWorkspaceModal?.isOpen, - personalWorkspaceStatus: personalWorkspaceRebaseStatusSelector(state), - personalWorkspaceName: personalWorkspaceNameSelector(state) -}), { - confirmRebase: actions.UI.SyncWorkspaceModal.apply, - abortRebase: actions.UI.SyncWorkspaceModal.cancel, - syncWorkspace: actions.CR.Workspaces.syncWorkspace -}) -@neos(globalRegistry => ({ - i18nRegistry: globalRegistry.get('i18n') -})) -export default class SyncWorkspaceDialog extends PureComponent { - static propTypes = { - isOpen: PropTypes.bool.isRequired, - personalWorkspaceStatus: PropTypes.string.isRequired, - personalWorkspaceName: PropTypes.string.isRequired, - confirmRebase: PropTypes.func.isRequired, - abortRebase: PropTypes.func.isRequired, - syncWorkspace: PropTypes.func.isRequired - }; - - handleAbort = () => { - const {abortRebase} = this.props; - - abortRebase(); - } - - handleConfirm = () => { - const {confirmRebase, syncWorkspace, personalWorkspaceName} = this.props; - confirmRebase(); - syncWorkspace(personalWorkspaceName); - } - - renderTitle() { - const {i18nRegistry} = this.props; - - const synchronizeWorkspaceLabel = i18nRegistry.translate( - 'syncPersonalWorkSpace', - 'Synchronize personal workspace', {}, 'Neos.Neos.Ui', 'Main') - return ( -
- - - {synchronizeWorkspaceLabel} - -
- ); - } - - renderAbort() { - const abortLabel = this.props.i18nRegistry.translate('cancel', 'Cancel') - return ( - - ); - } - - renderConfirm() { - const {i18nRegistry, personalWorkspaceStatus} = this.props; - const confirmationLabel = i18nRegistry.translate( - 'syncPersonalWorkSpaceConfirm', - 'Synchronize now', {}, 'Neos.Neos.Ui', 'Main') - if (personalWorkspaceStatus !== WorkspaceStatus.OUTDATED) { - return (null); - } - return ( - - ); - } - - render() { - const {isOpen, personalWorkspaceStatus} = this.props; - if (!isOpen) { - return null; - } - const dialogMessage = this.getTranslatedContent(); - return ( - -
-

{dialogMessage}

-
-
- ); - } - - getTranslatedContent() { - const {personalWorkspaceStatus, i18nRegistry} = this.props; - if (personalWorkspaceStatus === WorkspaceStatus.OUTDATED) { - return i18nRegistry.translate( - 'syncPersonalWorkSpaceMessageOutdated', - 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + - 'You should synchronize your personal workspace to avoid conflicts.', {}, 'Neos.Neos.Ui', 'Main') - } - if (personalWorkspaceStatus === WorkspaceStatus.OUTDATED_CONFLICT) { - return i18nRegistry.translate( - 'syncPersonalWorkSpaceMessageOutdatedConflict', - 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + - 'The changes lead to an error state. Please contact your administrator to resolve the problem.', {}, 'Neos.Neos.Ui', 'Main') - } - return i18nRegistry.translate( - 'syncPersonalWorkSpaceMessage', - 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + - 'The changes lead to an error state. Please contact your administrator to resolve the problem.', {}, 'Neos.Neos.Ui', 'Main') - } -} diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.ts b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.ts new file mode 100644 index 0000000000..2fb12429ca --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.ts @@ -0,0 +1,10 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +export {default as SyncWorkspaceDialog} from './SyncWorkspaceDialog'; diff --git a/packages/neos-ui/src/manifest.containers.js b/packages/neos-ui/src/manifest.containers.js index 7d9d067ed7..f436041246 100644 --- a/packages/neos-ui/src/manifest.containers.js +++ b/packages/neos-ui/src/manifest.containers.js @@ -12,7 +12,7 @@ import {PublishingDialog} from './Containers/Modals/PublishingDialog/index'; import ReloginDialog from './Containers/Modals/ReloginDialog/index'; import KeyboardShortcutModal from './Containers/Modals/KeyboardShortcutModal/index'; import UnappliedChangesDialog from './Containers/Modals/UnappliedChangesDialog/index'; -import SyncWorkspaceDialog from './Containers/Modals/SyncWorkspaceDialog/index'; +import {SyncWorkspaceDialog} from './Containers/Modals/SyncWorkspaceDialog/index'; import PrimaryToolbar from './Containers/PrimaryToolbar/index'; import DimensionSwitcher from './Containers/PrimaryToolbar/DimensionSwitcher/index'; From f834747487476f59095eb0d3c1a7d62b6e2e89cb Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 28 Mar 2024 16:26:51 +0100 Subject: [PATCH 08/29] TASK: Split out `ConfirmationDialog` component from `SyncWorkspaceDialog` --- Configuration/Settings.yaml | 1 + .../Translations/en/SyncWorkspaceDialog.xlf | 19 +++ .../ConfirmationDialog.tsx | 89 +++++++++++++ .../Modals/SyncWorkspaceDialog/Diagram.tsx | 94 ++++++++++++++ .../SyncWorkspaceDialog.tsx | 121 +++--------------- .../SyncWorkspaceDialog/style.module.css | 75 ++++++++--- 6 files changed, 275 insertions(+), 124 deletions(-) create mode 100644 Resources/Private/Translations/en/SyncWorkspaceDialog.xlf create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConfirmationDialog.tsx create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/Diagram.tsx diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index 551a72992d..3d01d7d861 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -27,6 +27,7 @@ Neos: - Error - Main - PublishingDialog + - SyncWorkspaceDialog fusion: autoInclude: Neos.Neos.Ui: true diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf new file mode 100644 index 0000000000..9b57bc40fa --- /dev/null +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -0,0 +1,19 @@ + + + + + + Synchronize workspace "{workspaceName}" with "{baseWorkspaceName}" + + + Workspace "{baseWorkspaceName}" has been modified. You need to synchronize your workspace "{workspaceName}" with it in order to see those changes and avoid conflicts. Do you wish to proceed? + + + No, cancel + + + Yes, synchronize now + + + + diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConfirmationDialog.tsx new file mode 100644 index 0000000000..36ec8a8f8d --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConfirmationDialog.tsx @@ -0,0 +1,89 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import I18n from '@neos-project/neos-ui-i18n'; +import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; +import {SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; + +import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync'; + +import {Diagram} from './Diagram'; +import style from './style.module.css'; + +export const ConfirmationDialog: React.FC<{ + workspaceName: WorkspaceName; + baseWorkspaceName: WorkspaceName; + onCancel: () => void; + onConfirm: () => void; +}> = (props) => { + return ( + + + , + + ]} + title={ +
+ + +
+ } + onRequestClose={props.onCancel} + type="warn" + isOpen + autoFocus + theme={undefined as any} + style={undefined as any} + > +
+ + +
+
+ ); +} diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/Diagram.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/Diagram.tsx new file mode 100644 index 0000000000..6250bc99a4 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/Diagram.tsx @@ -0,0 +1,94 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; +import cx from 'classnames'; + +import {Icon} from '@neos-project/react-ui-components'; +import {SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; + +import style from './style.module.css'; + +export const Diagram: React.FC<{ + phase: SyncingPhase; + workspaceName: string; + baseWorkspaceName: string; +}> = (props) => { + return ( +
+ + + +
+ ); +}; + +const WorkspaceIcon: React.FC<{ + workspaceName: string; +}> = (props) => { + return ( +
+ + + {props.workspaceName} + +
+ ); +}; + +const Process: React.FC<{ + phase: SyncingPhase; +}> = (props) => { + if (props.phase === SyncingPhase.CONFLICT) { + return ( + + ); + } + + if (props.phase === SyncingPhase.ERROR) { + return ( + + ); + } + + if (props.phase === SyncingPhase.SUCCESS) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index c5153bbbc7..1659927f04 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -11,69 +11,29 @@ import React from 'react'; // @ts-ignore import {connect} from 'react-redux'; -import {neos} from '@neos-project/neos-ui-decorators'; import {selectors, actions} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; -import {I18nRegistry, WorkspaceName, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; -import I18n from '@neos-project/neos-ui-i18n'; -import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; -import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync'; - -import style from './style.module.css'; - -const LABELS_BY_WORKSPACE_STATUS = { - [WorkspaceStatus.UP_TO_DATE]: { - dialogMessage: { - id: 'Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessage', - fallback: - 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + - 'The changes lead to an error state. Please contact your administrator to resolve the problem.' - } - }, - [WorkspaceStatus.OUTDATED]: { - dialogMessage: { - id: 'Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessageOutdated', - fallback: - 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + - 'You should synchronize your personal workspace to avoid conflicts.' - } - }, - [WorkspaceStatus.OUTDATED_CONFLICT]: { - dialogMessage: { - id: 'Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessageOutdatedConflict', - fallback: - 'It seems like there are changes in the workspace that are not reflected in your personal workspace.\n' + - 'The changes lead to an error state. Please contact your administrator to resolve the problem.' - } - } -} +import {ConfirmationDialog} from './ConfirmationDialog'; type SyncWorkspaceDialogPropsFromReduxState = { isOpen: boolean; - personalWorkspaceStatus: WorkspaceStatus; personalWorkspaceName: WorkspaceName; + baseWorkspaceName: WorkspaceName; }; const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ - isOpen: Boolean(state?.cr?.syncing), - personalWorkspaceStatus: (selectors as any).CR.Workspaces - .personalWorkspaceRebaseStatusSelector(state), + isOpen: true, personalWorkspaceName: (selectors as any).CR.Workspaces - .personalWorkspaceNameSelector(state) + .personalWorkspaceNameSelector(state), + baseWorkspaceName: (selectors as any).CR.Workspaces + .baseWorkspaceSelector(state) }), { confirmRebase: actions.CR.Syncing.confirm, abortRebase: actions.CR.Syncing.cancel }); -type SyncWorkspaceDialogPropsFromNeosGlobals = { - i18nRegistry: I18nRegistry; -}; - -const withNeosGlobals = neos((globalRegistry): SyncWorkspaceDialogPropsFromNeosGlobals => ({ - i18nRegistry: globalRegistry.get('i18n') -})); - type SyncWorkspaceDialogHandlers = { confirmRebase: () => void; abortRebase: () => void; @@ -81,11 +41,10 @@ type SyncWorkspaceDialogHandlers = { type SyncWorkspaceDialogProps = & SyncWorkspaceDialogPropsFromReduxState - & SyncWorkspaceDialogPropsFromNeosGlobals & SyncWorkspaceDialogHandlers; const SyncWorkspaceDialog: React.FC = (props) => { - const handleAbort = React.useCallback(() => { + const handleCancel = React.useCallback(() => { props.abortRebase(); }, []); const handleConfirm = React.useCallback(() => { @@ -97,63 +56,13 @@ const SyncWorkspaceDialog: React.FC = (props) => { } return ( - - - , - props.personalWorkspaceStatus === WorkspaceStatus.OUTDATED ? ( - - ) : null - ]} - title={ -
- - - - -
- } - onRequestClose={handleAbort} - type={props.personalWorkspaceStatus === WorkspaceStatus.OUTDATED ? 'warn' : 'error'} - isOpen - autoFocus - theme={undefined as any} - style={undefined as any} - > -
-

- -

-
-
+ ); }; -export default withReduxState(withNeosGlobals(SyncWorkspaceDialog as any)); +export default withReduxState(SyncWorkspaceDialog as any); diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css index 45cf3d5165..dd04c33bb6 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css @@ -1,36 +1,75 @@ .modalContents { padding: var(--spacing-Full); white-space: pre-line; + display: flex; + flex-direction: column; + gap: var(--spacing-Full); } -.buttonIcon { - display: inline-block; - width: 1.5rem; - height: 1.5rem; - float: left; - margin-right: var(--spacing-Quarter); -} - -.buttonIcon span { - display: inline-block; - width: 100%; -} - -.buttonIcon svg { - width: 100%; - height: auto; +.button { + display: inline-flex; + flex-direction: row; + gap: var(--spacing-Full); + align-items: center; + justify-content: center; } -.confirmText { - vertical-align: sub; +.icon { + width: 1.5rem; + height: 1.5rem; } .modalTitle { display: flex; flex-direction: row; align-items: center; + gap: var(--spacing-Full); } .modalTitle span:first-child { scale: .8; } + +.diagram { + display: flex; + gap: var(--spacing-Full); + align-items: center; + justify-content: space-between; + padding: var(--spacing-Full); + margin: 0 auto calc(2 * var(--spacing-Full)); + width: 300px; +} +.diagram__workspace { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-Half); + position: relative; +} +.diagram__workspace__icon { + font-size: 32px; +} +.diagram__workspace__icon--trash { + color: var(--colors-Warn); +} +.diagram__workspace__label { + position: absolute; + display: block; + bottom: -100%; + left: 50%; + width: 160px; + transform: translate(-50%, 0); + text-align: center; +} +.diagram__process__icon { + font-size: 48px; +} +.diagram__process__icon--error { + color: var(--colors-Error); +} +.diagram__process__icon--success { + color: var(--colors-Success); +} +.diagram__process__icon--warn { + color: var(--colors-Warn); +} From 49719ea8133fbea599060ce23c8360d180e428a8 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 29 Mar 2024 15:42:01 +0100 Subject: [PATCH 09/29] TASK: Create `ResolutionStrategySelectionDialog` component --- .../Translations/en/SyncWorkspaceDialog.xlf | 30 +++ .../SyncWorkspaceDialog/ConflictList.tsx | 241 ++++++++++++++++++ .../ResolutionStrategySelectionDialog.tsx | 204 +++++++++++++++ .../SyncWorkspaceDialog.tsx | 112 ++++++-- .../SyncWorkspaceDialog/style.module.css | 67 +++++ 5 files changed, 637 insertions(+), 17 deletions(-) create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf index 9b57bc40fa..c2dcd89828 100644 --- a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -14,6 +14,36 @@ Yes, synchronize now + + Conflicts between workspace "{workspaceName}" and "{baseWorkspaceName}" + + + Workspace "{baseWorkspaceName}" contains modifications that are in conflict with the changes in workspace "{workspaceName}". + + + Show information about {numberOfConflicts} conflict(s) + + + In order to proceed, you need to decide what to do with the conflicting changes: + + + Drop conflicting changes + + + This will save all non-conflicting changes, while every conflicting change will be lost. + + + Discard workspace "{workspaceName}" + + + This will discard all changes in your workspace, including those on other sites. + + + Cancel Synchronization + + + Accept choice and continue + diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx new file mode 100644 index 0000000000..b6a20b94e8 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ConflictList.tsx @@ -0,0 +1,241 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {I18nRegistry} from '@neos-project/neos-ts-interfaces'; +import I18n from '@neos-project/neos-ui-i18n'; +import {Icon} from '@neos-project/react-ui-components'; +import {Conflict, ReasonForConflict} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; +import {TypeOfChange} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; + +import style from './style.module.css'; + +export const ConflictList: React.FC<{ + conflicts: Conflict[]; + i18n: I18nRegistry; +}> = (props) => { + return ( +
    + {props.conflicts.map((conflict, i) => ( + + ))} +
+ ) +} + +const VARIANTS_BY_TYPE_OF_CHANGE = { + [TypeOfChange.NODE_HAS_BEEN_CHANGED]: { + icon: 'pencil', + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.typeOfChange.NODE_HAS_BEEN_CHANGED.label', + fallback: (props: { label: string }) => + `"${props.label}" has been edited.` + } + }, + [TypeOfChange.NODE_HAS_BEEN_CREATED]: { + icon: 'plus', + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.typeOfChange.NODE_HAS_BEEN_CREATED.label', + fallback: (props: { label: string }) => + `"${props.label}" has been created.` + } + }, + [TypeOfChange.NODE_HAS_BEEN_DELETED]: { + icon: 'times', + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.typeOfChange.NODE_HAS_BEEN_DELETED.label', + fallback: (props: { label: string }) => + `"${props.label}" has been deleted.` + } + }, + [TypeOfChange.NODE_HAS_BEEN_MOVED]: { + icon: 'long-arrow-right', + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.typeOfChange.NODE_HAS_BEEN_MOVED.label', + fallback: (props: { label: string }) => + `"${props.label}" has been moved.` + } + } +} as const; + +const VARIANTS_BY_REASON_FOR_CONFLICT = { + [ReasonForConflict.NODE_HAS_BEEN_DELETED]: { + icon: 'times', + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.reasonForConflict.NODE_HAS_BEEN_DELETED.label', + fallback: (props: { label: string }) => + `"${props.label}" or one of its ancestor nodes has been deleted.` + } + } +} as const; + +const ConflictItem: React.FC<{ + conflict: Conflict; + i18n: I18nRegistry; +}> = (props) => { + const changeVariant = props.conflict.typeOfChange === null + ? null + : VARIANTS_BY_TYPE_OF_CHANGE[props.conflict.typeOfChange]; + const reasonVariant = props.conflict.reasonForConflict === null + ? null + : VARIANTS_BY_REASON_FOR_CONFLICT[props.conflict.reasonForConflict]; + const affectedNode = props.conflict.affectedNode ?? { + icon: 'question', + label: props.i18n.translate( + 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.unknownNode', + 'Unknown Node' + ) + }; + const affectedDocument = props.conflict.affectedDocument ?? { + icon: 'question', + label: props.i18n.translate( + 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.unknownDocument', + 'Unknown Document' + ) + }; + const affectedSite = props.conflict.affectedSite ?? { + icon: 'question', + label: props.i18n.translate( + 'Neos.Neos.Ui:SyncWorkspaceDialog:conflictList.unknownSite', + 'Unknown Site' + ) + }; + + return ( +
  • +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    + {changeVariant ? ( +
    + + +
    + ) : ( +
    + +
    + )} + +
    +
    +
    + +
    + {reasonVariant ? ( +
    + + +
    + ) : ( +
    + +
    + )} +
    +
    +
    +
  • + ); +} + +const Node: React.FC<{ + icon: string; + label: string; + typeOfChange?: null | TypeOfChange; +}> = (props) => ( + + + {props.typeOfChange ? ( + + ) : null} + {props.label ?? ( + + )} + +); diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx new file mode 100644 index 0000000000..756e554cf7 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx @@ -0,0 +1,204 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import I18n from '@neos-project/neos-ui-i18n'; +import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {Button, Dialog, Icon, SelectBox, SelectBox_Option_MultiLineWithThumbnail} from '@neos-project/react-ui-components'; +import {Conflict, ResolutionStrategy, SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; + +import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync'; + +import {ConflictList} from './ConflictList'; +import {Diagram} from './Diagram'; +import style from './style.module.css'; + +const VARIANTS_BY_RESOLUTION_STRATEGY = { + [ResolutionStrategy.FORCE]: { + icon: 'puzzle-piece', + labels: { + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:resolutionStrategy.selection.option.FORCE.label', + fallback: () => 'Drop conflicting changes' + }, + description: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:resolutionStrategy.selection.option.FORCE.description', + fallback: 'This will save all non-conflicting changes, while every conflicting change will be lost.' + } + } + }, + [ResolutionStrategy.DISCARD_ALL]: { + icon: 'trash', + labels: { + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:resolutionStrategy.selection.option.DISCARD_ALL.label', + fallback: (props: {workspaceName: WorkspaceName}) => + `Discard workspace "${props.workspaceName}"` + }, + description: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:resolutionStrategy.selection.option.DISCARD_ALL.description', + fallback: 'This will discard all changes in your workspace, including those on other sites.' + } + } + } +} as const; + +const OPTIONS_FOR_RESOLUTION_STRATEGY_SELECTION = [ + { + value: ResolutionStrategy.FORCE + }, + { + value: ResolutionStrategy.DISCARD_ALL + } +] as const; + +export const ResolutionStrategySelectionDialog: React.FC<{ + workspaceName: WorkspaceName; + baseWorkspaceName: WorkspaceName; + conflicts: Conflict[]; + i18n: I18nRegistry; + onCancel: () => void; + onSelectResolutionStrategy: (strategy: ResolutionStrategy) => void; +}> = (props) => { + const [ + selectedResolutionStrategy, + setSelectedResolutionStrategy + ] = React.useState(ResolutionStrategy.FORCE); + const options = React.useMemo(() => { + return OPTIONS_FOR_RESOLUTION_STRATEGY_SELECTION + .map(({value}) => { + const variant = VARIANTS_BY_RESOLUTION_STRATEGY[value]; + const params = { + workspaceName: props.workspaceName + }; + + return { + value: String(value), + icon: variant.icon, + label: props.i18n.translate( + variant.labels.label.id, + variant.labels.label.fallback(params), + params + ), + description: props.i18n.translate( + variant.labels.description.id, + variant.labels.description.fallback + ) + }; + }) + }, [props.i18n, props.workspaceName]); + const handleSelectResolutionStrategy = React.useCallback((value: string) => { + setSelectedResolutionStrategy(parseInt(value, 10)); + }, []); + const handleConfirm = React.useCallback(() => { + props.onSelectResolutionStrategy(selectedResolutionStrategy); + }, [selectedResolutionStrategy]); + + return ( + + + , + + ]} + title={ +
    + + +
    + } + onRequestClose={props.onCancel} + type="warn" + isOpen + autoFocus + theme={undefined as any} + style={undefined as any} + > +
    + + +
    + + + + +
    + + +
    +
    + ); +} + +const ResolutionStrategyOption: React.FC<{ + option: { + value: ResolutionStrategy; + icon: string; + label: string; + description: string; + }; +}> = (props) => ( + +); diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 1659927f04..fb2f32dda2 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -11,20 +11,74 @@ import React from 'react'; // @ts-ignore import {connect} from 'react-redux'; +import {neos} from '@neos-project/neos-ui-decorators'; import {selectors, actions} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; -import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {ReasonForConflict, ResolutionStrategy, SyncingPhase, State as SyncingState} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; +import {TypeOfChange} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; import {ConfirmationDialog} from './ConfirmationDialog'; +import {ResolutionStrategySelectionDialog} from './ResolutionStrategySelectionDialog'; type SyncWorkspaceDialogPropsFromReduxState = { - isOpen: boolean; + syncingState: SyncingState; personalWorkspaceName: WorkspaceName; baseWorkspaceName: WorkspaceName; }; const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ - isOpen: true, + syncingState: { + process: { + phase: SyncingPhase.CONFLICT, + conflicts: [{ + affectedNode: { + icon: 'header', + label: 'New Blog article' + }, + affectedSite: { + icon: 'globe', + label: 'Our Website' + }, + affectedDocument: { + icon: 'file', + label: 'New Blog article' + }, + typeOfChange: TypeOfChange.NODE_HAS_BEEN_CHANGED, + reasonForConflict: ReasonForConflict.NODE_HAS_BEEN_DELETED + }, { + affectedNode: { + icon: 'image', + label: 'A dog sitting next to a chair' + }, + affectedSite: { + icon: 'globe', + label: 'Our Website' + }, + affectedDocument: { + icon: 'file', + label: 'Old Blog article' + }, + typeOfChange: TypeOfChange.NODE_HAS_BEEN_CREATED, + reasonForConflict: ReasonForConflict.NODE_HAS_BEEN_DELETED + }, { + affectedNode: { + icon: 'image', + label: 'A very long text to stress the design a little (or a lot, depending of what your definition of this is)' + }, + affectedSite: { + icon: 'globe', + label: 'Our Website' + }, + affectedDocument: { + icon: 'file', + label: 'Old Blog article' + }, + typeOfChange: TypeOfChange.NODE_HAS_BEEN_DELETED, + reasonForConflict: ReasonForConflict.NODE_HAS_BEEN_DELETED + }] + } + }, personalWorkspaceName: (selectors as any).CR.Workspaces .personalWorkspaceNameSelector(state), baseWorkspaceName: (selectors as any).CR.Workspaces @@ -34,6 +88,14 @@ const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFro abortRebase: actions.CR.Syncing.cancel }); +type SyncWorkspaceDialogPropsFromNeosGlobals = { + i18nRegistry: I18nRegistry; +}; + +const withNeosGlobals = neos((globalRegistry): SyncWorkspaceDialogPropsFromNeosGlobals => ({ + i18nRegistry: globalRegistry.get('i18n') +})); + type SyncWorkspaceDialogHandlers = { confirmRebase: () => void; abortRebase: () => void; @@ -41,28 +103,44 @@ type SyncWorkspaceDialogHandlers = { type SyncWorkspaceDialogProps = & SyncWorkspaceDialogPropsFromReduxState + & SyncWorkspaceDialogPropsFromNeosGlobals & SyncWorkspaceDialogHandlers; const SyncWorkspaceDialog: React.FC = (props) => { const handleCancel = React.useCallback(() => { - props.abortRebase(); + console.log('@TODO: props.abortRebase()'); }, []); const handleConfirm = React.useCallback(() => { - props.confirmRebase(); + console.log('@TODO: props.confirmRebase()'); + }, [props.personalWorkspaceName]); + const handleSelectResolutionStrategy = React.useCallback((selectedStrategy: ResolutionStrategy) => { + console.log('@TODO: Resolution strategy was selected', {selectedStrategy}); }, []); - if (!props.isOpen) { - return null; + switch (props.syncingState?.process.phase) { + case SyncingPhase.START: + return ( + + ); + case SyncingPhase.CONFLICT: + return ( + + ); + default: + return null; } - - return ( - - ); }; -export default withReduxState(SyncWorkspaceDialog as any); +export default withReduxState(withNeosGlobals(SyncWorkspaceDialog as any)); diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css index dd04c33bb6..f6b8a3f44c 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css @@ -19,6 +19,10 @@ height: 1.5rem; } +.summary { + cursor: pointer; +} + .modalTitle { display: flex; flex-direction: row; @@ -73,3 +77,66 @@ .diagram__process__icon--warn { color: var(--colors-Warn); } + +.node { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: var(--spacing-Half); + position: relative; + padding-left: 1.6em; +} +.node__icon { + position: absolute; + left: 0; +} +.node__changeIcon { + position: absolute; + font-size: 10px; + bottom: 0px; + left: 6px; + color: var(--colors-Warn); +} + +.conflictList > * + * { + margin-top: 2px; +} + +.conflict__details[open] { + border: 2px solid var(--colors-ContrastBright); +} +.conflict__summary { + cursor: pointer; + background-color: var(--colors-ContrastDark); + padding: var(--spacing-Full); +} +.conflict__summary::marker { + position: absolute; +} +.conflict__summary__contents { + display: inline-block; + max-width: 95%; + padding-left: var(--spacing-Half); +} +.conflict__changeIcon { + color: var(--colors-Warn); +} +.conflict__descriptionList { + display: grid; + grid-template-columns: 1fr 1fr; + padding: var(--spacing-Full); + background-color: var(--colors-ContrastDarkest); + gap: var(--spacing-Full); +} +.conflict__descriptionList__group { + display: flex; + flex-direction: column; + gap: var(--spacing-Quarter); +} +.conflict__descriptionList__description { + display: flex; + flex-direction: row; + align-items: first baseline; + gap: var(--spacing-Full); + margin-left: var(--spacing-Full); +} From 3ccdba65980f29c0183b10a02ba95dbf1b35efa4 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 29 Mar 2024 16:23:59 +0100 Subject: [PATCH 10/29] TASK: Add confirmation dialog for `DISCARD_ALL` resolution strategy --- .../Translations/en/SyncWorkspaceDialog.xlf | 14 +++ .../src/CR/Workspaces/index.ts | 2 + .../ResolutionStrategyConfirmationDialog.tsx | 90 +++++++++++++++++++ .../SyncWorkspaceDialog.tsx | 26 +++++- 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf index c2dcd89828..55200b65cc 100644 --- a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -44,6 +44,20 @@ Accept choice and continue + + Discard all changes in workspace "{workspaceName}" + + + You are about to discard all changes in workspace "{workspaceName}". This includes all changes on other sites. + + Do you wish to proceed? Be careful: This cannot be undone! + + + No, cancel + + + Yes, discard everything + 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 1bc1130daf..2081d50861 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts @@ -24,6 +24,7 @@ export interface PublishableNode { export interface WorkspaceInformation { name: WorkspaceName; + totalNumberOfChanges: number; publishableNodes: Array; baseWorkspace: WorkspaceName; readOnly?: boolean; @@ -37,6 +38,7 @@ export interface State extends Readonly<{ export const defaultState: State = { personalWorkspace: { name: '', + totalNumberOfChanges: 0, publishableNodes: [], baseWorkspace: '', status: WorkspaceStatus.UP_TO_DATE diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx new file mode 100644 index 0000000000..03e77641f3 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx @@ -0,0 +1,90 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import I18n from '@neos-project/neos-ui-i18n'; +import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; +import {PublishingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; + +import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync'; +import {Diagram as DiscardDiagram} from '../PublishingDialog/Diagram'; + +import style from './style.module.css'; + +export const ResolutionStrategyConfirmationDialog: React.FC<{ + workspaceName: WorkspaceName; + totalNumberOfChangesInWorkspace: number; + onCancelConflictResolution: () => void; + onConfirmResolutionStrategy: () => void; +}> = (props) => { + return ( + + + , + + ]} + title={ +
    + + +
    + } + onRequestClose={props.onCancelConflictResolution} + type="error" + isOpen + autoFocus + theme={undefined as any} + style={undefined as any} + > +
    + + +
    +
    + ); +} diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index fb2f32dda2..11a1212b26 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -20,17 +20,20 @@ import {TypeOfChange} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces' import {ConfirmationDialog} from './ConfirmationDialog'; import {ResolutionStrategySelectionDialog} from './ResolutionStrategySelectionDialog'; +import {ResolutionStrategyConfirmationDialog} from './ResolutionStrategyConfirmationDialog'; type SyncWorkspaceDialogPropsFromReduxState = { syncingState: SyncingState; personalWorkspaceName: WorkspaceName; baseWorkspaceName: WorkspaceName; + totalNumberOfChangesInWorkspace: number; }; const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ syncingState: { process: { - phase: SyncingPhase.CONFLICT, + phase: SyncingPhase.RESOLVING, + strategy: ResolutionStrategy.DISCARD_ALL, conflicts: [{ affectedNode: { icon: 'header', @@ -81,8 +84,10 @@ const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFro }, personalWorkspaceName: (selectors as any).CR.Workspaces .personalWorkspaceNameSelector(state), - baseWorkspaceName: (selectors as any).CR.Workspaces - .baseWorkspaceSelector(state) + baseWorkspaceName: selectors.CR.Workspaces + .baseWorkspaceSelector(state), + totalNumberOfChangesInWorkspace: state.cr.workspaces.personalWorkspace + .totalNumberOfChanges }), { confirmRebase: actions.CR.Syncing.confirm, abortRebase: actions.CR.Syncing.cancel @@ -116,6 +121,12 @@ const SyncWorkspaceDialog: React.FC = (props) => { const handleSelectResolutionStrategy = React.useCallback((selectedStrategy: ResolutionStrategy) => { console.log('@TODO: Resolution strategy was selected', {selectedStrategy}); }, []); + const handleCancelConflictResolution = React.useCallback(() => { + console.log('@TODO: handleCancelConflictResolution'); + }, []); + const handleConfirmResolutionStrategy = React.useCallback(() => { + console.log('@TODO: handleConfirmResolutionStrategy'); + }, []); switch (props.syncingState?.process.phase) { case SyncingPhase.START: @@ -138,6 +149,15 @@ const SyncWorkspaceDialog: React.FC = (props) => { onSelectResolutionStrategy={handleSelectResolutionStrategy} /> ); + case SyncingPhase.RESOLVING: + return ( + + ); default: return null; } From 8db3bf81711a284d72a23f12c4326450b848d9f5 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 29 Mar 2024 17:59:14 +0100 Subject: [PATCH 11/29] TASK: Add confirmation dialog for `FORCE` resolution strategy --- .../Translations/en/SyncWorkspaceDialog.xlf | 51 ++++++++++ .../ResolutionStrategyConfirmationDialog.tsx | 96 ++++++++++++++++++- .../SyncWorkspaceDialog.tsx | 8 +- .../SyncWorkspaceDialog/style.module.css | 2 +- 4 files changed, 153 insertions(+), 4 deletions(-) diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf index 55200b65cc..ef1af6473b 100644 --- a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -58,6 +58,57 @@ Yes, discard everything + + You are about to drop the following changes: + + + Do you wish to proceed? Be careful: This cannot be undone! + + + No, cancel + + + Yes, drop those changes + + + Drop conflicting changes in workspace "{workspaceName}" + + + "{label}" has been edited. + + + "{label}" has been created. + + + "{label}" has been deleted. + + + "{label}" has been moved. + + + "{label}" or one of its ancestor nodes has been deleted. + + + Affected Site + + + Affected Document + + + What was changed? + + + Why is there a conflict? + + + Unknown Node + + + Unknown Document + + + Unknown Site + diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx index 03e77641f3..c0e67a104a 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx @@ -9,17 +9,111 @@ */ import React from 'react'; -import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; +import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; import I18n from '@neos-project/neos-ui-i18n'; import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; import {PublishingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; +import {Conflict, ResolutionStrategy} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {WorkspaceSyncIcon} from '../../PrimaryToolbar/WorkspaceSync'; import {Diagram as DiscardDiagram} from '../PublishingDialog/Diagram'; +import {ConflictList} from './ConflictList'; import style from './style.module.css'; export const ResolutionStrategyConfirmationDialog: React.FC<{ + workspaceName: WorkspaceName; + totalNumberOfChangesInWorkspace: number; + baseWorkspaceName: WorkspaceName; + strategy: ResolutionStrategy; + conflicts: Conflict[]; + i18n: I18nRegistry; + onCancelConflictResolution: () => void; + onConfirmResolutionStrategy: () => void; +}> = (props) => { + switch (props.strategy) { + case ResolutionStrategy.FORCE: + return (); + case ResolutionStrategy.DISCARD_ALL: + default: + return (); + } +} + +const ForceConfirmationDialog: React.FC<{ + workspaceName: WorkspaceName; + baseWorkspaceName: WorkspaceName; + conflicts: Conflict[]; + i18n: I18nRegistry; + onCancelConflictResolution: () => void; + onConfirmResolutionStrategy: () => void; +}> = (props) => { + return ( + + + , + + ]} + title={ +
    + + +
    + } + onRequestClose={props.onCancelConflictResolution} + type="error" + isOpen + autoFocus + theme={undefined as any} + style={undefined as any} + > +
    + + + +
    +
    + ); +} + +const DiscardAllConfirmationDialog: React.FC<{ workspaceName: WorkspaceName; totalNumberOfChangesInWorkspace: number; onCancelConflictResolution: () => void; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 11a1212b26..223971fe04 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -33,7 +33,7 @@ const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFro syncingState: { process: { phase: SyncingPhase.RESOLVING, - strategy: ResolutionStrategy.DISCARD_ALL, + strategy: ResolutionStrategy.FORCE, conflicts: [{ affectedNode: { icon: 'header', @@ -82,7 +82,7 @@ const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFro }] } }, - personalWorkspaceName: (selectors as any).CR.Workspaces + personalWorkspaceName: selectors.CR.Workspaces .personalWorkspaceNameSelector(state), baseWorkspaceName: selectors.CR.Workspaces .baseWorkspaceSelector(state), @@ -153,7 +153,11 @@ const SyncWorkspaceDialog: React.FC = (props) => { return ( diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css index f6b8a3f44c..9211723d1f 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css @@ -9,7 +9,7 @@ .button { display: inline-flex; flex-direction: row; - gap: var(--spacing-Full); + gap: var(--spacing-Half); align-items: center; justify-content: center; } From acb76ba561df8dede5cea1be8679e0e5785b733f Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Tue, 2 Apr 2024 14:15:19 +0200 Subject: [PATCH 12/29] TASK: Create `ResultDialog` component for Syncing workflow --- .../Translations/en/SyncWorkspaceDialog.xlf | 21 +++ .../SyncWorkspaceDialog/ResultDialog.tsx | 156 ++++++++++++++++++ .../SyncWorkspaceDialog.tsx | 71 +++----- .../SyncWorkspaceDialog/style.module.css | 41 +++++ 4 files changed, 239 insertions(+), 50 deletions(-) create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResultDialog.tsx diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf index ef1af6473b..1583794203 100644 --- a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -109,6 +109,27 @@ Unknown Site + + Workspace "{workspaceName}" is up-to-date + + + Workspace "{workspaceName}" has been successfully synchronized with all recent changes in workspace "{baseWorkspaceName}". + + + OK + + + Workspace "{workspaceName}" could not be synchronized + + + Workspace "{workspaceName}" could not be synchronized with the recent changes in workspace "{baseWorkspaceName}". + + + Cancel + + + Try again + diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResultDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResultDialog.tsx new file mode 100644 index 0000000000..3d1c8eae81 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResultDialog.tsx @@ -0,0 +1,156 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import I18n from '@neos-project/neos-ui-i18n'; +import {Button, Dialog, Icon} from '@neos-project/react-ui-components'; +import {AnyError, ErrorView} from '@neos-project/neos-ui-error'; + +import {Diagram} from './Diagram'; + +import style from './style.module.css'; +import {SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; +import {WorkspaceName} from '@neos-project/neos-ts-interfaces'; + +const VARIANTS_BY_SYNCING_PHASE = { + [SyncingPhase.SUCCESS]: { + icon: 'check', + style: 'success', + label: { + title: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:success.title', + fallback: (props: { workspaceName: WorkspaceName; }) => + `Workspace "${props.workspaceName}" is up-to-date` + }, + message: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:success.message', + fallback: (props: { workspaceName: WorkspaceName; baseWorkspaceName: WorkspaceName; }) => + `Workspace "${props.workspaceName}" has been successfully synchronized with all recent changes in workspace "${props.baseWorkspaceName}".` + } + } + }, + [SyncingPhase.ERROR]: { + icon: 'exclamation-circle', + style: 'error', + label: { + title: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:error.title', + fallback: (props: { workspaceName: WorkspaceName; }) => + `Workspace "${props.workspaceName}" could not be synchronized` + }, + message: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:error.message', + fallback: (props: { workspaceName: WorkspaceName; baseWorkspaceName: WorkspaceName; }) => + `Workspace "${props.workspaceName}" could not be synchronized with the recent changes in workspace "${props.baseWorkspaceName}".` + } + } + } +} as const; + +type Result = + | { + phase: SyncingPhase.ERROR; + error: null | AnyError; + } + | { phase: SyncingPhase.SUCCESS }; + +export const ResultDialog: React.FC<{ + workspaceName: string; + baseWorkspaceName: string; + result: Result; + onRetry: () => void; + onAcknowledge: () => void; +}> = (props) => { + const variant = VARIANTS_BY_SYNCING_PHASE[props.result.phase]; + + return ( + + + , + + ] : [ + + ]} + title={ +
    + + +
    + } + onRequestClose={props.onAcknowledge} + type={variant.style} + isOpen + autoFocus + preventClosing={props.result.phase === SyncingPhase.ERROR} + theme={undefined as any} + style={undefined as any} + > +
    + + {props.result.phase === SyncingPhase.ERROR + ? () + : ( + + ) + } +
    +
    + ); +}; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 223971fe04..1fcd634ac1 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -15,12 +15,12 @@ import {neos} from '@neos-project/neos-ui-decorators'; import {selectors, actions} from '@neos-project/neos-ui-redux-store'; import {GlobalState} from '@neos-project/neos-ui-redux-store/src/System'; import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; -import {ReasonForConflict, ResolutionStrategy, SyncingPhase, State as SyncingState} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; -import {TypeOfChange} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; +import {ResolutionStrategy, SyncingPhase, State as SyncingState} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {ConfirmationDialog} from './ConfirmationDialog'; import {ResolutionStrategySelectionDialog} from './ResolutionStrategySelectionDialog'; import {ResolutionStrategyConfirmationDialog} from './ResolutionStrategyConfirmationDialog'; +import {ResultDialog} from './ResultDialog'; type SyncWorkspaceDialogPropsFromReduxState = { syncingState: SyncingState; @@ -32,54 +32,8 @@ type SyncWorkspaceDialogPropsFromReduxState = { const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ syncingState: { process: { - phase: SyncingPhase.RESOLVING, - strategy: ResolutionStrategy.FORCE, - conflicts: [{ - affectedNode: { - icon: 'header', - label: 'New Blog article' - }, - affectedSite: { - icon: 'globe', - label: 'Our Website' - }, - affectedDocument: { - icon: 'file', - label: 'New Blog article' - }, - typeOfChange: TypeOfChange.NODE_HAS_BEEN_CHANGED, - reasonForConflict: ReasonForConflict.NODE_HAS_BEEN_DELETED - }, { - affectedNode: { - icon: 'image', - label: 'A dog sitting next to a chair' - }, - affectedSite: { - icon: 'globe', - label: 'Our Website' - }, - affectedDocument: { - icon: 'file', - label: 'Old Blog article' - }, - typeOfChange: TypeOfChange.NODE_HAS_BEEN_CREATED, - reasonForConflict: ReasonForConflict.NODE_HAS_BEEN_DELETED - }, { - affectedNode: { - icon: 'image', - label: 'A very long text to stress the design a little (or a lot, depending of what your definition of this is)' - }, - affectedSite: { - icon: 'globe', - label: 'Our Website' - }, - affectedDocument: { - icon: 'file', - label: 'Old Blog article' - }, - typeOfChange: TypeOfChange.NODE_HAS_BEEN_DELETED, - reasonForConflict: ReasonForConflict.NODE_HAS_BEEN_DELETED - }] + phase: SyncingPhase.ERROR, + error: new Error('Something bad happened') } }, personalWorkspaceName: selectors.CR.Workspaces @@ -127,6 +81,12 @@ const SyncWorkspaceDialog: React.FC = (props) => { const handleConfirmResolutionStrategy = React.useCallback(() => { console.log('@TODO: handleConfirmResolutionStrategy'); }, []); + const handleAcknowledge = React.useCallback(() => { + console.log('@TODO: handleAcknowledge'); + }, []); + const handleRetry = React.useCallback(() => { + console.log('@TODO: handleRetry'); + }, []); switch (props.syncingState?.process.phase) { case SyncingPhase.START: @@ -162,6 +122,17 @@ const SyncWorkspaceDialog: React.FC = (props) => { onConfirmResolutionStrategy={handleConfirmResolutionStrategy} /> ); + case SyncingPhase.ERROR: + case SyncingPhase.SUCCESS: + return ( + + ); default: return null; } diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css index 9211723d1f..46207735d4 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css @@ -140,3 +140,44 @@ gap: var(--spacing-Full); margin-left: var(--spacing-Full); } + +.diagram { + display: flex; + gap: var(--spacing-Full); + align-items: center; + justify-content: space-between; + padding: var(--spacing-Full); + margin: 0 auto calc(2 * var(--spacing-Full)); + width: 300px; +} +.diagram__workspace { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-Half); + position: relative; +} +.diagram__workspace__icon { + font-size: 32px; +} +.diagram__workspace__icon--trash { + color: var(--colors-Warn); +} +.diagram__workspace__label { + position: absolute; + display: block; + bottom: -100%; + left: 50%; + width: 160px; + transform: translate(-50%, 0); + text-align: center; +} +.diagram__process__icon { + font-size: 48px; +} +.diagram__process__icon--error { + color: var(--colors-Error); +} +.diagram__process__icon--success { + color: var(--colors-Success); +} From af8f830e29ef242bb31481d9990082ff9b3d1b35 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 3 Apr 2024 20:30:45 +0200 Subject: [PATCH 13/29] TASK: Create `ProcessIndicator` component for Syncing workflow --- .../Translations/en/SyncWorkspaceDialog.xlf | 6 ++ .../SyncWorkspaceDialog/ProcessIndicator.tsx | 57 +++++++++++++++++++ .../SyncWorkspaceDialog.tsx | 11 +++- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ProcessIndicator.tsx diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf index 1583794203..e943c7056a 100644 --- a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -14,6 +14,12 @@ Yes, synchronize now + + Synchronizing workspace "{workspaceName}"... + + + Please wait, while workspace "{workspaceName}" is being synchronized with recent changes in workspace "{baseWorkspaceName}". This may take a while. + Conflicts between workspace "{workspaceName}" and "{baseWorkspaceName}" diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ProcessIndicator.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ProcessIndicator.tsx new file mode 100644 index 0000000000..ee0323a1b7 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ProcessIndicator.tsx @@ -0,0 +1,57 @@ +/* + * This file is part of the Neos.Neos.Ui package. + * + * (c) Contributors of the Neos Project - www.neos.io + * + * This package is Open Source Software. For the full copyright and license + * information, please view the LICENSE file which was distributed with this + * source code. + */ +import React from 'react'; + +import {Dialog, Icon} from '@neos-project/react-ui-components'; +import I18n from '@neos-project/neos-ui-i18n'; +import {SyncingPhase} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; + +import {Diagram} from './Diagram'; +import style from './style.module.css'; + +export const ProcessIndicator: React.FC<{ + workspaceName: string; + baseWorkspaceName: string; +}> = (props) => { + return ( + + + + + } + type={undefined as any} + isOpen + autoFocus + preventClosing + theme={undefined as any} + style={undefined as any} + > +
    + + +
    +
    + ); +}; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 1fcd634ac1..6b7b4eaa30 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -18,6 +18,7 @@ import {I18nRegistry, WorkspaceName} from '@neos-project/neos-ts-interfaces'; import {ResolutionStrategy, SyncingPhase, State as SyncingState} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {ConfirmationDialog} from './ConfirmationDialog'; +import {ProcessIndicator} from './ProcessIndicator'; import {ResolutionStrategySelectionDialog} from './ResolutionStrategySelectionDialog'; import {ResolutionStrategyConfirmationDialog} from './ResolutionStrategyConfirmationDialog'; import {ResultDialog} from './ResultDialog'; @@ -32,8 +33,7 @@ type SyncWorkspaceDialogPropsFromReduxState = { const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ syncingState: { process: { - phase: SyncingPhase.ERROR, - error: new Error('Something bad happened') + phase: SyncingPhase.ONGOING } }, personalWorkspaceName: selectors.CR.Workspaces @@ -98,6 +98,13 @@ const SyncWorkspaceDialog: React.FC = (props) => { onConfirm={handleConfirm} /> ); + case SyncingPhase.ONGOING: + return ( + + ); case SyncingPhase.CONFLICT: return ( Date: Wed, 3 Apr 2024 20:55:07 +0200 Subject: [PATCH 14/29] TASK: Wire-up `SyncWorkspaceDialog` with `Syncing` redux state partition --- .../SyncWorkspaceDialog.tsx | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 6b7b4eaa30..d2c43377e2 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -31,11 +31,7 @@ type SyncWorkspaceDialogPropsFromReduxState = { }; const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFromReduxState => ({ - syncingState: { - process: { - phase: SyncingPhase.ONGOING - } - }, + syncingState: state.cr.syncing, personalWorkspaceName: selectors.CR.Workspaces .personalWorkspaceNameSelector(state), baseWorkspaceName: selectors.CR.Workspaces @@ -43,8 +39,13 @@ const withReduxState = connect((state: GlobalState): SyncWorkspaceDialogPropsFro totalNumberOfChangesInWorkspace: state.cr.workspaces.personalWorkspace .totalNumberOfChanges }), { - confirmRebase: actions.CR.Syncing.confirm, - abortRebase: actions.CR.Syncing.cancel + confirm: actions.CR.Syncing.confirm, + cancel: actions.CR.Syncing.cancel, + selectResolutionStrategy: actions.CR.Syncing.selectResolutionStrategy, + cancelResolution: actions.CR.Syncing.cancelResolution, + confirmResolution: actions.CR.Syncing.confirmResolution, + retry: actions.CR.Syncing.retry, + acknowledge: actions.CR.Syncing.acknowledge }); type SyncWorkspaceDialogPropsFromNeosGlobals = { @@ -56,8 +57,13 @@ const withNeosGlobals = neos((globalRegistry): SyncWorkspaceDialogPropsFromNeosG })); type SyncWorkspaceDialogHandlers = { - confirmRebase: () => void; - abortRebase: () => void; + confirm: () => void; + cancel: () => void; + selectResolutionStrategy: (selectedStrategy: ResolutionStrategy) => void; + cancelResolution: () => void; + confirmResolution: () => void; + retry: () => void; + acknowledge: () => void; }; type SyncWorkspaceDialogProps = @@ -67,25 +73,25 @@ type SyncWorkspaceDialogProps = const SyncWorkspaceDialog: React.FC = (props) => { const handleCancel = React.useCallback(() => { - console.log('@TODO: props.abortRebase()'); + props.cancel(); }, []); const handleConfirm = React.useCallback(() => { - console.log('@TODO: props.confirmRebase()'); - }, [props.personalWorkspaceName]); + props.confirm(); + }, []); const handleSelectResolutionStrategy = React.useCallback((selectedStrategy: ResolutionStrategy) => { - console.log('@TODO: Resolution strategy was selected', {selectedStrategy}); + props.selectResolutionStrategy(selectedStrategy); }, []); const handleCancelConflictResolution = React.useCallback(() => { - console.log('@TODO: handleCancelConflictResolution'); + props.cancelResolution(); }, []); const handleConfirmResolutionStrategy = React.useCallback(() => { - console.log('@TODO: handleConfirmResolutionStrategy'); + props.confirmResolution(); }, []); const handleAcknowledge = React.useCallback(() => { - console.log('@TODO: handleAcknowledge'); + props.acknowledge(); }, []); const handleRetry = React.useCallback(() => { - console.log('@TODO: handleRetry'); + props.retry(); }, []); switch (props.syncingState?.process.phase) { From 81d01bed49549acb67c1637fdee99b38140af501 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Wed, 3 Apr 2024 20:55:41 +0200 Subject: [PATCH 15/29] TASK: Remove legacy `syncWorkspace` action from Workspaces state partition --- .../neos-ui-redux-store/src/CR/Workspaces/index.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) 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 2081d50861..720a449ca1 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts @@ -47,8 +47,7 @@ export const defaultState: State = { export enum actionTypes { UPDATE = '@neos/neos-ui/CR/Workspaces/UPDATE', - CHANGE_BASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/CHANGE_BASE_WORKSPACE', - SYNC_WORKSPACE = '@neos/neos-ui/CR/Workspaces/SYNC_WORKSPACE' + CHANGE_BASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/CHANGE_BASE_WORKSPACE' } export type Action = ActionType; @@ -63,18 +62,12 @@ const update = (data: WorkspaceInformation) => createAction(actionTypes.UPDATE, */ const changeBaseWorkspace = (name: string) => createAction(actionTypes.CHANGE_BASE_WORKSPACE, name); -/** - * Rebase the user workspace - */ -const syncWorkspace = (name: string) => createAction(actionTypes.SYNC_WORKSPACE, name); - // // Export the actions // export const actions = { update, - changeBaseWorkspace, - syncWorkspace + changeBaseWorkspace }; // From c24e9ac74561469e9c85eac43f7e0ed75dbcc555 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 8 Apr 2024 17:26:32 +0200 Subject: [PATCH 16/29] TASK: Implement DiscardAllChanges command --- Classes/Application/DiscardAllChanges.php | 45 +++++++++++++++++++ .../Controller/BackendServiceController.php | 37 +++++++++++++++ Classes/Infrastructure/MVC/RoutesProvider.php | 2 + Configuration/Routes.Service.yaml | 8 ++++ 4 files changed, 92 insertions(+) create mode 100644 Classes/Application/DiscardAllChanges.php diff --git a/Classes/Application/DiscardAllChanges.php b/Classes/Application/DiscardAllChanges.php new file mode 100644 index 0000000000..26174e968d --- /dev/null +++ b/Classes/Application/DiscardAllChanges.php @@ -0,0 +1,45 @@ + $values + */ + public static function fromArray(array $values): self + { + return new self( + ContentRepositoryId::fromString($values['contentRepositoryId']), + WorkspaceName::fromString($values['workspaceName']), + ); + } +} diff --git a/Classes/Controller/BackendServiceController.php b/Classes/Controller/BackendServiceController.php index a7ec16a887..cb4e7ea674 100644 --- a/Classes/Controller/BackendServiceController.php +++ b/Classes/Controller/BackendServiceController.php @@ -37,6 +37,7 @@ use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\Service\UserService; use Neos\Neos\Ui\Application\ChangeTargetWorkspace; +use Neos\Neos\Ui\Application\DiscardAllChanges; use Neos\Neos\Ui\Application\DiscardChangesInDocument; use Neos\Neos\Ui\Application\DiscardChangesInSite; use Neos\Neos\Ui\Application\PublishChangesInDocument; @@ -277,6 +278,42 @@ public function publishChangesInDocumentAction(array $command): void } } + /** + * Discard all changes in the user's personal workspace + * + * @phpstan-param array $command + */ + public function discardAllChangesAction(array $command): void + { + try { + /** @todo send from UI */ + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $command['contentRepositoryId'] = $contentRepositoryId->value; + $command = DiscardAllChanges::fromArray($command); + + $workspace = $this->workspaceProvider->provideForWorkspaceName( + $command->contentRepositoryId, + $command->workspaceName + ); + $discardingResult = $workspace->discardAllChanges(); + + $this->view->assign('value', [ + 'success' => [ + 'numberOfAffectedChanges' => $discardingResult->numberOfDiscardedChanges + ] + ]); + } catch (\Exception $e) { + $this->view->assign('value', [ + 'error' => [ + 'class' => $e::class, + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ] + ]); + } + } + /** * Discard all changes in the given site * diff --git a/Classes/Infrastructure/MVC/RoutesProvider.php b/Classes/Infrastructure/MVC/RoutesProvider.php index 60d6c3a3e9..c95d51f776 100644 --- a/Classes/Infrastructure/MVC/RoutesProvider.php +++ b/Classes/Infrastructure/MVC/RoutesProvider.php @@ -36,6 +36,8 @@ public function getRoutes(UriBuilder $uriBuilder): array $helper->buildUiServiceRoute('publishChangesInSite'), 'publishChangesInDocument' => $helper->buildUiServiceRoute('publishChangesInDocument'), + 'discardAllChanges' => + $helper->buildUiServiceRoute('discardAllChanges'), 'discardChangesInSite' => $helper->buildUiServiceRoute('discardChangesInSite'), 'discardChangesInDocument' => diff --git a/Configuration/Routes.Service.yaml b/Configuration/Routes.Service.yaml index dd180309b7..2fcfd8ffb0 100644 --- a/Configuration/Routes.Service.yaml +++ b/Configuration/Routes.Service.yaml @@ -22,6 +22,14 @@ '@action': 'publishChangesInDocument' httpMethods: ['POST'] +- + name: 'Discard all changes in workspace' + uriPattern: 'discard-all-changes' + defaults: + '@controller': 'BackendService' + '@action': 'discardAllChanges' + httpMethods: ['POST'] + - name: 'Discard all changes in site' uriPattern: 'discard-changes-in-site' From 0e0aef053e63e7b3171e4e8fa2cfe3613ba942de Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 8 Apr 2024 17:31:00 +0200 Subject: [PATCH 17/29] TASK: Integrate DiscardAll command with publishing workflow on the client side --- .../Translations/en/PublishingDialog.xlf | 72 +++++++++++++++++++ .../src/Endpoints/index.ts | 16 +++++ .../src/CR/Publishing/index.ts | 1 + packages/neos-ui-sagas/src/Publish/index.ts | 19 ++++- .../PublishingDialog/ConfirmationDialog.tsx | 44 ++++++++++++ .../PublishingDialog/ProcessIndicator.tsx | 28 ++++++++ .../PublishingDialog/PublishingDialog.tsx | 12 +++- .../Modals/PublishingDialog/ResultDialog.tsx | 70 ++++++++++++++++++ 8 files changed, 258 insertions(+), 4 deletions(-) diff --git a/Resources/Private/Translations/en/PublishingDialog.xlf b/Resources/Private/Translations/en/PublishingDialog.xlf index 4ec7d7bf46..3c86b9ce2b 100644 --- a/Resources/Private/Translations/en/PublishingDialog.xlf +++ b/Resources/Private/Translations/en/PublishingDialog.xlf @@ -2,6 +2,42 @@ + + Publish all changes in workspace "{scopeTitle}" + + + Are you sure that you want to publish all {numberOfChanges} change(s) in workspace "{scopeTitle}" to workspace "{targetWorkspaceName}"? Be careful: This cannot be undone! + + + No, cancel + + + Yes, publish + + + Publishing all changes in workspace "{scopeTitle}"... + + + Please wait while {numberOfChanges} change(s) are being published. This may take a while. + + + Changes in workspace "{scopeTitle}" could not be published + + + Try again + + + Cancel + + + All changes in workspace "{scopeTitle}" were published + + + All {numberOfChanges} change(s) in workspace "{scopeTitle}" were successfully published to workspace "{targetWorkspaceName}". + + + OK + Publish all changes in site "{scopeTitle}" @@ -74,6 +110,42 @@ OK + + Discard all changes in workspace "{scopeTitle}" + + + Are you sure that you want to discard all {numberOfChanges} change(s) in workspace "{scopeTitle}"? Be careful: This cannot be undone! + + + No, cancel + + + Yes, discard + + + Discarding all changes in workspace "{scopeTitle}"... + + + Please wait while {numberOfChanges} change(s) are being discarded. This may take a while. + + + Changes in workspace "{scopeTitle}" could not be discarded + + + Try again + + + Cancel + + + All changes in workspace "{scopeTitle}" were discarded + + + All {numberOfChanges} change(s) in workspace "{scopeTitle}" were sucessfully discarded. + + + OK + Discard all changes in site "{scopeTitle}" diff --git a/packages/neos-ui-backend-connector/src/Endpoints/index.ts b/packages/neos-ui-backend-connector/src/Endpoints/index.ts index d45b73f8f5..1bcfed2c08 100644 --- a/packages/neos-ui-backend-connector/src/Endpoints/index.ts +++ b/packages/neos-ui-backend-connector/src/Endpoints/index.ts @@ -11,6 +11,7 @@ export interface Routes { change: string; publishChangesInSite: string; publishChangesInDocument: string; + discardAllChanges: string; discardChangesInSite: string; discardChangesInDocument: string; changeBaseWorkspace: string; @@ -96,6 +97,20 @@ export default (routes: Routes) => { })).then(response => fetchWithErrorHandling.parseJson(response)) .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); + const discardAllChanges = (workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ + url: routes.ui.service.discardAllChanges, + method: 'POST', + credentials: 'include', + headers: { + 'X-Flow-Csrftoken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + command: {workspaceName} + }) + })).then(response => fetchWithErrorHandling.parseJson(response)) + .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); + const discardChangesInSite = (siteId: NodeContextPath, workspaceName: WorkspaceName) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ url: routes.ui.service.discardChangesInSite, method: 'POST', @@ -706,6 +721,7 @@ export default (routes: Routes) => { change, publishChangesInSite, publishChangesInDocument, + discardAllChanges, discardChangesInSite, discardChangesInDocument, changeBaseWorkspace, 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 bbe65ec3f8..21fea2cac6 100644 --- a/packages/neos-ui-redux-store/src/CR/Publishing/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Publishing/index.ts @@ -17,6 +17,7 @@ export enum PublishingMode { } export enum PublishingScope { + ALL, SITE, DOCUMENT } diff --git a/packages/neos-ui-sagas/src/Publish/index.ts b/packages/neos-ui-sagas/src/Publish/index.ts index a6d4e923e8..63220b1339 100644 --- a/packages/neos-ui-sagas/src/Publish/index.ts +++ b/packages/neos-ui-sagas/src/Publish/index.ts @@ -38,12 +38,16 @@ export function * watchPublishing({routes}: {routes: Routes}) { const {endpoints} = backend.get(); const ENDPOINT_BY_MODE_AND_SCOPE = { [PublishingMode.PUBLISH]: { + [PublishingScope.ALL]: + null, [PublishingScope.SITE]: endpoints.publishChangesInSite, [PublishingScope.DOCUMENT]: endpoints.publishChangesInDocument }, [PublishingMode.DISCARD]: { + [PublishingScope.ALL]: + endpoints.discardAllChanges, [PublishingScope.SITE]: endpoints.discardChangesInSite, [PublishingScope.DOCUMENT]: @@ -51,6 +55,9 @@ export function * watchPublishing({routes}: {routes: Routes}) { } }; const SELECTORS_BY_SCOPE = { + [PublishingScope.ALL]: { + ancestorIdSelector: null + }, [PublishingScope.SITE]: { ancestorIdSelector: selectors.CR.Nodes.siteNodeContextPathSelector }, @@ -69,15 +76,23 @@ export function * watchPublishing({routes}: {routes: Routes}) { const {scope, mode} = action.payload; const endpoint = ENDPOINT_BY_MODE_AND_SCOPE[mode][scope]; + if (!endpoint) { + console.warn('"Publish all" is not implemented!'); + return; + } const {ancestorIdSelector} = SELECTORS_BY_SCOPE[scope]; const workspaceName: WorkspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); - const ancestorId: NodeContextPath = yield select(ancestorIdSelector); + const ancestorId: NodeContextPath = ancestorIdSelector + ? yield select(ancestorIdSelector) + : null; do { try { window.addEventListener('beforeunload', handleWindowBeforeUnload); - const result: PublishingResponse = yield call(endpoint, ancestorId, workspaceName); + 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)); diff --git a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ConfirmationDialog.tsx index b159fe55a7..5e715a8867 100644 --- a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ConfirmationDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ConfirmationDialog.tsx @@ -25,6 +25,28 @@ const ConfirmationDialogVariants = { title: 'share-square-o', confirm: 'share-square-o' }, + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.confirmation.title', + fallback: (props: { scopeTitle: string; }) => + `Publish all changes in workspace "${props.scopeTitle}"` + }, + message: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.confirmation.message', + fallback: (props: { numberOfChanges: number; scopeTitle: string; targetWorkspaceName: null | string; }) => + `Are you sure that you want to publish all ${props.numberOfChanges} change(s) in workspace "${props.scopeTitle}" to workspace "${props.targetWorkspaceName}"? Be careful: This cannot be undone!` + }, + cancel: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.confirmation.cancel', + fallback: 'No, cancel' + }, + confirm: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.confirmation.confirm', + fallback: 'Yes, publish' + } + } + }, [PublishingScope.SITE]: { label: { title: { @@ -77,6 +99,28 @@ const ConfirmationDialogVariants = { title: 'exclamation-triangle', confirm: 'ban' }, + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.confirmation.title', + fallback: (props: { scopeTitle: string; }) => + `Discard all changes in workspace "${props.scopeTitle}"` + }, + message: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.confirmation.message', + fallback: (props: { numberOfChanges: number; scopeTitle: string; }) => + `Are you sure that you want to discard all ${props.numberOfChanges} change(s) in workspace "${props.scopeTitle}"? Be careful: This cannot be undone!` + }, + cancel: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.confirmation.cancel', + fallback: 'No, cancel' + }, + confirm: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.confirmation.confirm', + fallback: 'Yes, discard' + } + } + }, [PublishingScope.SITE]: { label: { title: { diff --git a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ProcessIndicator.tsx b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ProcessIndicator.tsx index 449e3231de..49a0a2286a 100644 --- a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ProcessIndicator.tsx +++ b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ProcessIndicator.tsx @@ -19,6 +19,20 @@ import style from './style.module.css'; const ProcessIndicatorVariants = { [PublishingMode.PUBLISH]: { id: 'neos-PublishDialog', + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.process.title', + fallback: (props: { scopeTitle: string; }) => + `Publishing all changes in workspace "${props.scopeTitle}"...` + }, + message: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.process.message', + fallback: (props: { numberOfChanges: number; }) => + `Please wait while ${props.numberOfChanges} change(s) are being published. This may take a while.` + } + } + }, [PublishingScope.SITE]: { label: { title: { @@ -50,6 +64,20 @@ const ProcessIndicatorVariants = { }, [PublishingMode.DISCARD]: { id: 'neos-DiscardDialog', + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.process.title', + fallback: (props: { scopeTitle: string; }) => + `Discarding all changes in workspace "${props.scopeTitle}"...` + }, + message: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.process.message', + fallback: (props: { numberOfChanges: number; }) => + `Please wait while ${props.numberOfChanges} change(s) are being discarded. This may take a while.` + } + } + }, [PublishingScope.SITE]: { label: { title: { diff --git a/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx b/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx index 0a1a117a2e..b8844bfd4d 100644 --- a/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/PublishingDialog/PublishingDialog.tsx @@ -24,7 +24,11 @@ import {ConfirmationDialog} from './ConfirmationDialog'; import {ProcessIndicator} from './ProcessIndicator'; import {ResultDialog} from './ResultDialog'; -const {publishableNodesSelector, publishableNodesInDocumentSelector} = (selectors as any).CR.Workspaces; +const { + publishableNodesSelector, + publishableNodesInDocumentSelector, + personalWorkspaceNameSelector +} = (selectors as any).CR.Workspaces; const {siteNodeSelector, documentNodeSelector} = (selectors as any).CR.Nodes; type PublishingDialogProperties = @@ -125,6 +129,8 @@ export default connect((state: GlobalState): PublishingDialogProperties => { let numberOfChanges = 0; if (publishingState.process.phase === PublishingPhase.SUCCESS) { numberOfChanges = publishingState.process.numberOfAffectedChanges; + } else if (scope === PublishingScope.ALL) { + numberOfChanges = state.cr.workspaces.personalWorkspace.totalNumberOfChanges; } else if (scope === PublishingScope.SITE) { numberOfChanges = publishableNodesSelector(state).length; } else if (scope === PublishingScope.DOCUMENT) { @@ -132,7 +138,9 @@ export default connect((state: GlobalState): PublishingDialogProperties => { } let scopeTitle = 'N/A'; - if (scope === PublishingScope.SITE) { + if (scope === PublishingScope.ALL) { + scopeTitle = personalWorkspaceNameSelector(state); + } else if (scope === PublishingScope.SITE) { scopeTitle = siteNodeSelector(state).label; } else if (scope === PublishingScope.DOCUMENT) { scopeTitle = documentNodeSelector(state).label; diff --git a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx index 6b0f7250b8..d66e971f2d 100644 --- a/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/PublishingDialog/ResultDialog.tsx @@ -24,6 +24,24 @@ const ResultDialogVariants = { [PublishingPhase.SUCCESS]: { style: 'success', icon: 'check', + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.success.title', + fallback: (props: { scopeTitle: string; }) => + `All changes in workspace "${props.scopeTitle}" were published` + }, + message: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.success.message', + fallback: (props: { numberOfChanges: number; scopeTitle: string; targetWorkspaceName: null | string; }) => + `All ${props.numberOfChanges} change(s) in workspace "${props.scopeTitle}" were sucessfully published to workspace "${props.targetWorkspaceName}".` + }, + acknowledge: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.success.acknowledge', + fallback: 'OK' + } + } + }, [PublishingScope.SITE]: { label: { title: { @@ -64,6 +82,23 @@ const ResultDialogVariants = { [PublishingPhase.ERROR]: { style: 'error', icon: 'exclamation-circle', + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.error.title', + fallback: (props: { scopeTitle: string; }) => + `Changes in workspace "${props.scopeTitle}" could not be published` + }, + retry: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.error.retry', + fallback: 'Try again' + }, + acknowledge: { + id: 'Neos.Neos.Ui:PublishingDialog:publish.all.error.acknowledge', + fallback: 'OK' + } + } + }, [PublishingScope.SITE]: { label: { title: { @@ -105,6 +140,24 @@ const ResultDialogVariants = { [PublishingPhase.SUCCESS]: { style: 'success', icon: 'check', + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.success.title', + fallback: (props: { scopeTitle: string; }) => + `All changes in workspace "${props.scopeTitle}" were discarded` + }, + message: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.success.message', + fallback: (props: { numberOfChanges: number; scopeTitle: string; }) => + `All ${props.numberOfChanges} change(s) in workspace "${props.scopeTitle}" were sucessfully discarded.` + }, + acknowledge: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.success.acknowledge', + fallback: 'OK' + } + } + }, [PublishingScope.SITE]: { label: { title: { @@ -145,6 +198,23 @@ const ResultDialogVariants = { [PublishingPhase.ERROR]: { style: 'error', icon: 'exclamation-circle', + [PublishingScope.ALL]: { + label: { + title: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.error.title', + fallback: (props: { scopeTitle: string; }) => + `Changes in workspace "${props.scopeTitle}" could not be discarded` + }, + acknowledge: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.error.acknowledge', + fallback: 'Cancel' + }, + retry: { + id: 'Neos.Neos.Ui:PublishingDialog:discard.all.error.retry', + fallback: 'Try again' + } + } + }, [PublishingScope.SITE]: { label: { title: { From c2c0a4101661019ba1a2deb3d8e901e006813b6d Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 8 Apr 2024 17:35:22 +0200 Subject: [PATCH 18/29] TASK: Implement DISCARD_ALL strategy for conflict resolution during rebase --- .../src/CR/Syncing/index.ts | 4 +- packages/neos-ui-sagas/src/Sync/index.ts | 151 ++++++++++++------ .../ResolutionStrategySelectionDialog.tsx | 3 +- .../SyncWorkspaceDialog.tsx | 28 ++-- 4 files changed, 126 insertions(+), 60 deletions(-) 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 406204fa2f..2c41a72cd8 100644 --- a/packages/neos-ui-redux-store/src/CR/Syncing/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Syncing/index.ts @@ -54,6 +54,7 @@ export type State = null | { | { phase: SyncingPhase.ONGOING } | { phase: SyncingPhase.CONFLICT; + strategy: null | ResolutionStrategy; conflicts: Conflict[]; } | { @@ -198,7 +199,8 @@ export const reducer = (state: State = defaultState, action: Action): State => { return { process: { phase: SyncingPhase.CONFLICT, - conflicts: action.payload.conflicts + conflicts: action.payload.conflicts, + strategy: null } }; case actionTypes.RESOLUTION_STARTED: diff --git a/packages/neos-ui-sagas/src/Sync/index.ts b/packages/neos-ui-sagas/src/Sync/index.ts index 3673857d88..697a4ea3e0 100644 --- a/packages/neos-ui-sagas/src/Sync/index.ts +++ b/packages/neos-ui-sagas/src/Sync/index.ts @@ -13,16 +13,27 @@ import {DimensionCombination, WorkspaceName} from '@neos-project/neos-ts-interfa import {AnyError} from '@neos-project/neos-ui-error'; import {actionTypes, actions, selectors} from '@neos-project/neos-ui-redux-store'; import backend from '@neos-project/neos-ui-backend-connector'; +import {PublishingMode, PublishingScope} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; import {Conflict, ResolutionStrategy} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; +import {WorkspaceInformation} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; // @TODO: This is a helper to gain type access to the available backend endpoints. // It shouldn't be necessary to do this, and this hack should be removed once a // better type API is available -import {default as Endpoints} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; +import {default as Endpoints, Routes} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; type Endpoints = ReturnType; -export function * watchSyncing() { - yield takeEvery(actionTypes.CR.Syncing.STARTED, function * change() { +import {makeReloadNodes} from '../CR/NodeOperations/reloadNodes'; + +type SyncWorkspaceResult = + | { success: true } + | { conflicts: Conflict[] } + | { error: AnyError }; + +export function * watchSyncing({routes}: {routes: Routes}) { + const syncPersonalWorkspace = makeSyncPersonalWorkspace({routes}); + + yield takeEvery(actionTypes.CR.Syncing.STARTED, function * sync() { if (yield * waitForConfirmation()) { do { yield * syncPersonalWorkspace(false); @@ -45,46 +56,58 @@ function * waitForConfirmation() { return Boolean(confirmed); } -type SyncWorkspaceResult = - | { success: true } - | { conflicts: Conflict[] } - | { error: AnyError }; - -function * syncPersonalWorkspace(force: boolean) { - const {syncWorkspace} = backend.get().endpoints as Endpoints; - const personalWorkspaceName: WorkspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); - const dimensionSpacePoint: null|DimensionCombination = yield select(selectors.CR.ContentDimensions.active); - - try { - const result: SyncWorkspaceResult = yield call(syncWorkspace, personalWorkspaceName, force, dimensionSpacePoint); - if ('success' in result) { - yield put(actions.CR.Syncing.succeed()); - } else if ('conflicts' in result) { - yield * resolveConflicts(result.conflicts); - } else { - yield put(actions.CR.Syncing.fail(result.error)); +const makeSyncPersonalWorkspace = (deps: { + routes: Routes +}) => { + const refreshAfterSyncing = makeRefreshAfterSyncing(deps); + const resolveConflicts = makeResolveConflicts({syncPersonalWorkspace}); + + function * syncPersonalWorkspace(force: boolean) { + const {syncWorkspace} = backend.get().endpoints as Endpoints; + const personalWorkspaceName: WorkspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); + const dimensionSpacePoint: null|DimensionCombination = yield select(selectors.CR.ContentDimensions.active); + + try { + const result: SyncWorkspaceResult = yield call(syncWorkspace, personalWorkspaceName, force, dimensionSpacePoint); + if ('success' in result) { + yield * refreshAfterSyncing(); + yield put(actions.CR.Syncing.succeed()); + } else if ('conflicts' in result) { + yield * resolveConflicts(result.conflicts); + } else { + yield put(actions.CR.Syncing.fail(result.error)); + } + } catch (error) { + yield put(actions.CR.Syncing.fail(error as AnyError)); } - } catch (error) { - yield put(actions.CR.Syncing.fail(error as AnyError)); - } finally { - yield * refreshAfterSyncing(); } -} -function * resolveConflicts(conflicts: Conflict[]): any { - yield put(actions.CR.Syncing.resolve(conflicts)); - - const {payload: {strategy}}: ReturnType< - typeof actions.CR.Syncing.selectResolutionStrategy - > = yield take(actionTypes.CR.Syncing.RESOLUTION_STARTED); + return syncPersonalWorkspace; +} - if (yield * waitForResolutionConfirmation()) { - if (strategy === ResolutionStrategy.FORCE) { - yield * syncPersonalWorkspace(true); - } else if (strategy === ResolutionStrategy.DISCARD_ALL) { - yield * discardAll(); - } +const makeResolveConflicts = (deps: { + syncPersonalWorkspace: ReturnType +}) => { + const discardAll = makeDiscardAll(deps); + + function * resolveConflicts(conflicts: Conflict[]): any { + yield put(actions.CR.Syncing.resolve(conflicts)); + + yield takeEvery>( + actionTypes.CR.Syncing.RESOLUTION_STARTED, + function * resolve({payload: {strategy}}) { + if (strategy === ResolutionStrategy.FORCE) { + if (yield * waitForResolutionConfirmation()) { + yield * deps.syncPersonalWorkspace(true); + } + } else if (strategy === ResolutionStrategy.DISCARD_ALL) { + yield * discardAll(); + } + } + ); } + + return resolveConflicts; } function * waitForResolutionConfirmation() { @@ -111,14 +134,50 @@ function * waitForRetry() { return Boolean(retried); } -function * discardAll() { - yield console.log('@TODO: Discard All'); +const makeDiscardAll = (deps: { + syncPersonalWorkspace: ReturnType; +}) => { + function * discardAll() { + yield put(actions.CR.Publishing.start( + PublishingMode.DISCARD, + PublishingScope.ALL + )); + + const {cancelled, failed}: { + cancelled: null | ReturnType; + failed: null | ReturnType; + finished: null | ReturnType; + } = yield race({ + cancelled: take(actionTypes.CR.Publishing.CANCELLED), + failed: take(actionTypes.CR.Publishing.FAILED), + finished: take(actionTypes.CR.Publishing.FINISHED) + }); + + if (cancelled) { + yield put(actions.CR.Syncing.cancelResolution()); + } else if (failed) { + yield put(actions.CR.Syncing.finish()); + } else { + yield put(actions.CR.Syncing.confirmResolution()); + yield * deps.syncPersonalWorkspace(false); + } + } + + return discardAll; } -function * refreshAfterSyncing() { - yield console.log('@TODO: Refresh after Sync'); - // const {getWorkspaceInfo} = backend.get().endpoints as Endpoints; - // const workspaceInfo = yield call(getWorkspaceInfo); - // yield put(actions.CR.Workspaces.update(workspaceInfo)); - // yield put(actions.UI.Remote.finishSynchronization()); +const makeRefreshAfterSyncing = (deps: { + routes: Routes +}) => { + const {getWorkspaceInfo} = backend.get().endpoints as Endpoints; + const reloadNodes = makeReloadNodes(deps); + + function * refreshAfterSyncing() { + const workspaceInfo: WorkspaceInformation = yield call(getWorkspaceInfo); + yield put(actions.CR.Workspaces.update(workspaceInfo)); + + yield * reloadNodes(); + } + + return refreshAfterSyncing; } diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx index 756e554cf7..8ffa2a0054 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx @@ -63,6 +63,7 @@ export const ResolutionStrategySelectionDialog: React.FC<{ workspaceName: WorkspaceName; baseWorkspaceName: WorkspaceName; conflicts: Conflict[]; + defaultStrategy: null | ResolutionStrategy; i18n: I18nRegistry; onCancel: () => void; onSelectResolutionStrategy: (strategy: ResolutionStrategy) => void; @@ -70,7 +71,7 @@ export const ResolutionStrategySelectionDialog: React.FC<{ const [ selectedResolutionStrategy, setSelectedResolutionStrategy - ] = React.useState(ResolutionStrategy.FORCE); + ] = React.useState(props.defaultStrategy ?? ResolutionStrategy.FORCE); const options = React.useMemo(() => { return OPTIONS_FOR_RESOLUTION_STRATEGY_SELECTION .map(({value}) => { diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index d2c43377e2..2da8d0a0fd 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -117,24 +117,28 @@ const SyncWorkspaceDialog: React.FC = (props) => { workspaceName={props.personalWorkspaceName} baseWorkspaceName={props.baseWorkspaceName} conflicts={props.syncingState.process.conflicts} + defaultStrategy={props.syncingState.process.strategy} i18n={props.i18nRegistry} onCancel={handleCancel} onSelectResolutionStrategy={handleSelectResolutionStrategy} /> ); case SyncingPhase.RESOLVING: - return ( - - ); + if (props.syncingState.process.strategy === ResolutionStrategy.FORCE) { + return ( + + ); + } + return null; case SyncingPhase.ERROR: case SyncingPhase.SUCCESS: return ( From 8d7f0bd440c8423f0c276766b83f3e7d5852e173 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 11 Apr 2024 11:22:58 +0200 Subject: [PATCH 19/29] TASK: Add `beforeunload` handler during rebase --- packages/neos-ui-sagas/src/Sync/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/neos-ui-sagas/src/Sync/index.ts b/packages/neos-ui-sagas/src/Sync/index.ts index 697a4ea3e0..b6270a5f10 100644 --- a/packages/neos-ui-sagas/src/Sync/index.ts +++ b/packages/neos-ui-sagas/src/Sync/index.ts @@ -25,6 +25,12 @@ type Endpoints = ReturnType; import {makeReloadNodes} from '../CR/NodeOperations/reloadNodes'; +const handleWindowBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = true; + return true; +}; + type SyncWorkspaceResult = | { success: true } | { conflicts: Conflict[] } @@ -68,6 +74,7 @@ const makeSyncPersonalWorkspace = (deps: { const dimensionSpacePoint: null|DimensionCombination = yield select(selectors.CR.ContentDimensions.active); try { + window.addEventListener('beforeunload', handleWindowBeforeUnload); const result: SyncWorkspaceResult = yield call(syncWorkspace, personalWorkspaceName, force, dimensionSpacePoint); if ('success' in result) { yield * refreshAfterSyncing(); @@ -79,6 +86,8 @@ const makeSyncPersonalWorkspace = (deps: { } } catch (error) { yield put(actions.CR.Syncing.fail(error as AnyError)); + } finally { + window.removeEventListener('beforeunload', handleWindowBeforeUnload); } } From 3f2bb45c63b23241e9aee7e937cc93f46e901d5d Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 11 Apr 2024 22:27:30 +0200 Subject: [PATCH 20/29] TASK: Allow the SelectBox component to carry an ID --- packages/react-ui-components/src/DropDown/wrapper.tsx | 9 +++++++-- packages/react-ui-components/src/SelectBox/selectBox.js | 8 +++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/react-ui-components/src/DropDown/wrapper.tsx b/packages/react-ui-components/src/DropDown/wrapper.tsx index e34a3ab1ec..b2c76cb0c8 100644 --- a/packages/react-ui-components/src/DropDown/wrapper.tsx +++ b/packages/react-ui-components/src/DropDown/wrapper.tsx @@ -9,6 +9,11 @@ import ShallowDropDownContents from './contents'; import PropTypes from 'prop-types'; export interface DropDownWrapperProps { + /** + * An optional `id` to attach to the wrapper. + */ + readonly id?: string; + /** * An optional `className` to attach to the wrapper. */ @@ -95,7 +100,7 @@ class StatelessDropDownWrapperWithoutClickOutsideBehavior extends PureComponent< } public render(): JSX.Element { - const {children, className, theme, style, padded, ...restProps} = this.props; + const {children, id, className, theme, style, padded, ...restProps} = this.props; const rest = omit(restProps, ['isOpen', 'onToggle', 'onClose']); const styleClassName: string = style ? `dropDown--${style}` : ''; const finalClassName = mergeClassNames( @@ -109,7 +114,7 @@ class StatelessDropDownWrapperWithoutClickOutsideBehavior extends PureComponent< ); return ( -
    +
    {React.Children.map( children, // @ts-ignore diff --git a/packages/react-ui-components/src/SelectBox/selectBox.js b/packages/react-ui-components/src/SelectBox/selectBox.js index 4eb95afede..0f9ce4a807 100644 --- a/packages/react-ui-components/src/SelectBox/selectBox.js +++ b/packages/react-ui-components/src/SelectBox/selectBox.js @@ -21,6 +21,11 @@ export default class SelectBox extends PureComponent { // ------------------------------ // Basic Props for core functionality // ------------------------------ + /** + * DOM id of the select box + */ + id: PropTypes.string, + /** * This prop represents the set of options to be chosen from * Each option must have a value and can have a label and an icon. @@ -189,6 +194,7 @@ export default class SelectBox extends PureComponent { render() { const { + id, options, theme, showDropDownToggle, @@ -225,7 +231,7 @@ export default class SelectBox extends PureComponent { }); return ( - + {this.renderHeader()} From 2b1cd02358c50ffb5cede733afbae85d268f9e95 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 11 Apr 2024 22:31:20 +0200 Subject: [PATCH 21/29] TASK: Create second user during E2E setup --- .circleci/config.yml | 3 ++- Tests/IntegrationTests/e2e-docker.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0532539840..6c5370233d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -100,7 +100,8 @@ jobs: ./flow flow:cache:flush ./flow flow:cache:warmup ./flow doctrine:migrate - ./flow user:create --username=admin --password=admin --first-name=John --last-name=Doe --roles=Administrator + ./flow user:create --username=admin --password=admin --first-name=Admin --last-name=Admington --roles=Administrator + ./flow user:create --username=editor --password=editor --first-name=Editor --last-name=McEditworth --roles=Editor - run: name: Start flow server command: /home/circleci/app/flow server:run --port 8081 diff --git a/Tests/IntegrationTests/e2e-docker.sh b/Tests/IntegrationTests/e2e-docker.sh index 67e88c6c56..63a7f99b13 100755 --- a/Tests/IntegrationTests/e2e-docker.sh +++ b/Tests/IntegrationTests/e2e-docker.sh @@ -53,7 +53,8 @@ dc exec -T php bash <<-'BASH' ./flow flow:cache:flush ./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=admin --password=admin --first-name=Admin --last-name=Admington --roles=Administrator || true + ./flow user:create --username=editor --password=editor --first-name=Editor --last-name=McEditworth --roles=Editor || true ./flow cr:setup --content-repository onedimension ./flow cr:import --content-repository onedimension --path ./DistributionPackages/Neos.Test.OneDimension/Resources/Private/Content From 018671f849c47b6bcbd4d6476258e732f74b1b87 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Thu, 11 Apr 2024 22:32:43 +0200 Subject: [PATCH 22/29] TASK: Create E2E test scenario for conflict resolution with "Discard All" strategy --- .../Fixtures/1Dimension/syncing.e2e.js | 184 ++++++++++++++++++ Tests/IntegrationTests/pageModel.js | 26 +++ Tests/IntegrationTests/utils.js | 14 ++ .../ResolutionStrategySelectionDialog.tsx | 1 + 4 files changed, 225 insertions(+) create mode 100644 Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js diff --git a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js new file mode 100644 index 0000000000..6db8af06c9 --- /dev/null +++ b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js @@ -0,0 +1,184 @@ +import {Selector} from 'testcafe'; +import {ReactSelector, waitForReact} from 'testcafe-react-selectors'; +import {checkPropTypes, adminUserOnOneDimensionTestSite, editorUserOnOneDimensionTestSite} from './../../utils.js'; +import { + Page, + PublishDropDown +} from './../../pageModel'; + +/* global fixture:true */ + +fixture`Syncing` + .afterEach(() => checkPropTypes()); + +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); +}); + +async function prepareConflictBetweenAdminAndEditor(t) { + // + // Login as "admin" + // + await switchToRole(t, adminUserOnOneDimensionTestSite); + 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'); + + // + // Publish everything + // + await PublishDropDown.publishAll(); + + // + // Login as "editor" + // + await switchToRole(t, editorUserOnOneDimensionTestSite); + + // + // 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".'); + + // + // Login as "admin" again + // + await switchToRole(t, adminUserOnOneDimensionTestSite); + + // + // 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); + + // + // 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(); + + // + // Login as "admin" again and visit [🗋 Sync Demo #3] + // + await switchToRole(t, adminUserOnOneDimensionTestSite); + await Page.goToPage('Sync Demo #3'); + + // + // 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 + }); +} + +async function switchToRole(t, role) { + await t.useRole(role); + await waitForReact(30000); + await Page.goToPage('Home'); +} + +async function chooseDiscardAllAndFinishSynchronization(t) { + // + // Choose "Discard All" as resolution strategy + // + await t.click(Selector('#neos-SelectResolutionStrategy-SelectBox')); + await t.click(Selector('[role="button"]').withText('Discard workspace "user-admin"')); + await t.click(Selector('#neos-SelectResolutionStrategy-Accept')); + + // + // Go through discard workflow + // + await t.click(Selector('#neos-DiscardDialog-Confirm')); + await t.expect(Selector('#neos-DiscardDialog-Acknowledge').exists) + .ok('Acknowledge button for "Discard all" is not available.', { + timeout: 30000 + }); + // For reasons unknown, we have to press the acknowledge button really + // hard for testcafe to realize our intent... + await t.wait(200); + 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 assertThatSynchronizationWasSuccessful(t) { + // + // Assert that we have been redirected to the home page by checking if + // the currently focused document tree node is "Home". + // + await t + .expect(Selector('[role="treeitem"] [role="button"][class*="isFocused"]').textContent) + .eql('Home'); + + // + // 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".'); +} diff --git a/Tests/IntegrationTests/pageModel.js b/Tests/IntegrationTests/pageModel.js index 4e0e3d6649..4ef3c1be95 100644 --- a/Tests/IntegrationTests/pageModel.js +++ b/Tests/IntegrationTests/pageModel.js @@ -55,6 +55,8 @@ export class PublishDropDown { static publishDropdownDiscardAll = ReactSelector('PublishDropDown ShallowDropDownContents').find('button').withText('Discard all'); + static publishDropdownPublishAll = ReactSelector('PublishDropDown ShallowDropDownContents').find('button').withText('Publish all'); + static async discardAll() { await t.click(this.publishDropdown) @@ -68,6 +70,30 @@ export class PublishDropDown { await t.click(Selector('#neos-DiscardDialog-Confirm')); } await Page.waitForIframeLoading(); + + const acknowledgeButtonExists = await Selector('#neos-DiscardDialog-Acknowledge').exists; + if (acknowledgeButtonExists) { + await t.click(Selector('#neos-DiscardDialog-Acknowledge')); + } + } + + static async publishAll() { + const $publishAllBtn = Selector(this.publishDropdownPublishAll); + const $confirmBtn = Selector('#neos-PublishDialog-Confirm'); + const $acknowledgeBtn = Selector('#neos-PublishDialog-Acknowledge'); + + await t.click(this.publishDropdown) + await t.expect($publishAllBtn.exists) + .ok('"Publish all" button is not available.'); + await t.click($publishAllBtn); + await t.expect($confirmBtn.exists) + .ok('Confirmation button for "Publish all" is not available.'); + await t.click($confirmBtn); + await t.expect($acknowledgeBtn.exists) + .ok('Acknowledge button for "Publish all" is not available.', { + timeout: 30000 + }); + await t.click($acknowledgeBtn); } } diff --git a/Tests/IntegrationTests/utils.js b/Tests/IntegrationTests/utils.js index 92942768c4..77966ea221 100644 --- a/Tests/IntegrationTests/utils.js +++ b/Tests/IntegrationTests/utils.js @@ -6,6 +6,8 @@ export const subSection = name => console.log('\x1b[33m%s\x1b[0m', ' - ' + name) const adminUserName = 'admin'; const adminPassword = 'admin'; +const editorUserName = 'editor'; +const editorPassword = 'editor'; export const getUrl = ClientFunction(() => window.location.href); @@ -21,6 +23,18 @@ export const adminUserOnOneDimensionTestSite = Role('http://onedimension.localho await Page.waitForIframeLoading(); }, {preserveUrl: true}); +export const editorUserOnOneDimensionTestSite = Role('http://onedimension.localhost:8081/neos', async t => { + await t + .typeText('#username', editorUserName) + .typeText('#password', editorPassword) + .click('button.neos-login-btn'); + + await t.expect(getUrl()).contains('/content'); + + await waitForReact(30000); + await Page.waitForIframeLoading(); +}, {preserveUrl: true}); + export const adminUserOnTwoDimensionsTestSite = Role('http://twodimensions.localhost:8081/neos', async t => { await t .typeText('#username', adminUserName) diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx index 8ffa2a0054..c4feb6f686 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx @@ -178,6 +178,7 @@ export const ResolutionStrategySelectionDialog: React.FC<{ fallback="In order to proceed, you need to decide what to do with the conflicting changes:" /> Date: Thu, 11 Apr 2024 23:45:00 +0200 Subject: [PATCH 23/29] TASK: Create E2E test scenario for conflict resolution with "Drop conflicting changes" strategy --- .../Fixtures/1Dimension/syncing.e2e.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js index 6db8af06c9..1e8470f2de 100644 --- a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js +++ b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js @@ -19,7 +19,20 @@ test('Syncing: Create a conflict state between two editors and choose "Discard a await assertThatSynchronizationWasSuccessful(t); }); +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); +}); + 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(); + // // Login as "admin" // @@ -163,6 +176,25 @@ async function chooseDiscardAllAndFinishSynchronization(t) { await t.click(Selector('#neos-SyncWorkspace-Acknowledge')); } +async function chooseDropConflictingChangesAndFinishSynchronization(t) { + // + // Choose "Drop conflicting changes" as resolution strategy + // + 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 + // + await t.click(Selector('#neos-ResolutionStrategyConfirmation-Confirm')); + 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 assertThatSynchronizationWasSuccessful(t) { // // Assert that we have been redirected to the home page by checking if From 6bc82a5cce85b9d55a65fe487edbc2d897745c7b Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 12 Apr 2024 15:50:16 +0200 Subject: [PATCH 24/29] TASK: Stabilize E2E tests --- .../Fixtures/1Dimension/discarding.e2e.js | 4 --- .../Fixtures/1Dimension/syncing.e2e.js | 1 + Tests/IntegrationTests/pageModel.js | 35 ++++++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/Tests/IntegrationTests/Fixtures/1Dimension/discarding.e2e.js b/Tests/IntegrationTests/Fixtures/1Dimension/discarding.e2e.js index 2ad0457556..db382f1d70 100644 --- a/Tests/IntegrationTests/Fixtures/1Dimension/discarding.e2e.js +++ b/Tests/IntegrationTests/Fixtures/1Dimension/discarding.e2e.js @@ -57,7 +57,6 @@ test('Discarding: create a document node and then discard it', async t => { subSection('Discard that node'); await PublishDropDown.discardAll(t); - await t.click(Selector('#neos-DiscardDialog-Acknowledge')); await t .expect(Page.treeNode.withText(pageTitleToCreate).exists).notOk('Discarded node gone from the tree') .expect(ReactSelector('Provider').getReact(({props}) => { @@ -91,7 +90,6 @@ test('Discarding: delete a document node and then discard deletion', async t => subSection('Discard page deletion'); await PublishDropDown.discardAll(t); - await t.click(Selector('#neos-DiscardDialog-Acknowledge')); await t .expect(Page.treeNode.withText(pageTitleToDelete).exists).ok('Deleted node reappeared in the tree'); }); @@ -118,7 +116,6 @@ test('Discarding: create a content node and then discard it', async t => { subSection('Discard that node'); await PublishDropDown.discardAll(t); - await t.click(Selector('#neos-DiscardDialog-Acknowledge')); await t .expect(Page.treeNode.withText(defaultHeadlineTitle).exists).notOk('Discarded node gone from the tree') .expect(ReactSelector('Provider').getReact(({props}) => { @@ -150,7 +147,6 @@ test('Discarding: delete a content node and then discard deletion', async t => { subSection('Discard page deletion'); await PublishDropDown.discardAll(t); - await t.click(Selector('#neos-DiscardDialog-Acknowledge')); await t .expect(Page.treeNode.withText(headlineToDelete).exists).ok('Deleted node reappeared in the tree'); await Page.waitForIframeLoading(t); diff --git a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js index 1e8470f2de..5a148b4b52 100644 --- a/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js +++ b/Tests/IntegrationTests/Fixtures/1Dimension/syncing.e2e.js @@ -32,6 +32,7 @@ async function prepareConflictBetweenAdminAndEditor(t) { // await switchToRole(t, editorUserOnOneDimensionTestSite); await Page.waitForIframeLoading(); + await t.wait(2000); // // Login as "admin" diff --git a/Tests/IntegrationTests/pageModel.js b/Tests/IntegrationTests/pageModel.js index 4ef3c1be95..d9f6139272 100644 --- a/Tests/IntegrationTests/pageModel.js +++ b/Tests/IntegrationTests/pageModel.js @@ -58,23 +58,27 @@ export class PublishDropDown { static publishDropdownPublishAll = ReactSelector('PublishDropDown ShallowDropDownContents').find('button').withText('Publish all'); static async discardAll() { - await t.click(this.publishDropdown) + const $discardAllBtn = Selector(this.publishDropdownDiscardAll); + const $confirmBtn = Selector('#neos-DiscardDialog-Confirm'); + const $acknowledgeBtn = Selector('#neos-DiscardDialog-Acknowledge'); - const publishDropdownDiscardAllExists = await Selector(this.publishDropdownDiscardAll).exists; - if (publishDropdownDiscardAllExists) { - await t.click(this.publishDropdownDiscardAll); - } + await t.click(this.publishDropdown) + await t.expect($discardAllBtn.exists) + .ok('"Discard all" button is not available.'); - const confirmButtonExists = await Selector('#neos-DiscardDialog-Confirm').exists; - if (confirmButtonExists) { - await t.click(Selector('#neos-DiscardDialog-Confirm')); + if (await $discardAllBtn.hasAttribute('disabled')) { + return; } - await Page.waitForIframeLoading(); - const acknowledgeButtonExists = await Selector('#neos-DiscardDialog-Acknowledge').exists; - if (acknowledgeButtonExists) { - await t.click(Selector('#neos-DiscardDialog-Acknowledge')); - } + await t.click($discardAllBtn); + await t.expect($confirmBtn.exists) + .ok('Confirmation button for "Discard all" is not available.'); + await t.click($confirmBtn); + await t.expect($acknowledgeBtn.exists) + .ok('Acknowledge button for "Discard all" is not available.', { + timeout: 30000 + }); + await t.click($acknowledgeBtn); } static async publishAll() { @@ -85,6 +89,11 @@ export class PublishDropDown { await t.click(this.publishDropdown) await t.expect($publishAllBtn.exists) .ok('"Publish all" button is not available.'); + + if (await $publishAllBtn.hasAttribute('disabled')) { + return; + } + await t.click($publishAllBtn); await t.expect($confirmBtn.exists) .ok('Confirmation button for "Publish all" is not available.'); From 5b383c15bbc743981202cef5e19905eb2cc05d96 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 12 Apr 2024 16:17:45 +0200 Subject: [PATCH 25/29] TASK: Remove legacy `WorkspaceStatus.OUTDATED_CONFLICT` from ts-interfaces --- packages/neos-ts-interfaces/src/index.ts | 3 +-- packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts | 5 +---- .../PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/neos-ts-interfaces/src/index.ts b/packages/neos-ts-interfaces/src/index.ts index b8dc49753e..0f47449fa0 100644 --- a/packages/neos-ts-interfaces/src/index.ts +++ b/packages/neos-ts-interfaces/src/index.ts @@ -103,8 +103,7 @@ export enum SelectionModeTypes { export enum WorkspaceStatus { UP_TO_DATE = 'UP_TO_DATE', - OUTDATED = 'OUTDATED', - OUTDATED_CONFLICT = 'OUTDATED_CONFLICT' + OUTDATED = 'OUTDATED' } export interface ValidatorConfiguration { diff --git a/packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts b/packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts index 29c0767ba8..d79fd80f4c 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts @@ -1,7 +1,7 @@ import {createSelector} from 'reselect'; import {documentNodeContextPathSelector} from '../Nodes/selectors'; import {GlobalState} from '../../System'; -import {NodeContextPath, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; +import {NodeContextPath} from '@neos-project/neos-ts-interfaces'; export const personalWorkspaceNameSelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.name; @@ -10,9 +10,6 @@ export const personalWorkspaceRebaseStatusSelector = (state: GlobalState) => sta export const baseWorkspaceSelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.baseWorkspace; export const isWorkspaceReadOnlySelector = (state: GlobalState) => { - if (state?.cr?.workspaces?.personalWorkspace?.status === WorkspaceStatus.OUTDATED_CONFLICT) { - return true; - } return state?.cr?.workspaces?.personalWorkspace?.readOnly || false }; diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx index 219d02c4d3..51a2d2b434 100644 --- a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSync.tsx @@ -70,9 +70,7 @@ const WorkspaceSync: React.FC = (props) => { hoverStyle={props.personalWorkspaceStatus === WorkspaceStatus.OUTDATED ? 'warn' : 'error'} title={buttonTitle} > - +
    ); From 18c7da9eafbac7665f0731a27dad99123a27caf4 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 12 Apr 2024 16:20:21 +0200 Subject: [PATCH 26/29] TASK: Remove legacy `state.ui.remote.isSyncing` --- .../src/UI/Remote/index.ts | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/neos-ui-redux-store/src/UI/Remote/index.ts b/packages/neos-ui-redux-store/src/UI/Remote/index.ts index b9b1fcb0d2..f26267ed1a 100644 --- a/packages/neos-ui-redux-store/src/UI/Remote/index.ts +++ b/packages/neos-ui-redux-store/src/UI/Remote/index.ts @@ -5,13 +5,11 @@ import {InitAction} from '../../System'; import {NodeContextPath} from '@neos-project/neos-ts-interfaces'; export interface State extends Readonly<{ - isSaving: boolean, - isSyncing: boolean + isSaving: boolean }> {} export const defaultState: State = { - isSaving: false, - isSyncing: false + isSaving: false }; // @@ -22,8 +20,6 @@ export enum actionTypes { FINISH_SAVING = '@neos/neos-ui/UI/Remote/FINISH_SAVING', LOCK_PUBLISHING = '@neos/neos-ui/UI/Remote/LOCK_PUBLISHING', UNLOCK_PUBLISHING = '@neos/neos-ui/UI/Remote/UNLOCK_PUBLISHING', - START_SYNCHRONIZATION = '@neos/neos-ui/UI/Remote/START_SYNCHRONIZATION', - FINISH_SYNCHRONIZATION = '@neos/neos-ui/UI/Remote/FINISH_SYNCHRONIZATION', DOCUMENT_NODE_CREATED = '@neos/neos-ui/UI/Remote/DOCUMENT_NODE_CREATED' } @@ -37,16 +33,6 @@ const startSaving = () => createAction(actionTypes.START_SAVING); */ const finishSaving = () => createAction(actionTypes.FINISH_SAVING); -/** - * Marks an ongoing synchronization process. - */ -const startSynchronization = () => createAction(actionTypes.START_SYNCHRONIZATION); - -/** - * Marks that an ongoing synchronization process has finished. - */ -const finishSynchronization = () => createAction(actionTypes.FINISH_SYNCHRONIZATION); - /** * Marks that an publishing process has been locked. */ @@ -70,8 +56,6 @@ export const actions = { finishSaving, lockPublishing, unlockPublishing, - startSynchronization, - finishSynchronization, documentNodeCreated }; @@ -98,14 +82,6 @@ export const reducer = (state: State = defaultState, action: InitAction | Action draft.isSaving = false; break; } - case actionTypes.START_SYNCHRONIZATION: { - draft.isSyncing = true; - break; - } - case actionTypes.FINISH_SYNCHRONIZATION: { - draft.isSyncing = false; - break; - } } }); From f3ac50155466246506a185e6726310f979d43452 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 12 Apr 2024 16:29:23 +0200 Subject: [PATCH 27/29] TASK: Remove obsolete i18n labels Namely: Neos.Neos.Ui:Main:workspaceSynchronizationApplied Neos.Neos.Ui:Main:syncPersonalWorkSpaceConfirm Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessage Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessageOutdated Neos.Neos.Ui:Main:syncPersonalWorkSpaceMessageOutdatedConflict --- Resources/Private/Translations/en/Main.xlf | 17 ------------- Resources/Private/Translations/es/Main.xlf | 28 ---------------------- Resources/Private/Translations/nl/Main.xlf | 24 ------------------- Resources/Private/Translations/pt/Main.xlf | 24 ------------------- 4 files changed, 93 deletions(-) diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index 112f57df44..810ab1cb43 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -347,26 +347,9 @@ Delete {amount} nodes - - Successfully synced "{workspaceName}" workspace. - Synchronize personal workspace - - Synchronize now - - - Your personal workspace is up-to-date with the current workspace. - - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - You should synchronize your personal workspace to avoid conflicts. - - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - The changes lead to an error state. Please contact your administrator to resolve the problem. - Minimum diff --git a/Resources/Private/Translations/es/Main.xlf b/Resources/Private/Translations/es/Main.xlf index 2a1ba84fda..82ddbeadaf 100644 --- a/Resources/Private/Translations/es/Main.xlf +++ b/Resources/Private/Translations/es/Main.xlf @@ -538,38 +538,10 @@ Add Añadir - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - You should synchronize your personal workspace to avoid conflicts. - Parece que hay cambios en el espacio de trabajo que no se reflejan en su espacio de trabajo personal. - Debería sincronizar su espacio de trabajo personal para evitar conflictos. - - - Successfully synced "{workspaceName}" workspace. - Sincronizado con éxito el espacio de trabajo "{workspaceName}". - - - Your personal workspace is up-to-date with the current workspace. - Su espacio de trabajo personal está actualizado con el espacio de trabajo actual. - Synchronize personal workspace Sincronizar el espacio de trabajo personal - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - The changes lead to an error state. Please contact your administrator to resolve the problem. - Parece que hay cambios en el espacio de trabajo que no se reflejan en su espacio de trabajo personal. - Los cambios provocan un estado de error. Póngase en contacto con su administrador para resolver el problema. - - - Synchronize now - Sincronizar ahora - - - Copy node type to clipboard - Copiar tipo de nodo al portapapeles - diff --git a/Resources/Private/Translations/nl/Main.xlf b/Resources/Private/Translations/nl/Main.xlf index 8ea20edd85..393a0b03da 100644 --- a/Resources/Private/Translations/nl/Main.xlf +++ b/Resources/Private/Translations/nl/Main.xlf @@ -523,34 +523,10 @@ Add Voeg toe - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - You should synchronize your personal workspace to avoid conflicts. - Het lijkt erop dat er wijzigingen zijn in het werkblad die niet in uw persoonlijke werkblad zitten. -U wordt aangeraden uw persoonlijke werkblad te synchroniseren om conflicten te voorkomen. - - - Successfully synced "{workspaceName}" workspace. - Werkblad "{workspaceName}" succesvol gesynchroniseerd. - - - Your personal workspace is up-to-date with the current workspace. - Uw persoonlijke werkblad is bijgewerkt met het huidige werkblad. - Synchronize personal workspace Synchroniseer persoonlijk werkblad - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - The changes lead to an error state. Please contact your administrator to resolve the problem. - Het lijkt erop dat er wijzigingen zijn in het werkblad die niet in uw persoonlijke werkblad zitten. -De wijzigingen hebben geresulteerd in een foutmelding. Neemt u alstublieft contact op met de beheerder om het probleem op te lossen. - - - Synchronize now - Nu synchroniseren - diff --git a/Resources/Private/Translations/pt/Main.xlf b/Resources/Private/Translations/pt/Main.xlf index c81ce6168c..2cf512187d 100644 --- a/Resources/Private/Translations/pt/Main.xlf +++ b/Resources/Private/Translations/pt/Main.xlf @@ -421,24 +421,10 @@ Format options Opções de formatação - - Successfully synced "{workspaceName}" workspace. - Espaço de trabalho "{workspaceName}" sincronizado com sucesso. - Synchronize personal workspace Sincronizar espaço de trabalho pessoal - - Your personal workspace is up-to-date with the current workspace. - O seu espaço de trabalho pessoal está atualizado com o espaço de trabalho atual. - - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - You should synchronize your personal workspace to avoid conflicts. - Parece que há alterações no espaço de trabalho que não se refletem no seu espaço de trabalho pessoal. -Você deve sincronizar o seu espaço de trabalho pessoal para evitar conflitos. - Technical details copied Pormenores técnicos copiados @@ -483,16 +469,6 @@ Você deve sincronizar o seu espaço de trabalho pessoal para evitar conflitos.< Headline 4 Título 4 - - Synchronize now - Sincronizar agora - - - It seems like there are changes in the workspace that are not reflected in your personal workspace. - The changes lead to an error state. Please contact your administrator to resolve the problem. - Parece que há alterações no espaço de trabalho que não se refletem no seu espaço de trabalho pessoal. -As mudanças levam a um estado de erro. Entre em contacto com o seu administrador para resolver o problema. - Copy technical details Copiar pormenores técnicos From b8d7d031deaa8b9a4712907fda8203ed77bc3246 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Fri, 26 Apr 2024 18:55:30 +0200 Subject: [PATCH 28/29] TASK: Remove remaining ad-hoc typings for backend endpoints see: https://github.com/neos/neos-ui/pull/3759#discussion_r1579433249 --- .../src/CR/NodeOperations/reloadNodes.ts | 9 ++------- packages/neos-ui-sagas/src/Sync/index.ts | 11 +++-------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/neos-ui-sagas/src/CR/NodeOperations/reloadNodes.ts b/packages/neos-ui-sagas/src/CR/NodeOperations/reloadNodes.ts index 2cc03d6631..73501d63e1 100644 --- a/packages/neos-ui-sagas/src/CR/NodeOperations/reloadNodes.ts +++ b/packages/neos-ui-sagas/src/CR/NodeOperations/reloadNodes.ts @@ -16,12 +16,7 @@ import {AnyError} from '@neos-project/neos-ui-error'; import backend from '@neos-project/neos-ui-backend-connector'; // @ts-ignore import {getGuestFrameDocument} from '@neos-project/neos-ui-guest-frame/src/dom'; - -// @TODO: This is a helper to gain type access to the available backend endpoints. -// It shouldn't be necessary to do this, and this hack should be removed once a -// better type API is available -import {default as Endpoints, Routes} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; -type Endpoints = ReturnType; +import {Routes} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; type ReloadNodesResponse = | { @@ -36,7 +31,7 @@ export const makeReloadNodes = (deps: { routes?: Routes; }) => { const redirectToDefaultModule = makeRedirectToDefaultModule(deps); - const {reloadNodes: reloadNodesEndpoint} = backend.get().endpoints as Endpoints; + const {reloadNodes: reloadNodesEndpoint} = backend.get().endpoints; return function * reloadNodes() { const workspaceName: WorkspaceName = yield select( diff --git a/packages/neos-ui-sagas/src/Sync/index.ts b/packages/neos-ui-sagas/src/Sync/index.ts index b6270a5f10..c9ad7ea86f 100644 --- a/packages/neos-ui-sagas/src/Sync/index.ts +++ b/packages/neos-ui-sagas/src/Sync/index.ts @@ -16,12 +16,7 @@ import backend from '@neos-project/neos-ui-backend-connector'; import {PublishingMode, PublishingScope} from '@neos-project/neos-ui-redux-store/src/CR/Publishing'; import {Conflict, ResolutionStrategy} from '@neos-project/neos-ui-redux-store/src/CR/Syncing'; import {WorkspaceInformation} from '@neos-project/neos-ui-redux-store/src/CR/Workspaces'; - -// @TODO: This is a helper to gain type access to the available backend endpoints. -// It shouldn't be necessary to do this, and this hack should be removed once a -// better type API is available -import {default as Endpoints, Routes} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; -type Endpoints = ReturnType; +import {Routes} from '@neos-project/neos-ui-backend-connector/src/Endpoints'; import {makeReloadNodes} from '../CR/NodeOperations/reloadNodes'; @@ -69,7 +64,7 @@ const makeSyncPersonalWorkspace = (deps: { const resolveConflicts = makeResolveConflicts({syncPersonalWorkspace}); function * syncPersonalWorkspace(force: boolean) { - const {syncWorkspace} = backend.get().endpoints as Endpoints; + const {syncWorkspace} = backend.get().endpoints; const personalWorkspaceName: WorkspaceName = yield select(selectors.CR.Workspaces.personalWorkspaceNameSelector); const dimensionSpacePoint: null|DimensionCombination = yield select(selectors.CR.ContentDimensions.active); @@ -178,7 +173,7 @@ const makeDiscardAll = (deps: { const makeRefreshAfterSyncing = (deps: { routes: Routes }) => { - const {getWorkspaceInfo} = backend.get().endpoints as Endpoints; + const {getWorkspaceInfo} = backend.get().endpoints; const reloadNodes = makeReloadNodes(deps); function * refreshAfterSyncing() { From 8d2d1cb62116f9afcd052ad07bec6af16054b565 Mon Sep 17 00:00:00 2001 From: Wilhelm Behncke Date: Mon, 29 Apr 2024 12:31:16 +0200 Subject: [PATCH 29/29] TASK: Retrieve NodeType from NodeTypeManager in ConflictsBuilder --- Classes/Application/SyncWorkspace/ConflictsBuilder.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Classes/Application/SyncWorkspace/ConflictsBuilder.php b/Classes/Application/SyncWorkspace/ConflictsBuilder.php index a8095409c4..e589c24906 100644 --- a/Classes/Application/SyncWorkspace/ConflictsBuilder.php +++ b/Classes/Application/SyncWorkspace/ConflictsBuilder.php @@ -33,6 +33,7 @@ use Neos\ContentRepository\Core\Feature\SubtreeTagging\Command\UntagSubtree; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandsThatFailedDuringRebase; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\CommandThatFailedDuringRebase; +use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindClosestNodeFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; @@ -56,6 +57,8 @@ #[Flow\Proxy(false)] final class ConflictsBuilder { + private NodeTypeManager $nodeTypeManager; + private ?Workspace $workspace; /** @@ -73,6 +76,8 @@ public function __construct( WorkspaceName $workspaceName, private ?DimensionSpacePoint $preferredDimensionSpacePoint, ) { + $this->nodeTypeManager = $contentRepository->getNodeTypeManager(); + $this->workspace = $contentRepository->getWorkspaceFinder() ->findOneByName($workspaceName); } @@ -250,8 +255,10 @@ private function extractValidDimensionSpacePointFromNodeAggregate( private function createIconLabelForNode(Node $node): IconLabel { + $nodeType = $this->nodeTypeManager->getNodeType($node->nodeTypeName); + return new IconLabel( - icon: $node->nodeType?->getConfiguration('ui.icon') ?? 'questionmark', + icon: $nodeType?->getConfiguration('ui.icon') ?? 'questionmark', label: $node->getLabel() ); }