diff --git a/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php b/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php index d090fdb815..78ef6d12cc 100644 --- a/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php +++ b/Classes/Application/PublishChangesInDocument/PublishChangesInDocumentCommandHandler.php @@ -15,6 +15,7 @@ namespace Neos\Neos\Ui\Application\PublishChangesInDocument; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\PartialWorkspaceRebaseFailed; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateCurrentlyDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateDoesCurrentlyNotCoverDimensionSpacePoint; use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry; @@ -51,7 +52,8 @@ final class PublishChangesInDocumentCommandHandler */ public function handle( PublishChangesInDocumentCommand $command - ): PublishSucceeded|ConflictsOccurred { + ): PublishSucceeded|ConflictsOccurred|PartialPublishFailed + { try { $publishingResult = $this->workspacePublishingService->publishChangesInDocument( $command->contentRepositoryId, @@ -67,6 +69,32 @@ public function handle( numberOfAffectedChanges: $publishingResult->numberOfPublishedChanges, baseWorkspaceName: $workspace?->baseWorkspaceName?->value ); + } catch (WorkspaceRebaseFailed $e) { + $conflictsFactory = new ConflictsFactory( + contentRepository: $this->contentRepositoryRegistry + ->get($command->contentRepositoryId), + nodeLabelGenerator: $this->nodeLabelGenerator, + workspaceName: $command->workspaceName, + preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint + ); + + return new ConflictsOccurred( + conflicts: $conflictsFactory->fromWorkspaceRebaseFailed($e), + isPartialPublish: false + ); + } catch (PartialWorkspaceRebaseFailed $e) { + $conflictsFactory = new ConflictsFactory( + contentRepository: $this->contentRepositoryRegistry + ->get($command->contentRepositoryId), + nodeLabelGenerator: $this->nodeLabelGenerator, + workspaceName: $command->workspaceName, + preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint + ); + + return new ConflictsOccurred( + conflicts: $conflictsFactory->fromPartialWorkspaceRebaseFailed($e), + isPartialPublish: true + ); } catch (NodeAggregateCurrentlyDoesNotExist $e) { throw new \RuntimeException( $this->getLabel('NodeNotPublishedMissingParentNode'), @@ -79,18 +107,7 @@ public function handle( 1705053432, $e ); - } catch (WorkspaceRebaseFailed $e) { - $conflictsFactory = new ConflictsFactory( - contentRepository: $this->contentRepositoryRegistry - ->get($command->contentRepositoryId), - nodeLabelGenerator: $this->nodeLabelGenerator, - workspaceName: $command->workspaceName, - preferredDimensionSpacePoint: $command->preferredDimensionSpacePoint - ); - return new ConflictsOccurred( - conflicts: $conflictsFactory->fromWorkspaceRebaseFailed($e) - ); } } } diff --git a/Classes/Application/Shared/ConflictsOccurred.php b/Classes/Application/Shared/ConflictsOccurred.php index fcf1817f5a..6c721a4b47 100644 --- a/Classes/Application/Shared/ConflictsOccurred.php +++ b/Classes/Application/Shared/ConflictsOccurred.php @@ -23,7 +23,8 @@ final readonly class ConflictsOccurred implements \JsonSerializable { public function __construct( - public readonly Conflicts $conflicts + public readonly Conflicts $conflicts, + public readonly bool $isPartialPublish = true ) { } diff --git a/Classes/Infrastructure/ContentRepository/ConflictsFactory.php b/Classes/Infrastructure/ContentRepository/ConflictsFactory.php index 214e7bcd1c..4a5f768454 100644 --- a/Classes/Infrastructure/ContentRepository/ConflictsFactory.php +++ b/Classes/Infrastructure/ContentRepository/ConflictsFactory.php @@ -29,6 +29,7 @@ use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasTagged; use Neos\ContentRepository\Core\Feature\SubtreeTagging\Event\SubtreeWasUntagged; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\ConflictingEvent; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\PartialWorkspaceRebaseFailed; use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\NodeType\NodeTypeManager; use Neos\ContentRepository\Core\Projection\ContentGraph\ContentSubgraphInterface; @@ -87,6 +88,23 @@ public function fromWorkspaceRebaseFailed( return new Conflicts(...$conflictsByKey); } + public function fromPartialWorkspaceRebaseFailed( + PartialWorkspaceRebaseFailed $partialWorkspaceRebaseFailed + ): Conflicts { + /** @var array */ + $conflictsByKey = []; + + foreach ($partialWorkspaceRebaseFailed->conflictingEvents as $conflictingEvent) { + $conflict = $this->createConflict($conflictingEvent); + if (!array_key_exists($conflict->key, $conflictsByKey)) { + // deduplicate if the conflict affects the same node + $conflictsByKey[$conflict->key] = $conflict; + } + } + + return new Conflicts(...$conflictsByKey); + } + private function createConflict( ConflictingEvent $conflictingEvent ): Conflict { diff --git a/Resources/Private/Translations/en/PublishingDialog.xlf b/Resources/Private/Translations/en/PublishingDialog.xlf index 3c86b9ce2b..7ca6fab54b 100644 --- a/Resources/Private/Translations/en/PublishingDialog.xlf +++ b/Resources/Private/Translations/en/PublishingDialog.xlf @@ -218,6 +218,15 @@ OK + + Could not publish all changes in "{scopeTitle}" + + + Some changes in this document are dependant on changes in other documents. Do you want to publish all changes to the workspace "{targetWorkspaceName}"? + + + Yes, publish all changes + diff --git a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf index e943c7056a..29f78cdc5e 100644 --- a/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf +++ b/Resources/Private/Translations/en/SyncWorkspaceDialog.xlf @@ -44,6 +44,12 @@ This will discard all changes in your workspace, including those on other sites. + + Publish all changes to workspace "{workspaceName}" + + + This will publish all changes in your workspace, including those on other sites. + Cancel Synchronization @@ -58,12 +64,26 @@ Do you wish to proceed? Be careful: This cannot be undone! + + Publish all changes in workspace "{workspaceName}" + + + You are about to publish 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 + + No, cancel + + + Yes, publish everything + You are about to drop the following changes: 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 ea6b159677..c6f1c7b853 100644 --- a/packages/neos-ui-redux-store/src/CR/Syncing/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Syncing/index.ts @@ -24,7 +24,8 @@ export enum SyncingPhase { export enum ResolutionStrategy { FORCE, - DISCARD_ALL + DISCARD_ALL, + PUBLISH_ALL } export enum ReasonForConflict { @@ -106,8 +107,8 @@ const confirm = () => createAction(actionTypes.CONFIRMED); /** * Signal that conflicts occurred during the ongoing syncing (rebasing) workflow */ -const resolve = (conflicts: Conflict[]) => - createAction(actionTypes.CONFLICTS_DETECTED, {conflicts}); +const resolve = (conflicts: Conflict[], strategy: ResolutionStrategy) => + createAction(actionTypes.CONFLICTS_DETECTED, {conflicts, strategy}); /** * Initiates the process of resolving a conflict that occurred @@ -192,7 +193,7 @@ export const reducer = (state: State = defaultState, action: Action): State => { process: { phase: SyncingPhase.CONFLICT, conflicts: action.payload.conflicts, - strategy: null + strategy: action.payload.strategy } }; } diff --git a/packages/neos-ui-sagas/src/Publish/index.ts b/packages/neos-ui-sagas/src/Publish/index.ts index df2c098ee4..65d1a3c9fd 100644 --- a/packages/neos-ui-sagas/src/Publish/index.ts +++ b/packages/neos-ui-sagas/src/Publish/index.ts @@ -34,7 +34,7 @@ type PublishingResponse = numberOfAffectedChanges: number; } } - | { conflicts: Conflict[] } + | { conflicts: Conflict[], isPartialPublish: boolean } | { error: AnyError }; export function * watchPublishing({routes}: {routes: Routes}) { @@ -75,6 +75,7 @@ export function * watchPublishing({routes}: {routes: Routes}) { yield takeEvery(actionTypes.CR.Publishing.STARTED, function * publishingWorkflow(action: ReturnType) { const confirmed = yield * waitForConfirmation(); + if (!confirmed) { return; } @@ -92,7 +93,6 @@ export function * watchPublishing({routes}: {routes: Routes}) { const ancestorId: NodeContextPath = ancestorIdSelector ? yield select(ancestorIdSelector) : null; - function * attemptToPublishOrDiscard(): Generator { const result: PublishingResponse = scope === PublishingScope.ALL ? yield call(endpoint as any, workspaceName) @@ -104,7 +104,7 @@ export function * watchPublishing({routes}: {routes: Routes}) { } else if ('conflicts' in result) { yield put(actions.CR.Publishing.conflicts()); const conflictsWereResolved: boolean = - yield * resolveConflicts(result.conflicts); + yield * resolveConflicts(result.conflicts, result.isPartialPublish); if (conflictsWereResolved) { yield put(actions.CR.Publishing.resolveConflicts()); diff --git a/packages/neos-ui-sagas/src/Sync/index.ts b/packages/neos-ui-sagas/src/Sync/index.ts index a6f2fca4ab..71485c6c48 100644 --- a/packages/neos-ui-sagas/src/Sync/index.ts +++ b/packages/neos-ui-sagas/src/Sync/index.ts @@ -28,7 +28,7 @@ const handleWindowBeforeUnload = (event: BeforeUnloadEvent) => { type SyncWorkspaceResult = | { success: true } - | { conflicts: Conflict[] } + | { conflicts: Conflict[], isPartialPublish: false } | { error: AnyError }; export function * watchSyncing({routes}: {routes: Routes}) { @@ -75,7 +75,7 @@ export const makeSyncPersonalWorkspace = (deps: { yield * refreshAfterSyncing(); yield put(actions.CR.Syncing.succeed()); } else if ('conflicts' in result) { - yield * resolveConflicts(result.conflicts); + yield * resolveConflicts(result.conflicts, result.isPartialPublish); } else { yield put(actions.CR.Syncing.fail(result.error)); } @@ -93,10 +93,12 @@ export const makeResolveConflicts = (deps: { syncPersonalWorkspace: ReturnType }) => { const discardAll = makeDiscardAll(deps); + const publishAll = makePublishAll(); - function * resolveConflicts(conflicts: Conflict[]): any { + function * resolveConflicts(conflicts: Conflict[], isPartialPublish: boolean): any { while (true) { - yield put(actions.CR.Syncing.resolve(conflicts)); + const defaultResolutionStrategy = isPartialPublish ? ResolutionStrategy.PUBLISH_ALL : ResolutionStrategy.FORCE + yield put(actions.CR.Syncing.resolve(conflicts, defaultResolutionStrategy)); const {started}: { cancelled: null | ReturnType; @@ -105,10 +107,8 @@ export const makeResolveConflicts = (deps: { cancelled: take(actionTypes.CR.Syncing.CANCELLED), started: take(actionTypes.CR.Syncing.RESOLUTION_STARTED) }); - if (started) { const {payload: {strategy}} = started; - if (strategy === ResolutionStrategy.FORCE) { if (yield * waitForResolutionConfirmation()) { yield * deps.syncPersonalWorkspace(true); @@ -119,8 +119,21 @@ export const makeResolveConflicts = (deps: { } if (strategy === ResolutionStrategy.DISCARD_ALL) { - yield * discardAll(); - return true; + if (yield * waitForResolutionConfirmation()) { + yield * discardAll(); + return true; + } + + continue; + } + + if (strategy === ResolutionStrategy.PUBLISH_ALL) { + if (yield * waitForResolutionConfirmation()) { + yield * publishAll(); + return true; + } + + continue; } } @@ -163,7 +176,7 @@ const makeDiscardAll = (deps: { PublishingMode.DISCARD, PublishingScope.ALL )); - + yield put(actions.CR.Publishing.confirm()); const {cancelled, failed}: { cancelled: null | ReturnType; failed: null | ReturnType; @@ -179,7 +192,7 @@ const makeDiscardAll = (deps: { } else if (failed) { yield put(actions.CR.Syncing.finish()); } else { - yield put(actions.CR.Syncing.confirmResolution()); + yield put(actions.CR.Syncing.finish()); yield * deps.syncPersonalWorkspace(false); } } @@ -187,6 +200,34 @@ const makeDiscardAll = (deps: { return discardAll; } +const makePublishAll = () => { + function * publishAll() { + yield put(actions.CR.Publishing.start( + PublishingMode.PUBLISH, + PublishingScope.SITE + )); + yield put(actions.CR.Publishing.confirm()); + const {cancelled, failed, finished}: { + 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.finish()); + } + } + + return publishAll; +} + const makeRefreshAfterSyncing = (deps: { routes: Routes }) => { diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx index c0e67a104a..cf95638e20 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategyConfirmationDialog.tsx @@ -34,6 +34,9 @@ export const ResolutionStrategyConfirmationDialog: React.FC<{ switch (props.strategy) { case ResolutionStrategy.FORCE: return (); + + case ResolutionStrategy.PUBLISH_ALL: + return (); case ResolutionStrategy.DISCARD_ALL: default: return (); @@ -182,3 +185,73 @@ const DiscardAllConfirmationDialog: React.FC<{ ); } +const PublishAllConfirmationDialog: React.FC<{ + workspaceName: WorkspaceName; + baseWorkspaceName: 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/ResolutionStrategySelectionDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx index c4feb6f686..f086027b1a 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/ResolutionStrategySelectionDialog.tsx @@ -47,6 +47,20 @@ const VARIANTS_BY_RESOLUTION_STRATEGY = { fallback: 'This will discard all changes in your workspace, including those on other sites.' } } + }, + [ResolutionStrategy.PUBLISH_ALL]: { + icon: 'check-double', + labels: { + label: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:resolutionStrategy.selection.option.PUBLISH_ALL.label', + fallback: (props: {workspaceName: WorkspaceName}) => + `Publish all changes in workspace "${props.workspaceName}"` + }, + description: { + id: 'Neos.Neos.Ui:SyncWorkspaceDialog:resolutionStrategy.selection.option.PUBLISH_ALL.description', + fallback: 'This will publish all changes in your workspace, including those on other sites.' + } + } } } as const; @@ -56,6 +70,9 @@ const OPTIONS_FOR_RESOLUTION_STRATEGY_SELECTION = [ }, { value: ResolutionStrategy.DISCARD_ALL + }, + { + value: ResolutionStrategy.PUBLISH_ALL } ] as const; diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx index 2da8d0a0fd..830ee35151 100644 --- a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/SyncWorkspaceDialog.tsx @@ -93,7 +93,6 @@ const SyncWorkspaceDialog: React.FC = (props) => { const handleRetry = React.useCallback(() => { props.retry(); }, []); - switch (props.syncingState?.process.phase) { case SyncingPhase.START: return ( @@ -124,21 +123,18 @@ const SyncWorkspaceDialog: React.FC = (props) => { /> ); case SyncingPhase.RESOLVING: - if (props.syncingState.process.strategy === ResolutionStrategy.FORCE) { - return ( - - ); - } - return null; + return ( + + ); case SyncingPhase.ERROR: case SyncingPhase.SUCCESS: return (