diff --git a/Classes/Controller/BackendServiceController.php b/Classes/Controller/BackendServiceController.php index af8e6a8b3a..ab9715a03a 100644 --- a/Classes/Controller/BackendServiceController.php +++ b/Classes/Controller/BackendServiceController.php @@ -19,6 +19,7 @@ use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdsToPublishOrDiscard; use Neos\ContentRepository\Core\Feature\WorkspacePublication\Dto\NodeIdToPublishOrDiscard; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Command\RebaseWorkspace; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; use Neos\ContentRepository\Core\SharedModel\Exception\NodeAggregateCurrentlyDoesNotExist; use Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName; @@ -56,6 +57,8 @@ class BackendServiceController extends ActionController { + use TranslationTrait; + /** * @var array */ @@ -618,4 +621,36 @@ public function generateUriPathSegmentAction(string $contextNode, string $text): $slug = $this->nodeUriPathSegmentGenerator->generateUriPathSegment($contextNode, $text); $this->view->assign('value', $slug); } + + /** + * Rebase user workspace to current workspace + * + * @param string $targetWorkspaceName + * @return void + */ + public function rebaseWorkspaceAction(string $targetWorkspaceName): void + { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + + $command = RebaseWorkspace::create(WorkspaceName::fromString($targetWorkspaceName)); + try { + $contentRepository->handle($command)->block(); + } catch (\Exception $exception) { + $error = new Error(); + $error->setMessage($error->getMessage()); + + $this->feedbackCollection->add($error); + $this->view->assign('value', $this->feedbackCollection); + return; + } + + $success = new Success(); + $success->setMessage( + $this->getLabel('workspaceSynchronizationApplied', ['workspaceName' => $targetWorkspaceName]) + ); + $this->feedbackCollection->add($success); + + $this->view->assign('value', $this->feedbackCollection); + } } diff --git a/Classes/Controller/TranslationTrait.php b/Classes/Controller/TranslationTrait.php new file mode 100644 index 0000000000..fb353f51fe --- /dev/null +++ b/Classes/Controller/TranslationTrait.php @@ -0,0 +1,42 @@ + $arguments + */ + public function getLabel(string $id, array $arguments = []): string + { + return $this->translator->translateById( + $id, + $arguments, + null, + null, + 'Main', + 'Neos.Neos.Ui' + ) ?: $id; + } +} diff --git a/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php b/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php index 2cdce7bb69..4f9d7a8b11 100644 --- a/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php +++ b/Classes/Domain/Model/Feedback/Operations/UpdateWorkspaceInfo.php @@ -125,7 +125,8 @@ public function serializePayload(ControllerContext $controllerContext) $this->workspaceName, $this->contentRepositoryId ), - 'baseWorkspace' => $workspace->baseWorkspaceName->value + 'baseWorkspace' => $workspace->baseWorkspaceName->value, + 'status' => $workspace->status ] : []; } } diff --git a/Classes/Fusion/Helper/WorkspaceHelper.php b/Classes/Fusion/Helper/WorkspaceHelper.php index 7d3c8c14b3..e58c20be2e 100644 --- a/Classes/Fusion/Helper/WorkspaceHelper.php +++ b/Classes/Fusion/Helper/WorkspaceHelper.php @@ -75,7 +75,8 @@ public function getPersonalWorkspace(ContentRepositoryId $contentRepositoryId): 'baseWorkspace' => $personalWorkspace->baseWorkspaceName, // TODO: FIX readonly flag! //'readOnly' => !$this->domainUserService->currentUserCanPublishToWorkspace($baseWorkspace) - 'readOnly' => false + 'readOnly' => false, + 'status' => $personalWorkspace->status->value ] : []; } diff --git a/Configuration/Routes.Service.yaml b/Configuration/Routes.Service.yaml index a4ad58b2e8..e98270928e 100644 --- a/Configuration/Routes.Service.yaml +++ b/Configuration/Routes.Service.yaml @@ -30,6 +30,13 @@ '@action': 'changeBaseWorkspace' httpMethods: ['POST'] +- + name: 'Rebase Workspace' + uriPattern: 'rebase-workspace' + defaults: + '@controller': 'BackendService' + '@action': 'rebaseWorkspace' + httpMethods: ['POST'] - name: 'Copy nodes to clipboard' uriPattern: 'copy-nodes' diff --git a/Resources/Private/Fusion/Backend/Root.fusion b/Resources/Private/Fusion/Backend/Root.fusion index af6e00a74b..2374d84821 100644 --- a/Resources/Private/Fusion/Backend/Root.fusion +++ b/Resources/Private/Fusion/Backend/Root.fusion @@ -116,6 +116,9 @@ backend = Neos.Fusion:Template { changeBaseWorkspace = Neos.Fusion:UriBuilder { action = 'changeBaseWorkspace' } + rebaseWorkspace = Neos.Fusion:UriBuilder { + action = 'rebaseWorkspace' + } copyNodes = Neos.Fusion:UriBuilder { action = 'copyNodes' } diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf index 0db6995fa5..29f22bbcf3 100644 --- a/Resources/Private/Translations/en/Main.xlf +++ b/Resources/Private/Translations/en/Main.xlf @@ -347,6 +347,26 @@ 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/packages/neos-ts-interfaces/src/index.ts b/packages/neos-ts-interfaces/src/index.ts index 9bd1fb005e..b8dc49753e 100644 --- a/packages/neos-ts-interfaces/src/index.ts +++ b/packages/neos-ts-interfaces/src/index.ts @@ -101,6 +101,12 @@ export enum SelectionModeTypes { RANGE_SELECT = 'RANGE_SELECT' } +export enum WorkspaceStatus { + UP_TO_DATE = 'UP_TO_DATE', + OUTDATED = 'OUTDATED', + OUTDATED_CONFLICT = 'OUTDATED_CONFLICT' +} + export interface ValidatorConfiguration { [propName: string]: any; } diff --git a/packages/neos-ui-backend-connector/src/Endpoints/index.ts b/packages/neos-ui-backend-connector/src/Endpoints/index.ts index 56f9075e0c..7e2061958d 100644 --- a/packages/neos-ui-backend-connector/src/Endpoints/index.ts +++ b/packages/neos-ui-backend-connector/src/Endpoints/index.ts @@ -12,6 +12,7 @@ export interface Routes { publish: string; discard: string; changeBaseWorkspace: string; + rebaseWorkspace: string; copyNodes: string; cutNodes: string; clearClipboard: string; @@ -111,6 +112,21 @@ 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, + + method: 'POST', + credentials: 'include', + headers: { + 'X-Flow-Csrftoken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + targetWorkspaceName + }) + })).then(response => fetchWithErrorHandling.parseJson(response)) + .catch(reason => fetchWithErrorHandling.generalErrorHandler(reason)); + const copyNodes = (nodes: NodeContextPath[]) => fetchWithErrorHandling.withCsrfToken(csrfToken => ({ url: routes.ui.service.copyNodes, @@ -660,6 +676,7 @@ export default (routes: Routes) => { publish, discard, changeBaseWorkspace, + rebaseWorkspace, 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 652835fe2e..ab1610930e 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 @@ -9,6 +9,7 @@ test(`should export actionTypes`, () => { expect(typeof (actionTypes.DISCARD_ABORTED)).toBe('string'); expect(typeof (actionTypes.DISCARD_CONFIRMED)).toBe('string'); expect(typeof (actionTypes.CHANGE_BASE_WORKSPACE)).toBe('string'); + expect(typeof (actionTypes.REBASE_WORKSPACE)).toBe('string'); }); test(`should export action creators`, () => { @@ -19,6 +20,7 @@ test(`should export action creators`, () => { expect(typeof (actions.abortDiscard)).toBe('function'); expect(typeof (actions.confirmDiscard)).toBe('function'); expect(typeof (actions.changeBaseWorkspace)).toBe('function'); + expect(typeof (actions.rebaseWorkspace)).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 8fd232e7e3..6f2c58a138 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/index.ts @@ -16,6 +16,7 @@ export interface WorkspaceInformation { }>; baseWorkspace: WorkspaceName; readOnly?: boolean; + status?: string; } export interface State extends Readonly<{ @@ -27,7 +28,8 @@ export const defaultState: State = { personalWorkspace: { name: '', publishableNodes: [], - baseWorkspace: '' + baseWorkspace: '', + status: '' }, toBeDiscarded: [] }; @@ -38,7 +40,8 @@ export enum actionTypes { COMMENCE_DISCARD = '@neos/neos-ui/CR/Workspaces/COMMENCE_DISCARD', DISCARD_ABORTED = '@neos/neos-ui/CR/Workspaces/DISCARD_ABORTED', DISCARD_CONFIRMED = '@neos/neos-ui/CR/Workspaces/DISCARD_CONFIRMED', - CHANGE_BASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/CHANGE_BASE_WORKSPACE' + CHANGE_BASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/CHANGE_BASE_WORKSPACE', + REBASE_WORKSPACE = '@neos/neos-ui/CR/Workspaces/REBASE_WORKSPACE' } export type Action = ActionType; @@ -75,6 +78,11 @@ const confirmDiscard = () => createAction(actionTypes.DISCARD_CONFIRMED); */ const changeBaseWorkspace = (name: string) => createAction(actionTypes.CHANGE_BASE_WORKSPACE, name); +/** + * Rebase the user workspace + */ +const rebaseWorkspace = (name: string) => createAction(actionTypes.REBASE_WORKSPACE, name); + // // Export the actions // @@ -84,7 +92,8 @@ export const actions = { commenceDiscard, abortDiscard, confirmDiscard, - changeBaseWorkspace + changeBaseWorkspace, + rebaseWorkspace }; // 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 babfd7f43c..29c0767ba8 100644 --- a/packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts +++ b/packages/neos-ui-redux-store/src/CR/Workspaces/selectors.ts @@ -1,13 +1,20 @@ import {createSelector} from 'reselect'; import {documentNodeContextPathSelector} from '../Nodes/selectors'; import {GlobalState} from '../../System'; -import {NodeContextPath} from '@neos-project/neos-ts-interfaces'; +import {NodeContextPath, WorkspaceStatus} from '@neos-project/neos-ts-interfaces'; export const personalWorkspaceNameSelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.name; +export const personalWorkspaceRebaseStatusSelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.status; + export const baseWorkspaceSelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.baseWorkspace; -export const isWorkspaceReadOnlySelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.readOnly || false; +export const isWorkspaceReadOnlySelector = (state: GlobalState) => { + if (state?.cr?.workspaces?.personalWorkspace?.status === WorkspaceStatus.OUTDATED_CONFLICT) { + return true; + } + return state?.cr?.workspaces?.personalWorkspace?.readOnly || false +}; export const publishableNodesSelector = (state: GlobalState) => state?.cr?.workspaces?.personalWorkspace?.publishableNodes; 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 d5590e504b..8fa8c82c75 100644 --- a/packages/neos-ui-redux-store/src/UI/Remote/index.ts +++ b/packages/neos-ui-redux-store/src/UI/Remote/index.ts @@ -7,13 +7,15 @@ import {NodeContextPath} from '@neos-project/neos-ts-interfaces'; export interface State extends Readonly<{ isSaving: boolean, isPublishing: boolean, - isDiscarding: boolean + isDiscarding: boolean, + isSyncing: boolean }> {} export const defaultState: State = { isSaving: false, isPublishing: false, - isDiscarding: false + isDiscarding: false, + isSyncing: false }; // @@ -28,6 +30,8 @@ export enum actionTypes { FINISH_PUBLISHING = '@neos/neos-ui/UI/Remote/FINISH_PUBLISHING', START_DISCARDING = '@neos/neos-ui/UI/Remote/START_DISCARDING', FINISH_DISCARDING = '@neos/neos-ui/UI/Remote/FINISH_DISCARDING', + 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' } @@ -61,6 +65,16 @@ const startDiscarding = () => createAction(actionTypes.START_DISCARDING); */ const finishDiscarding = () => createAction(actionTypes.FINISH_DISCARDING); +/** + * 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. */ @@ -88,6 +102,8 @@ export const actions = { finishPublishing, startDiscarding, finishDiscarding, + startSynchronization, + finishSynchronization, documentNodeCreated }; @@ -130,6 +146,14 @@ export const reducer = (state: State = defaultState, action: InitAction | Action draft.isDiscarding = false; break; } + case actionTypes.START_SYNCHRONIZATION: { + draft.isSyncing = true; + break; + } + case actionTypes.FINISH_SYNCHRONIZATION: { + draft.isSyncing = false; + break; + } } }); diff --git a/packages/neos-ui-redux-store/src/UI/SyncWorkspaceModal/index.ts b/packages/neos-ui-redux-store/src/UI/SyncWorkspaceModal/index.ts new file mode 100644 index 0000000000..734b96f003 --- /dev/null +++ b/packages/neos-ui-redux-store/src/UI/SyncWorkspaceModal/index.ts @@ -0,0 +1,69 @@ +import produce from 'immer'; +import {action as createAction, ActionType} from 'typesafe-actions'; + +import {InitAction} from '../../System'; + +export interface State extends Readonly<{ + isOpen: boolean; +}> {} + +export const defaultState: State = { + isOpen: false +}; + +// +// Export the action types +// +export enum actionTypes { + OPEN = '@neos/neos-ui/UI/SyncWorkspaceModal/OPEN', + CANCEL = '@neos/neos-ui/UI/SyncWorkspaceModal/CANCEL', + APPLY = '@neos/neos-ui/UI/SyncWorkspaceModal/APPLY' +} + +/** + * Opens the sync workspace modal + */ +const open = () => createAction(actionTypes.OPEN); + +/** + * Closes the sync workspace modal + */ +const cancel = () => createAction(actionTypes.CANCEL); + +/** + * Triggers the workspace rebase + */ +const apply = () => createAction(actionTypes.APPLY); + +// +// Export the actions +// +export const actions = { + open, + cancel, + apply +}; + +export type Action = ActionType; + +// +// Export the reducer +// +export const reducer = (state: State = defaultState, action: InitAction | Action) => produce(state, draft => { + switch (action.type) { + case actionTypes.OPEN: { + draft.isOpen = true; + break; + } + case actionTypes.CANCEL: { + draft.isOpen = false; + break; + } + case actionTypes.APPLY: { + draft.isOpen = false; + break; + } + } +}); + +export const selectors = {}; diff --git a/packages/neos-ui-redux-store/src/UI/index.ts b/packages/neos-ui-redux-store/src/UI/index.ts index e96a020250..8ccb2b9c74 100644 --- a/packages/neos-ui-redux-store/src/UI/index.ts +++ b/packages/neos-ui-redux-store/src/UI/index.ts @@ -17,6 +17,7 @@ import * as InsertionModeModal from './InsertionModeModal'; import * as SelectNodeTypeModal from './SelectNodeTypeModal'; import * as NodeCreationDialog from './NodeCreationDialog'; import * as NodeVariantCreationDialog from './NodeVariantCreationDialog'; +import * as SyncWorkspaceModal from './SyncWorkspaceModal'; import * as ContentTree from './ContentTree'; const all = { @@ -37,6 +38,7 @@ const all = { SelectNodeTypeModal, NodeCreationDialog, NodeVariantCreationDialog, + SyncWorkspaceModal, ContentTree }; @@ -64,6 +66,7 @@ export interface State { selectNodeTypeModal: SelectNodeTypeModal.State; nodeCreationDialog: NodeCreationDialog.State; nodeVariantCreationDialog: NodeVariantCreationDialog.State; + SyncWorkspaceModal: SyncWorkspaceModal.State; contentTree: ContentTree.State; } @@ -97,6 +100,7 @@ export const reducer = combineReducers({ selectNodeTypeModal: SelectNodeTypeModal.reducer, nodeCreationDialog: NodeCreationDialog.reducer, nodeVariantCreationDialog: NodeVariantCreationDialog.reducer, + SyncWorkspaceModal: SyncWorkspaceModal.reducer, contentTree: ContentTree.reducer } as any); // TODO: when we update redux, this shouldn't be necessary https://github.com/reduxjs/redux/issues/2709#issuecomment-357328709 diff --git a/packages/neos-ui-sagas/src/Publish/index.js b/packages/neos-ui-sagas/src/Publish/index.js index 729303ebca..976e1987ba 100644 --- a/packages/neos-ui-sagas/src/Publish/index.js +++ b/packages/neos-ui-sagas/src/Publish/index.js @@ -42,6 +42,24 @@ export function * watchChangeBaseWorkspace() { }); } +export function * watchRebaseWorkspace() { + const {rebaseWorkspace, getWorkspaceInfo} = backend.get().endpoints; + yield takeEvery(actionTypes.CR.Workspaces.REBASE_WORKSPACE, function * change(action) { + yield put(actions.UI.Remote.startSynchronization()); + + try { + const feedback = yield call(rebaseWorkspace, action.payload); + yield put(actions.ServerFeedback.handleServerFeedback(feedback)); + } catch (error) { + console.error('Failed to sync user workspace', error); + } finally { + const workspaceInfo = yield call(getWorkspaceInfo); + yield put(actions.CR.Workspaces.update(workspaceInfo)); + yield put(actions.UI.Remote.finishSynchronization()); + } + }); +} + export function * discardIfConfirmed() { const {discard} = backend.get().endpoints; yield takeLatest(actionTypes.CR.Workspaces.COMMENCE_DISCARD, function * waitForConfirmation() { diff --git a/packages/neos-ui-sagas/src/manifest.js b/packages/neos-ui-sagas/src/manifest.js index 55cdfa564c..7469ee768f 100644 --- a/packages/neos-ui-sagas/src/manifest.js +++ b/packages/neos-ui-sagas/src/manifest.js @@ -48,6 +48,7 @@ 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.discardIfConfirmed}); sagasRegistry.set('neos-ui/Publish/watchPublish', {saga: publish.watchPublish}); diff --git a/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js new file mode 100644 index 0000000000..d915a8578f --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/index.js @@ -0,0 +1,148 @@ +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, + rebaseWorkspace: actions.CR.Workspaces.rebaseWorkspace +}) +@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, + rebaseWorkspace: PropTypes.func.isRequired + }; + + handleAbort = () => { + const {abortRebase} = this.props; + + abortRebase(); + } + + handleConfirm = () => { + const {confirmRebase, rebaseWorkspace, personalWorkspaceName} = this.props; + confirmRebase(); + rebaseWorkspace(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/style.module.css b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css new file mode 100644 index 0000000000..45cf3d5165 --- /dev/null +++ b/packages/neos-ui/src/Containers/Modals/SyncWorkspaceDialog/style.module.css @@ -0,0 +1,36 @@ +.modalContents { + padding: var(--spacing-Full); + white-space: pre-line; +} + +.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; +} + +.confirmText { + vertical-align: sub; +} + +.modalTitle { + display: flex; + flex-direction: row; + align-items: center; +} + +.modalTitle span:first-child { + scale: .8; +} diff --git a/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js new file mode 100644 index 0000000000..23a67b13d0 --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/WorkspaceSyncIcon.js @@ -0,0 +1,35 @@ +/* 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/index.js b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.js new file mode 100644 index 0000000000..d3703d2d00 --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/index.js @@ -0,0 +1,73 @@ +/* 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/style.module.css b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/style.module.css new file mode 100644 index 0000000000..b0479568ff --- /dev/null +++ b/packages/neos-ui/src/Containers/PrimaryToolbar/WorkspaceSync/style.module.css @@ -0,0 +1,28 @@ +.wrapper { + display: inline-block; + position: relative; + vertical-align: top; + height: var(--spacing-GoldenUnit); +} + +.rebaseButton { + padding: 0 calc(var(--spacing-Full) - var(--spacing-Quarter)); + border-left: 1px solid black; +} + +.iconLayer { + width: 1.5em; + height: 1.5em; +} + +.badgeIconBackground { + background: var(--colors-ContrastDarkest); +} + +.badgeIconOnDarkBackground { + background: var(--colors-ContrastBrightest); +} + +.badgeIconOnDarkBackground svg { + color: var(--colors-ContrastDarkest); +} diff --git a/packages/neos-ui/src/manifest.containers.js b/packages/neos-ui/src/manifest.containers.js index a4feb712ba..e6f4eb2f01 100644 --- a/packages/neos-ui/src/manifest.containers.js +++ b/packages/neos-ui/src/manifest.containers.js @@ -12,11 +12,13 @@ import NodeVariantCreationDialog from './Containers/Modals/NodeVariantCreationDi 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 PrimaryToolbar from './Containers/PrimaryToolbar/index'; import DimensionSwitcher from './Containers/PrimaryToolbar/DimensionSwitcher/index'; import UserDropDown from './Containers/PrimaryToolbar/UserDropDown/index'; import PublishDropDown from './Containers/PrimaryToolbar/PublishDropDown/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'; @@ -55,6 +57,7 @@ manifest('main.containers', {}, globalRegistry => { containerRegistry.set('Modals/ReloginDialog', ReloginDialog); containerRegistry.set('Modals/KeyboardShortcutModal', KeyboardShortcutModal); containerRegistry.set('Modals/UnappliedChangesDialog', UnappliedChangesDialog); + containerRegistry.set('Modals/SyncWorkspaceDialog', SyncWorkspaceDialog); containerRegistry.set('PrimaryToolbar', PrimaryToolbar); containerRegistry.set('PrimaryToolbar/Left/MenuToggler', MenuToggler); @@ -63,6 +66,7 @@ manifest('main.containers', {}, globalRegistry => { containerRegistry.set('PrimaryToolbar/Right/DimensionSwitcher', DimensionSwitcher); containerRegistry.set('PrimaryToolbar/Right/UserDropDown', UserDropDown); containerRegistry.set('PrimaryToolbar/Right/PublishDropDown', PublishDropDown); + containerRegistry.set('PrimaryToolbar/Right/WorkspaceSync', WorkspaceSync); containerRegistry.set('SecondaryToolbar', SecondaryToolbar); containerRegistry.set('SecondaryToolbar/LoadingIndicator', LoadingIndicator);