diff --git a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Dto/SubtreeTags.php b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Dto/SubtreeTags.php index 8c22ff8e734..1195de7829a 100644 --- a/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Dto/SubtreeTags.php +++ b/Neos.ContentRepository.Core/Classes/Feature/SubtreeTagging/Dto/SubtreeTags.php @@ -27,7 +27,6 @@ */ private array $tags; - private function __construct(SubtreeTag ...$tags) { $tagsByValue = []; @@ -83,6 +82,11 @@ public function intersection(self $other): self return self::fromArray(array_intersect_key($this->tags, $other->tags)); } + public function difference(self $other): self + { + return self::fromArray(array_diff_key($this->tags, $other->tags)); + } + public function merge(self $other): self { return self::fromArray(array_merge($this->tags, $other->tags)); @@ -106,6 +110,11 @@ public function toStringArray(): array return $this->map(static fn (SubtreeTag $tag) => $tag->value); } + public function equals(SubtreeTags $other): bool + { + return count($this->tags) === count($other->tags) && array_diff_key($this->tags, $other->tags) === []; + } + public function getIterator(): \Traversable { yield from array_values($this->tags); diff --git a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeTags.php b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeTags.php index c22faebd7f4..c511e86b9a9 100644 --- a/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeTags.php +++ b/Neos.ContentRepository.Core/Classes/Projection/ContentGraph/NodeTags.php @@ -117,6 +117,12 @@ public function toStringArray(): array return $this->map(static fn (SubtreeTag $tag) => $tag->value); } + public function equals(NodeTags $other): bool + { + return $this->tags->equals($other->tags) + && $this->inheritedTags->equals($other->inheritedTags); + } + public function getIterator(): Traversable { foreach ($this->tags as $tag) { diff --git a/Neos.ContentRepository.Core/Tests/Unit/Feature/SubtreeTagging/Dto/SubtreeTagsTest.php b/Neos.ContentRepository.Core/Tests/Unit/Feature/SubtreeTagging/Dto/SubtreeTagsTest.php index 356e0fcd762..0f810240d58 100644 --- a/Neos.ContentRepository.Core/Tests/Unit/Feature/SubtreeTagging/Dto/SubtreeTagsTest.php +++ b/Neos.ContentRepository.Core/Tests/Unit/Feature/SubtreeTagging/Dto/SubtreeTagsTest.php @@ -132,6 +132,44 @@ public function intersectionTests(array $tags1, array $tags2, array $expectedRes self::assertSame($expectedResult, SubtreeTags::fromStrings(...$tags1)->intersection(SubtreeTags::fromStrings(...$tags2))->toStringArray()); } + public static function differenceDataProvider(): iterable + { + yield 'empty' => ['tags1' => [], 'tags2' => [], 'expectedResult' => []]; + yield 'one empty' => ['tags1' => [], 'tags2' => ['foo'], 'expectedResult' => []]; + yield 'two empty' => ['tags1' => ['foo'], 'tags2' => [], 'expectedResult' => ['foo']]; + yield 'no intersection' => ['tags1' => ['foo', 'bar'], 'tags2' => ['baz', 'foos'], 'expectedResult' => ['foo', 'bar']]; + yield 'with intersection' => ['tags1' => ['foo', 'bar', 'baz'], 'tags2' => ['baz', 'bars', 'foo'], 'expectedResult' => ['bar']]; + yield 'with intersection reversed' => ['tags1' => ['baz', 'bars', 'foo'], 'tags2' => ['foo', 'bar', 'baz'], 'expectedResult' => ['bars']]; + } + + /** + * @test + * @dataProvider differenceDataProvider + */ + public function differenceTests(array $tags1, array $tags2, array $expectedResult): void + { + self::assertSame($expectedResult, SubtreeTags::fromStrings(...$tags1)->difference(SubtreeTags::fromStrings(...$tags2))->toStringArray()); + } + + + public static function equalsDataProvider(): iterable + { + yield 'empty' => ['tags1' => [], 'tags2' => [], 'expectedResult' => true]; + yield 'one empty' => ['tags1' => [], 'tags2' => ['foo'], 'expectedResult' => false]; + yield 'other empty' => ['tags1' => ['foo'], 'tags2' => [], 'expectedResult' => false]; + yield 'equals' => ['tags1' => ['foo', 'bar'], 'tags2' => ['foo', 'bar'], 'expectedResult' => true]; + yield 'equals reversed' => ['tags1' => ['foo', 'bar'], 'tags2' => ['bar', 'foo'], 'expectedResult' => true]; + } + + /** + * @test + * @dataProvider equalsDataProvider + */ + public function equalsTests(array $tags1, array $tags2, bool $expectedResult): void + { + self::assertSame($expectedResult, SubtreeTags::fromStrings(...$tags1)->equals(SubtreeTags::fromStrings(...$tags2))); + } + public static function mergeDataProvider(): iterable { yield 'empty' => ['tags1' => [], 'tags2' => [], 'expectedResult' => []]; diff --git a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php index 87b33bc0d3b..c10d1e0ba6e 100644 --- a/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php +++ b/Neos.Neos/Classes/Domain/Model/WorkspaceRoleAssignments.php @@ -82,6 +82,21 @@ public static function createForSharedWorkspace(UserId $userId): self ); } + /** + * Default role assignment to be specified at creation via {@see WorkspaceService::createSharedWorkspace()} + * + * The specified user is manager + */ + public static function createForPrivateWorkspace(UserId $userId): self + { + return new self( + WorkspaceRoleAssignment::createForUser( + $userId, + WorkspaceRole::MANAGER, + ) + ); + } + public function isEmpty(): bool { return $this->assignments === []; diff --git a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php index 0bdd6dc234f..eb1c909dd17 100644 --- a/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php +++ b/Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php @@ -14,20 +14,17 @@ namespace Neos\Workspace\Ui\Controller; -use Doctrine\DBAL\Exception as DBALException; use Neos\ContentRepository\Core\ContentRepository; +use Neos\ContentRepository\Core\Dimension\ContentDimensionId; use Neos\ContentRepository\Core\DimensionSpace\DimensionSpacePoint; +use Neos\ContentRepository\Core\Feature\SubtreeTagging\Dto\SubtreeTag; use Neos\ContentRepository\Core\Feature\WorkspaceCreation\Exception\WorkspaceAlreadyExists; -use Neos\ContentRepository\Core\Feature\WorkspaceModification\Command\DeleteWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\DiscardIndividualNodesFromWorkspace; -use Neos\ContentRepository\Core\Feature\WorkspacePublication\Command\PublishIndividualNodesFromWorkspace; +use Neos\ContentRepository\Core\Feature\WorkspaceRebase\Exception\WorkspaceRebaseFailed; use Neos\ContentRepository\Core\Projection\ContentGraph\Filter\FindAncestorNodesFilter; use Neos\ContentRepository\Core\Projection\ContentGraph\Node; use Neos\ContentRepository\Core\Projection\ContentGraph\Nodes; use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints; -use Neos\ContentRepository\Core\SharedModel\ContentRepository\ContentRepositoryId; use Neos\ContentRepository\Core\SharedModel\Node\NodeAddress; -use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateIds; use Neos\ContentRepository\Core\SharedModel\Node\NodeName; use Neos\ContentRepository\Core\SharedModel\Workspace\ContentStreamId; use Neos\ContentRepository\Core\SharedModel\Workspace\Workspace; @@ -37,25 +34,25 @@ use Neos\Diff\Renderer\Html\HtmlArrayRenderer; use Neos\Error\Messages\Message; use Neos\Flow\Annotations as Flow; -use Neos\Flow\I18n\Exception\IndexOutOfBoundsException; -use Neos\Flow\I18n\Exception\InvalidFormatPlaceholderException; +use Neos\Flow\I18n\EelHelper\TranslationHelper; +use Neos\Flow\I18n\Translator; use Neos\Flow\Mvc\Exception\StopActionException; use Neos\Flow\Package\PackageManager; use Neos\Flow\Property\PropertyMapper; use Neos\Flow\Security\Context; -use Neos\Flow\Security\Exception\AccessDeniedException; +use Neos\Flow\Security\Policy\PolicyService; +use Neos\Fusion\View\FusionView; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Neos\Controller\Module\AbstractModuleController; -use Neos\Neos\Controller\Module\ModuleTranslationTrait; -use Neos\Neos\Domain\Model\SiteNodeName; -use Neos\Neos\Domain\Model\User; use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspaceDescription; use Neos\Neos\Domain\Model\WorkspaceRole; use Neos\Neos\Domain\Model\WorkspaceRoleAssignment; use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; +use Neos\Neos\Domain\Model\WorkspaceRoleSubject; use Neos\Neos\Domain\Model\WorkspaceTitle; +use Neos\Neos\Domain\NodeLabel\NodeLabelGeneratorInterface; use Neos\Neos\Domain\Repository\SiteRepository; use Neos\Neos\Domain\Service\NodeTypeNameFactory; use Neos\Neos\Domain\Service\UserService; @@ -64,21 +61,38 @@ use Neos\Neos\FrontendRouting\NodeUriBuilderFactory; use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult; use Neos\Neos\PendingChangesProjection\ChangeFinder; +use Neos\Neos\PendingChangesProjection\Changes; use Neos\Neos\Security\Authorization\ContentRepositoryAuthorizationService; use Neos\Neos\Utility\NodeTypeWithFallbackProvider; +use Neos\Workspace\Ui\ViewModel\ChangeItem; +use Neos\Workspace\Ui\ViewModel\ContentChangeItem; +use Neos\Workspace\Ui\ViewModel\ContentChangeItems; +use Neos\Workspace\Ui\ViewModel\ContentChangeProperties; +use Neos\Workspace\Ui\ViewModel\ContentChanges\AssetContentChange; +use Neos\Workspace\Ui\ViewModel\ContentChanges\DateTimeContentChange; +use Neos\Workspace\Ui\ViewModel\ContentChanges\ImageContentChange; +use Neos\Workspace\Ui\ViewModel\ContentChanges\TagContentChange; +use Neos\Workspace\Ui\ViewModel\ContentChanges\TextContentChange; +use Neos\Workspace\Ui\ViewModel\DocumentChangeItem; +use Neos\Workspace\Ui\ViewModel\DocumentItem; +use Neos\Workspace\Ui\ViewModel\EditWorkspaceFormData; use Neos\Workspace\Ui\ViewModel\PendingChanges; +use Neos\Workspace\Ui\ViewModel\Sorting; use Neos\Workspace\Ui\ViewModel\WorkspaceListItem; use Neos\Workspace\Ui\ViewModel\WorkspaceListItems; /** * The Neos Workspace module controller + * + * @internal for communication within the Workspace UI only */ #[Flow\Scope('singleton')] class WorkspaceController extends AbstractModuleController { - use ModuleTranslationTrait; use NodeTypeWithFallbackProvider; + protected $defaultViewObjectName = FusionView::class; + #[Flow\Inject] protected ContentRepositoryRegistry $contentRepositoryRegistry; @@ -107,114 +121,127 @@ class WorkspaceController extends AbstractModuleController protected WorkspaceService $workspaceService; #[Flow\Inject] - protected ContentRepositoryAuthorizationService $contentRepositoryAuthorizationService; + protected NodeLabelGeneratorInterface $nodeLabelGenerator; + + #[Flow\Inject] + protected Translator $translator; + + #[Flow\Inject] + protected PolicyService $policyService; + + #[Flow\Inject] + protected ContentRepositoryAuthorizationService $authorizationService; /** * Display a list of unpublished content */ - public function indexAction(): void + public function indexAction(Sorting|null $sorting = null): void { + $sorting ??= new Sorting( + sortBy: 'title', + sortAscending: true + ); + $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new AccessDeniedException('No user authenticated', 1718308216); + throw new \RuntimeException('No user authenticated', 1718308216); } - $contentRepositoryIds = $this->contentRepositoryRegistry->getContentRepositoryIds(); - $numberOfContentRepositories = $contentRepositoryIds->count(); - if ($numberOfContentRepositories === 0) { - throw new \RuntimeException('No content repository configured', 1718296290); - } - if ($this->request->hasArgument('contentRepositoryId')) { - $contentRepositoryIdArgument = $this->request->getArgument('contentRepositoryId'); - assert(is_string($contentRepositoryIdArgument)); - $contentRepositoryId = ContentRepositoryId::fromString($contentRepositoryIdArgument); - } else { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; - } - $this->view->assign('contentRepositoryIds', $contentRepositoryIds); - $this->view->assign('contentRepositoryId', $contentRepositoryId->value); - $this->view->assign('displayContentRepositorySelector', $numberOfContentRepositories > 1); - + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $items = []; - $allWorkspaces = $contentRepository->findWorkspaces(); - foreach ($allWorkspaces as $workspace) { - if ($workspace->isRootWorkspace()) { - continue; - } - $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); - if (!$permissions->read) { - continue; - } - $items[] = new WorkspaceListItem( - name: $workspace->workspaceName->value, - classification: $workspaceMetadata->classification->name, - title: $workspaceMetadata->title->value, - description: $workspaceMetadata->description->value, - baseWorkspaceName: $workspace->baseWorkspaceName->value, - pendingChanges: $this->computePendingChanges($workspace, $contentRepository), - hasDependantWorkspaces: !$allWorkspaces->getDependantWorkspaces($workspace->workspaceName)->isEmpty(), - permissions: $permissions, - ); - } - $this->view->assign('workspaces', WorkspaceListItems::fromArray($items)); + $workspaceListItems = $this->getWorkspaceListItems($contentRepository); + $workspaceListItems = match($sorting->sortBy) { + 'title' => $workspaceListItems->sortByTitle($sorting->sortAscending), + }; + + $this->view->assignMultiple([ + 'workspaceListItems' => $workspaceListItems, + 'flashMessages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), + 'sorting' => $sorting, + ]); } - public function showAction(WorkspaceName $workspace): void + public function reviewAction(WorkspaceName $workspace): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new AccessDeniedException('No user authenticated', 1720371024); + throw new \RuntimeException('No user authenticated', 1720371024); } $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspaceObj = $contentRepository->findWorkspaceByName($workspace); if (is_null($workspaceObj)) { - /** @todo add flash message */ - $this->redirect('index'); + $title = WorkspaceTitle::fromString($workspace->value); + $this->addFlashMessage( + $this->getModuleLabel('workspaces.workspaceDoesNotExist', [$title->value]), + '', + Message::SEVERITY_ERROR + ); + $this->forward('index'); + } + + $workspacePermissions = $this->authorizationService->getWorkspacePermissions($contentRepositoryId, $workspace, $this->securityContext->getRoles(), $currentUser->getId()); + if(!$workspacePermissions->read){ + $this->addFlashMessage( + $this->getModuleLabel('workspaces.changes.noPermissionToReadWorkspace'), + '', + Message::SEVERITY_ERROR + ); + $this->forward('index'); } $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace); $baseWorkspaceMetadata = null; $baseWorkspacePermissions = null; - if ($workspaceObj->baseWorkspaceName !== null) { - $baseWorkspace = $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName); - assert($baseWorkspace !== null); + $baseWorkspace = $workspaceObj->baseWorkspaceName !== null + ? $contentRepository->findWorkspaceByName($workspaceObj->baseWorkspaceName) + : null; + if ($baseWorkspace !== null) { $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); - $baseWorkspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepositoryId, $baseWorkspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); + $baseWorkspacePermissions = $this->authorizationService->getWorkspacePermissions($contentRepositoryId, $baseWorkspace->workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); } $this->view->assignMultiple([ - 'selectedWorkspace' => $workspaceObj, + 'selectedWorkspaceName' => $workspaceObj->workspaceName->value, 'selectedWorkspaceLabel' => $workspaceMetadata->title->value, 'baseWorkspaceName' => $workspaceObj->baseWorkspaceName, 'baseWorkspaceLabel' => $baseWorkspaceMetadata?->title->value, 'canPublishToBaseWorkspace' => $baseWorkspacePermissions?->write ?? false, + 'canPublishToWorkspace' => $workspacePermissions->write, 'siteChanges' => $this->computeSiteChanges($workspaceObj, $contentRepository), - 'contentDimensions' => $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority() + 'contentDimensions' => $contentRepository->getContentDimensionSource()->getContentDimensionsOrderedByPriority(), + 'flashMessages' => $this->controllerContext->getFlashMessageContainer()->getMessagesAndFlush(), ]); } - public function newAction(ContentRepositoryId $contentRepositoryId): void + public function newAction(): void { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository)); - $this->view->assign('contentRepositoryId', $contentRepositoryId->value); + $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, null)); } public function createAction( - ContentRepositoryId $contentRepositoryId, WorkspaceTitle $title, WorkspaceName $baseWorkspace, WorkspaceDescription $description, + string $visibility = 'shared', ): void { $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new AccessDeniedException('No user authenticated', 1718303756); + throw new \RuntimeException('No user authenticated', 1718303756); } + + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; $workspaceName = $this->workspaceService->getUniqueWorkspaceName($contentRepositoryId, $title->value); + + $assignments = match($visibility) { + 'shared' => WorkspaceRoleAssignments::createForSharedWorkspace($currentUser->getId()), + 'private' => WorkspaceRoleAssignments::createForPrivateWorkspace($currentUser->getId()), + default => throw new \RuntimeException(sprintf('Invalid visibility %s given', $visibility), 1736343542) + }; + try { $this->workspaceService->createSharedWorkspace( $contentRepositoryId, @@ -222,9 +249,7 @@ public function createAction( $title, $description, $baseWorkspace, - WorkspaceRoleAssignments::createForSharedWorkspace( - $currentUser->getId() - ) + $assignments ); } catch (WorkspaceAlreadyExists $exception) { $this->addFlashMessage( @@ -232,14 +257,23 @@ public function createAction( '', Message::SEVERITY_WARNING ); - $this->redirect('new'); + $this->throwStatus(400, 'Workspace with this title already exists'); + } catch (\Exception $exception) { + $this->addFlashMessage( + $exception->getMessage(), + $this->getModuleLabel('workspaces.workspaceCouldNotBeCreated'), + Message::SEVERITY_ERROR + ); + $this->throwStatus(500, 'Workspace could not be created'); } $this->addFlashMessage($this->getModuleLabel('workspaces.workspaceHasBeenCreated', [$title->value])); - $this->redirect('index'); + $this->forward('index'); } /** * Edit a workspace + * + * @param WorkspaceName $workspaceName The name of the workspace that is being edited */ public function editAction(WorkspaceName $workspaceName): void { @@ -248,60 +282,90 @@ public function editAction(WorkspaceName $workspaceName): void $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); $workspace = $contentRepository->findWorkspaceByName($workspaceName); + $title = WorkspaceTitle::fromString($workspaceName->value); if (is_null($workspace)) { - $this->addFlashMessage('Failed to find workspace "%s"', 'Error', Message::SEVERITY_ERROR, [$workspaceName->value]); - $this->redirect('index'); + $this->addFlashMessage( + $this->getModuleLabel('workspaces.workspaceDoesNotExist', [$title->value]), + '', + Message::SEVERITY_ERROR + ); + $this->throwStatus(404, 'Workspace does not exist'); + } + + if ($workspace->isRootWorkspace()) { + throw new \RuntimeException(sprintf('Workspace %s does not have a base-workspace.', $workspace->workspaceName->value), 1734019485); + } + + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspace->workspaceName); + $isShared = false; + if ($workspaceMetadata->classification === WorkspaceClassification::SHARED) { + foreach ($workspaceRoleAssignments as $roleAssignment) { + if ($roleAssignment->role === WorkspaceRole::COLLABORATOR) { + $isShared = true; + } + } } - $this->view->assign('workspace', $workspace); - $this->view->assign('baseWorkspaceOptions', $this->prepareBaseWorkspaceOptions($contentRepository, $workspaceName)); - // TODO: $this->view->assign('disableBaseWorkspaceSelector', - // $this->publishingService->getUnpublishedNodesCount($workspace) > 0); - // TODO fix $this->userService->currentUserCanTransferOwnershipOfWorkspace($workspace) - $this->view->assign('showOwnerSelector', false); + $editWorkspaceDto = new EditWorkspaceFormData( + workspaceName: $workspace->workspaceName, + workspaceTitle: $workspaceMetadata->title, + workspaceDescription: $workspaceMetadata->description, + workspaceHasChanges: $this->computePendingChanges($workspace, $contentRepository)->total > 0, + baseWorkspaceName: $workspace->baseWorkspaceName, + baseWorkspaceOptions: $this->prepareBaseWorkspaceOptions($contentRepository, $workspaceName), + isShared: $isShared, + ); - $this->view->assign('ownerOptions', $this->prepareOwnerOptions()); + $this->view->assign('editWorkspaceFormData', $editWorkspaceDto); } /** * Update a workspace * * @Flow\Validate(argumentName="title", type="\Neos\Flow\Validation\Validator\NotEmptyValidator") - * @param WorkspaceName $workspaceName + * @param WorkspaceName $workspaceName The name of the workspace that is being updated * @param WorkspaceTitle $title Human friendly title of the workspace, for example "Christmas Campaign" * @param WorkspaceDescription $description A description explaining the purpose of the new workspace - * @return void + * @param string $visibility Allow other editors to collaborate on this workspace if set to "shared" + * @param WorkspaceName|null $baseWorkspace The base workspace to rebase this workspace onto if modified */ public function updateAction( WorkspaceName $workspaceName, WorkspaceTitle $title, WorkspaceDescription $description, + string $visibility, + WorkspaceName|null $baseWorkspace = null, ): void { - $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - $currentUser = $this->userService->getCurrentUser(); if ($currentUser === null) { - throw new AccessDeniedException('No user is authenticated', 1729620262); - } - $workspacePermissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspaceName, $this->securityContext->getRoles(), $currentUser->getId()); - if (!$workspacePermissions->manage) { - throw new AccessDeniedException(sprintf('The authenticated user does not have manage permissions for workspace "%s"', $workspaceName->value), 1729620297); + throw new \RuntimeException('No user is authenticated', 1729505338); } + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + if ($title->value === '') { $title = WorkspaceTitle::fromString($workspaceName->value); } $workspace = $contentRepository->findWorkspaceByName($workspaceName); + + $userCanManageWorkspace = $this->authorizationService->getWorkspacePermissions($contentRepositoryId, $workspaceName, $this->securityContext->getRoles(), $this->userService->getCurrentUser()?->getId())->manage; + if (!$userCanManageWorkspace) { + $this->throwStatus(403); + } + if ($workspace === null) { $this->addFlashMessage( $this->getModuleLabel('workspaces.workspaceDoesNotExist'), '', Message::SEVERITY_ERROR ); - $this->redirect('index'); + $this->throwStatus(404, 'Workspace does not exist'); } + + // Update Metadata $this->workspaceService->setWorkspaceTitle( $contentRepositoryId, $workspaceName, @@ -312,25 +376,53 @@ public function updateAction( $workspaceName, $description, ); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.workspaceHasBeenUpdated', - [$title->value], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.workspaceHasBeenUpdated'); - $this->redirect('index'); + + $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepositoryId, $workspaceName); + $sharedRoleAssignment = WorkspaceRoleAssignment::createForGroup( + 'Neos.Neos:AbstractEditor', + WorkspaceRole::COLLABORATOR, + ); + + match($visibility) { + 'shared' => !$workspaceRoleAssignments->contains($sharedRoleAssignment) && $this->workspaceService->assignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleAssignment::createForGroup( + 'Neos.Neos:AbstractEditor', + WorkspaceRole::COLLABORATOR, + ) + ), + 'private' => $workspaceRoleAssignments->contains($sharedRoleAssignment) && $this->workspaceService->unassignWorkspaceRole( + $contentRepositoryId, + $workspaceName, + WorkspaceRoleSubject::createForGroup('Neos.Neos:AbstractEditor'), + ), + default => throw new \RuntimeException(sprintf('Invalid visibility %s given', $visibility), 1736339457) + }; + + if ($baseWorkspace !== null && $workspace->baseWorkspaceName?->equals($baseWorkspace) === false) { + // Update Base Workspace + $this->workspacePublishingService->changeBaseWorkspace( + $contentRepositoryId, + $workspaceName, + $baseWorkspace + ); + } + + $this->addFlashMessage( + $this->getModuleLabel( + 'workspaces.workspaceHasBeenUpdated', + [$title->value], + ) + ); + + $this->forward('index'); } /** * Delete a workspace * - * @param WorkspaceName $workspaceName A workspace to delete - * @throws IndexOutOfBoundsException - * @throws InvalidFormatPlaceholderException * @throws StopActionException - * @throws DBALException */ public function deleteAction(WorkspaceName $workspaceName): void { @@ -344,13 +436,13 @@ public function deleteAction(WorkspaceName $workspaceName): void '', Message::SEVERITY_ERROR ); - $this->redirect('index'); + $this->throwStatus(404, 'Workspace does not exist'); } $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); if ($workspaceMetadata->classification === WorkspaceClassification::PERSONAL) { - $this->redirect('index'); + $this->throwStatus(403, 'Personal workspaces cannot be deleted'); } $dependentWorkspaces = $contentRepository->findWorkspaces()->getDependantWorkspaces($workspaceName); @@ -362,222 +454,127 @@ public function deleteAction(WorkspaceName $workspaceName): void $dependentWorkspaceTitles[] = $dependentWorkspaceMetadata->title->value; } - $message = $this->translator->translateById( + $message = $this->getModuleLabel( 'workspaces.workspaceCannotBeDeletedBecauseOfDependencies', [$workspaceMetadata->title->value, implode(', ', $dependentWorkspaceTitles)], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.workspaceCannotBeDeletedBecauseOfDependencies'; + ); $this->addFlashMessage($message, '', Message::SEVERITY_WARNING); - $this->redirect('index'); + $this->throwStatus(403, 'Workspace has dependencies'); } - if ($workspace->hasPublishableChanges()) { - $nodesCount = $this->workspacePublishingService->countPendingWorkspaceChanges($contentRepositoryId, $workspaceName); - $message = $this->translator->translateById( + $nodesCount = 0; + + try { + $nodesCount = $contentRepository->projectionState(ChangeFinder::class) + ->countByContentStreamId( + $workspace->currentContentStreamId + ); + } catch (\Exception $exception) { + $message = $this->getModuleLabel( + 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes', + [$workspaceMetadata->title->value], + ); + $this->addFlashMessage($message, '', Message::SEVERITY_WARNING); + $this->throwStatus(500, 'Error while fetching unpublished nodes'); + } + if ($nodesCount > 0) { + $message = $this->getModuleLabel( 'workspaces.workspaceCannotBeDeletedBecauseOfUnpublishedNodes', [$workspaceMetadata->title->value, $nodesCount], $nodesCount, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.workspaceCannotBeDeletedBecauseOfUnpublishedNodes'; + ); $this->addFlashMessage($message, '', Message::SEVERITY_WARNING); - $this->redirect('index'); - } - - $contentRepository->handle( - DeleteWorkspace::create( - $workspaceName, - ) - ); - - $this->addFlashMessage($this->translator->translateById( - 'workspaces.workspaceHasBeenRemoved', - [$workspaceMetadata->title->value], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.workspaceHasBeenRemoved'); - $this->redirect('index'); - } - - /** - * Rebase the current users personal workspace onto the given $targetWorkspace and then - * redirects to the $targetNode in the content module. - */ - public function rebaseAndRedirectAction(string $targetNode, Workspace $targetWorkspace): void - { - $targetNodeAddress = NodeAddress::fromJsonString($targetNode); + $this->throwStatus(403, 'Workspace has unpublished nodes'); + // delete workspace on POST -> TODO: Split this into 2 actions like the create or edit workflows + } elseif ($this->request->getHttpRequest()->getMethod() === 'POST') { + $this->workspaceService->deleteWorkspace($contentRepositoryId, $workspaceName); - $user = $this->userService->getCurrentUser(); - if ($user === null) { - throw new \RuntimeException('No account is authenticated', 1710068880); - } - $personalWorkspace = $this->workspaceService->getPersonalWorkspaceForUser($targetNodeAddress->contentRepositoryId, $user->getId()); - - /** @todo do something else - * if ($personalWorkspace !== $targetWorkspace) { - * if ($this->publishingService->getUnpublishedNodesCount($personalWorkspace) > 0) { - * $message = $this->translator->translateById( - * 'workspaces.cantEditBecauseWorkspaceContainsChanges', - * [], - * null, - * null, - * 'Main, - * 'Neos.Workspace.Ui - * ) ?: 'workspaces.cantEditBecauseWorkspaceContainsChanges'; - * $this->addFlashMessage($message, '', Message::SEVERITY_WARNING, [], 1437833387); - * $this->redirect('show', null, null, ['workspace' => $targetWorkspace]); - * } - * $personalWorkspace->setBaseWorkspace($targetWorkspace); - * $this->workspaceFinder->update($personalWorkspace); - * } - */ - - $targetNodeAddressInPersonalWorkspace = NodeAddress::create( - $targetNodeAddress->contentRepositoryId, - $personalWorkspace->workspaceName, - $targetNodeAddress->dimensionSpacePoint, - $targetNodeAddress->aggregateId - ); - - if ($this->packageManager->isPackageAvailable('Neos.Neos.Ui')) { - $mainRequest = $this->controllerContext->getRequest()->getMainRequest(); - $this->uriBuilder->setRequest($mainRequest); - - $this->redirect( - 'index', - 'Backend', - 'Neos.Neos.Ui', - ['node' => $targetNodeAddressInPersonalWorkspace->toJson()] + $this->addFlashMessage( + $this->getModuleLabel( + 'workspaces.workspaceHasBeenRemoved', + [$workspaceMetadata->title->value], + ) ); + // Render a confirmation form if the request is not a POST request + } else { + $this->view->assign('workspaceName', $workspace->workspaceName->value); + $this->view->assign('workspaceTitle', $workspaceMetadata->title->value); } - - $this->redirectToUri( - $this->nodeUriBuilderFactory->forActionRequest($this->request) - ->uriFor($targetNodeAddressInPersonalWorkspace) - ); } /** - * Publish a single node - * - * @param string $nodeAddress - * @param WorkspaceName $selectedWorkspace + * Publish a single document node */ - public function publishNodeAction(string $nodeAddress, WorkspaceName $selectedWorkspace): void + public function publishDocumentAction(string $nodeAddress, WorkspaceName $selectedWorkspace): void { $nodeAddress = NodeAddress::fromJsonString($nodeAddress); - - $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - - $command = PublishIndividualNodesFromWorkspace::create( + $contentRepositoryId = $nodeAddress->contentRepositoryId; + $this->workspacePublishingService->publishChangesInDocument( + $contentRepositoryId, $selectedWorkspace, - NodeAggregateIds::create($nodeAddress->aggregateId), + $nodeAddress->aggregateId ); - $contentRepository->handle($command); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.selectedChangeHasBeenPublished', - [], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.selectedChangeHasBeenPublished'); - $this->redirect('show', null, null, ['workspace' => $selectedWorkspace->value]); + $this->addFlashMessage($this->getModuleLabel('workspaces.selectedChangeHasBeenPublished')); + $this->forward('review', null, null, ['workspace' => $selectedWorkspace->value]); } /** - * Discard a a single node + * Discard a single document node * - * @param string $nodeAddress - * @param WorkspaceName $selectedWorkspace + * @throws WorkspaceRebaseFailed */ - public function discardNodeAction(string $nodeAddress, WorkspaceName $selectedWorkspace): void + public function discardDocumentAction(string $nodeAddress, WorkspaceName $selectedWorkspace): void { $nodeAddress = NodeAddress::fromJsonString($nodeAddress); - - $contentRepository = $this->contentRepositoryRegistry->get($nodeAddress->contentRepositoryId); - - $command = DiscardIndividualNodesFromWorkspace::create( + $contentRepositoryId = $nodeAddress->contentRepositoryId; + $this->workspacePublishingService->discardChangesInDocument( + $contentRepositoryId, $selectedWorkspace, - NodeAggregateIds::create( - $nodeAddress->aggregateId - ), + $nodeAddress->aggregateId ); - $contentRepository->handle($command); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.selectedChangeHasBeenDiscarded', - [], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.selectedChangeHasBeenDiscarded'); - $this->redirect('show', null, null, ['workspace' => $selectedWorkspace->value]); + $this->addFlashMessage($this->getModuleLabel('workspaces.selectedChangeHasBeenDiscarded')); + $this->forward('review', null, null, ['workspace' => $selectedWorkspace->value]); } /** * @psalm-param list $nodes - * @throws IndexOutOfBoundsException - * @throws InvalidFormatPlaceholderException - * @throws StopActionException */ - public function publishOrDiscardNodesAction(array $nodes, string $action, string $selectedWorkspace): void + public function publishOrDiscardNodesAction(array $nodes, string $action, WorkspaceName $workspace): void { - $selectedWorkspaceName = WorkspaceName::fromString($selectedWorkspace); $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest()) ->contentRepositoryId; - $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); - - $nodesToPublishOrDiscard = []; - foreach ($nodes as $node) { - $nodeAddress = NodeAddress::fromJsonString($node); - $nodesToPublishOrDiscard[] = $nodeAddress->aggregateId; - } switch ($action) { case 'publish': - $command = PublishIndividualNodesFromWorkspace::create( - $selectedWorkspaceName, - NodeAggregateIds::create(...$nodesToPublishOrDiscard), + foreach ($nodes as $node) { + $nodeAddress = NodeAddress::fromJsonString($node); + $this->workspacePublishingService->publishChangesInDocument( + $contentRepositoryId, + $workspace, + $nodeAddress->aggregateId + ); + } + $this->addFlashMessage( + $this->getModuleLabel('workspaces.selectedChangesHaveBeenPublished') ); - $contentRepository->handle($command); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.selectedChangesHaveBeenPublished', - [], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.selectedChangesHaveBeenPublished'); break; case 'discard': - $command = DiscardIndividualNodesFromWorkspace::create( - $selectedWorkspaceName, - NodeAggregateIds::create(...$nodesToPublishOrDiscard), - ); - $contentRepository->handle($command); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.selectedChangesHaveBeenDiscarded', - [], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.selectedChangesHaveBeenDiscarded'); + foreach ($nodes as $node) { + $nodeAddress = NodeAddress::fromJsonString($node); + $this->workspacePublishingService->discardChangesInDocument( + $contentRepositoryId, + $workspace, + $nodeAddress->aggregateId + ); + } + $this->addFlashMessage($this->getModuleLabel('workspaces.selectedChangesHaveBeenDiscarded')); break; default: throw new \RuntimeException('Invalid action "' . htmlspecialchars($action) . '" given.', 1346167441); } - - $this->redirect('show', null, null, ['workspace' => $selectedWorkspaceName->value]); + $this->forward('review', null, null, ['workspace' => $workspace->value]); } /** @@ -590,18 +587,101 @@ public function publishWorkspaceAction(WorkspaceName $workspace): void $contentRepositoryId, $workspace, ); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.allChangesInWorkspaceHaveBeenPublished', - [ - htmlspecialchars($workspace->value), - htmlspecialchars($publishingResult->targetWorkspaceName->value) - ], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.allChangesInWorkspaceHaveBeenPublished'); - $this->redirect('index'); + $this->addFlashMessage( + $this->getModuleLabel( + 'workspaces.allChangesInWorkspaceHaveBeenPublished', + [ + htmlspecialchars($workspace->value), + htmlspecialchars($publishingResult->targetWorkspaceName->value) + ], + ) + ); + $this->forward('index'); + } + + public function confirmPublishAllChangesAction(WorkspaceName $workspaceName): void + { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->findWorkspaceByName($workspaceName); + if ($workspace === null) { + $this->addFlashMessage( + $this->getModuleLabel('workspaces.workspaceDoesNotExist'), + '', + Message::SEVERITY_ERROR + ); + $this->throwStatus(404, 'Workspace does not exist'); + } + + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + $this->view->assignMultiple([ + 'workspaceName' => $workspaceName->value, + 'workspaceTitle' => $workspaceMetadata->title->value, + ]); + } + + public function confirmDiscardAllChangesAction(WorkspaceName $workspaceName): void + { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->findWorkspaceByName($workspaceName); + if ($workspace === null) { + $this->addFlashMessage( + $this->getModuleLabel('workspaces.workspaceDoesNotExist'), + '', + Message::SEVERITY_ERROR + ); + $this->throwStatus(404, 'Workspace does not exist'); + } + + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + $this->view->assignMultiple([ + 'workspaceName' => $workspaceName->value, + 'workspaceTitle' => $workspaceMetadata->title->value, + ]); + } + + public function confirmPublishSelectedChangesAction(WorkspaceName $workspaceName): void + { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->findWorkspaceByName($workspaceName); + if ($workspace === null) { + $this->addFlashMessage( + $this->getModuleLabel('workspaces.workspaceDoesNotExist'), + '', + Message::SEVERITY_ERROR + ); + $this->throwStatus(404, 'Workspace does not exist'); + } + $baseWorkspace = $this->requireBaseWorkspace($workspace, $contentRepository); + + $baseWorkspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $baseWorkspace->workspaceName); + $this->view->assignMultiple([ + 'workspaceName' => $workspaceName->value, + 'baseWorkspaceTitle' => $baseWorkspaceMetadata->title->value, + ]); + } + + public function confirmDiscardSelectedChangesAction(WorkspaceName $workspaceName): void + { + $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $contentRepository = $this->contentRepositoryRegistry->get($contentRepositoryId); + $workspace = $contentRepository->findWorkspaceByName($workspaceName); + if ($workspace === null) { + $this->addFlashMessage( + $this->getModuleLabel('workspaces.workspaceDoesNotExist'), + '', + Message::SEVERITY_ERROR + ); + $this->throwStatus(404, 'Workspace does not exist'); + } + + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepositoryId, $workspace->workspaceName); + $this->view->assignMultiple([ + 'workspaceName' => $workspaceName->value, + 'workspaceTitle' => $workspaceMetadata->title->value, + ]); } /** @@ -612,19 +692,18 @@ public function publishWorkspaceAction(WorkspaceName $workspace): void public function discardWorkspaceAction(WorkspaceName $workspace): void { $contentRepositoryId = SiteDetectionResult::fromRequest($this->request->getHttpRequest())->contentRepositoryId; + $this->workspacePublishingService->discardAllWorkspaceChanges( $contentRepositoryId, $workspace, ); - $this->addFlashMessage($this->translator->translateById( - 'workspaces.allChangesInWorkspaceHaveBeenDiscarded', - [htmlspecialchars($workspace->value)], - null, - null, - 'Main', - 'Neos.Workspace.Ui' - ) ?: 'workspaces.allChangesInWorkspaceHaveBeenDiscarded'); - $this->redirect('index'); + $this->addFlashMessage( + $this->getModuleLabel( + 'workspaces.allChangesInWorkspaceHaveBeenDiscarded', + [htmlspecialchars($workspace->value)], + ) + ); + $this->forward('review', null, null, ['workspace' => $workspace->value]); } /** @@ -633,20 +712,16 @@ public function discardWorkspaceAction(WorkspaceName $workspace): void protected function computePendingChanges(Workspace $selectedWorkspace, ContentRepository $contentRepository): PendingChanges { $changesCount = ['new' => 0, 'changed' => 0, 'removed' => 0]; - foreach ($this->computeSiteChanges($selectedWorkspace, $contentRepository) as $siteChanges) { - foreach ($siteChanges['documents'] as $documentChanges) { - foreach ($documentChanges['changes'] as $change) { - if ($change['isRemoved'] === true) { - $changesCount['removed']++; - } elseif ($change['isNew']) { - $changesCount['new']++; - } else { - $changesCount['changed']++; - } - } + foreach($this->getChangesFromWorkspace($selectedWorkspace, $contentRepository) as $change) { + if ($change->deleted) { + $changesCount['removed']++; + } elseif ($change->created) { + $changesCount['new']++; + } else { + $changesCount['changed']++; } } - return new PendingChanges(new: $changesCount['new'], changed: $changesCount['changed'], removed: $changesCount['removed']); + return new PendingChanges(new: $changesCount['new'], changed: $changesCount['changed'], removed:$changesCount['removed']); } /** @@ -656,27 +731,26 @@ protected function computePendingChanges(Workspace $selectedWorkspace, ContentRe protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepository $contentRepository): array { $siteChanges = []; - $changes = $contentRepository->projectionState(ChangeFinder::class) - ->findByContentStreamId( - $selectedWorkspace->currentContentStreamId - ); + $changes = $this->getChangesFromWorkspace($selectedWorkspace, $contentRepository); + + // TODO hack for the case $change->originDimensionSpacePoint is NULL so we can fetch a subgraph still. This is the case for changes NodeAggregateNameWasChanged or NodeAggregateTypeWasChanged $dimensionSpacePoints = iterator_to_array($contentRepository->getVariationGraph()->getDimensionSpacePoints()); /** @var DimensionSpacePoint $arbitraryDimensionSpacePoint */ $arbitraryDimensionSpacePoint = reset($dimensionSpacePoints); - $selectedWorkspaceContentGraph = $contentRepository->getContentGraph($selectedWorkspace->workspaceName); - // If we deleted a node, there is no way for us to anymore find the deleted node in the ContentStream - // where the node was deleted. - // Thus, to figure out the rootline for display, we check the *base workspace* Content Stream. - // - // This is safe because the UI basically shows what would be removed once the deletion is published. - $baseWorkspace = $this->getBaseWorkspaceWhenSureItExists($selectedWorkspace, $contentRepository); - $baseWorkspaceContentGraph = $contentRepository->getContentGraph($baseWorkspace->workspaceName); - foreach ($changes as $change) { - $contentGraph = $change->deleted ? $baseWorkspaceContentGraph : $selectedWorkspaceContentGraph; - $subgraph = $contentGraph->getSubgraph( - $change->originDimensionSpacePoint?->toDimensionSpacePoint() ?: $arbitraryDimensionSpacePoint, + $workspaceName = $selectedWorkspace->workspaceName; + if ($change->deleted) { + // If we deleted a node, there is no way for us to anymore find the deleted node in the ContentStream + // where the node was deleted. + // Thus, to figure out the rootline for display, we check the *base workspace* Content Stream. + // + // This is safe because the UI basically shows what would be removed once the deletion is published. + $baseWorkspace = $this->requireBaseWorkspace($selectedWorkspace, $contentRepository); + $workspaceName = $baseWorkspace->workspaceName; + } + $subgraph = $contentRepository->getContentGraph($workspaceName)->getSubgraph( + $change->originDimensionSpacePoint?->toDimensionSpacePoint() ?? $arbitraryDimensionSpacePoint, VisibilityConstraints::withoutRestrictions() ); @@ -692,6 +766,7 @@ protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepos $nodePathSegments = []; $documentPathSegments = []; + $documentPathSegmentsNames = []; foreach ($ancestors as $ancestor) { $pathSegment = $ancestor->name ?: NodeName::fromString($ancestor->aggregateId->value); // Don't include `sites` path as they are not needed @@ -701,6 +776,7 @@ protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepos } if ($this->getNodeType($ancestor)->isOfType(NodeTypeNameFactory::NAME_DOCUMENT)) { $documentPathSegments[] = $pathSegment; + $documentPathSegmentsNames[] = $this->nodeLabelGenerator->getLabel($ancestor); if (is_null($documentNode)) { $documentNode = $ancestor; } @@ -712,30 +788,55 @@ protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepos // Neither $documentNode, $siteNode or its cannot really be null, this is just for type checks; // We should probably throw an exception though + if ($documentNode !== null && $siteNode !== null && $siteNode->name) { $siteNodeName = $siteNode->name->value; // Reverse `$documentPathSegments` to start with the site node. // The paths are used for grouping the nodes and for selecting a tree of nodes. - $documentPath = implode('/', array_reverse(array_map( - fn (NodeName $nodeName): string => $nodeName->value, - $documentPathSegments - ))); + $documentPath = implode( + '/', + array_reverse( + array_map( + fn(NodeName $nodeName): string => $nodeName->value, + $documentPathSegments + ) + ) + ); // Reverse `$nodePathSegments` to start with the site node. // The paths are used for grouping the nodes and for selecting a tree of nodes. - $relativePath = implode('/', array_reverse(array_map( - fn (NodeName $nodeName): string => $nodeName->value, - $nodePathSegments - ))); - if (!isset($siteChanges[$siteNodeName]['siteNode'])) { - $siteChanges[$siteNodeName]['siteNode'] - = $this->siteRepository->findOneByNodeName(SiteNodeName::fromString($siteNodeName)); + $relativePath = implode( + '/', + array_reverse( + array_map( + fn(NodeName $nodeName): string => $nodeName->value, + $nodePathSegments + ) + ) + ); + + if(!isset($siteChanges[$siteNodeName]['documents'][$documentPath]['document'])) { + $documentNodeAddress = NodeAddress::create( + $contentRepository->id, + $selectedWorkspace->workspaceName, + $documentNode->originDimensionSpacePoint->toDimensionSpacePoint(), + $documentNode->aggregateId + ); + $documentType = $contentRepository->getNodeTypeManager()->getNodeType($documentNode->nodeTypeName); + $siteChanges[$siteNodeName]['documents'][$documentPath]['document'] = new DocumentItem( + documentBreadCrumb: array_reverse($documentPathSegmentsNames), + aggregateId: $documentNodeAddress->aggregateId->value, + documentNodeAddress: $documentNodeAddress->toJson(), + documentIcon: $documentType?->getFullConfiguration()['ui']['icon'] ?? null + ); } - $siteChanges[$siteNodeName]['documents'][$documentPath]['documentNode'] = $documentNode; - // We need to set `isNew` and `isMoved` on document level to make our JS behave as before. if ($documentNode->equals($node)) { - $siteChanges[$siteNodeName]['documents'][$documentPath]['isNew'] = $change->created; - $siteChanges[$siteNodeName]['documents'][$documentPath]['isMoved'] = $change->moved; + $siteChanges[$siteNodeName]['documents'][$documentPath]['documentChanges'] = new DocumentChangeItem( + isRemoved: $change->deleted, + isNew: $change->created, + isMoved: $change->moved, + isHidden: $documentNode->tags->contain(SubtreeTag::disabled()), + ); } // As for changes of type `delete` we are using nodes from the live workspace @@ -744,29 +845,38 @@ protected function computeSiteChanges(Workspace $selectedWorkspace, ContentRepos $nodeAddress = NodeAddress::create( $contentRepository->id, $selectedWorkspace->workspaceName, - $change->originDimensionSpacePoint?->toDimensionSpacePoint() ?: $arbitraryDimensionSpacePoint, + $change->originDimensionSpacePoint?->toDimensionSpacePoint() ?? $arbitraryDimensionSpacePoint, $change->nodeAggregateId ); - - $change = [ - 'node' => $node, - 'serializedNodeAddress' => $nodeAddress->toJson(), - 'isRemoved' => $change->deleted, - 'isNew' => $change->created, - 'isMoved' => $change->moved, - 'contentChanges' => $this->renderContentChanges( + $nodeType = $contentRepository->getNodeTypeManager()->getNodeType($node->nodeTypeName); + $dimensions = []; + foreach ($node->dimensionSpacePoint->coordinates as $id => $coordinate) { + $contentDimension = new ContentDimensionId($id); + $dimensions[] = $contentRepository->getContentDimensionSource() + ->getDimension($contentDimension) + ?->getValue($coordinate) + ?->configuration['label'] ?? $coordinate; + } + $siteChanges[$siteNodeName]['documents'][$documentPath]['changes'][$node->dimensionSpacePoint->hash][$relativePath] = new ChangeItem( + serializedNodeAddress: $nodeAddress->toJson(), + hidden: $node->tags->contain(SubtreeTag::disabled()), + isRemoved: $change->deleted, + isNew: $change->created, + isMoved: $change->moved, + dimensions: $dimensions, + lastModificationDateTime: $node->timestamps->lastModified?->format('Y-m-d H:i'), + createdDateTime: $node->timestamps->created->format('Y-m-d H:i'), + label: $this->nodeLabelGenerator->getLabel($node), + icon: $nodeType?->getFullConfiguration()['ui']['icon'], + contentChanges: $this->renderContentChanges( $node, $change->contentStreamId, $contentRepository ) - ]; - $nodeType = $this->getNodeType($node); - if ($nodeType->isOfType('Neos.Neos:Node')) { - $change['configuration'] = $nodeType->getFullConfiguration(); - } - $siteChanges[$siteNodeName]['documents'][$documentPath]['changes'][$relativePath] = $change; + ); } } + } ksort($siteChanges); @@ -797,30 +907,43 @@ protected function getOriginalNode( /** * Renders the difference between the original and the changed content of the given node and returns it, along - * with meta information, in an array. - * - * @return array + * with meta information */ protected function renderContentChanges( Node $changedNode, ContentStreamId $contentStreamIdOfOriginalNode, ContentRepository $contentRepository, - ): array { + ): ContentChangeItems { $currentWorkspace = $contentRepository->findWorkspaces()->find( fn (Workspace $potentialWorkspace) => $potentialWorkspace->currentContentStreamId->equals($contentStreamIdOfOriginalNode) ); $originalNode = null; if ($currentWorkspace !== null) { - $baseWorkspace = $this->getBaseWorkspaceWhenSureItExists($currentWorkspace, $contentRepository); + $baseWorkspace = $this->requireBaseWorkspace($currentWorkspace, $contentRepository); $originalNode = $this->getOriginalNode($changedNode, $baseWorkspace->workspaceName, $contentRepository); } - $contentChanges = []; $changeNodePropertiesDefaults = $this->getNodeType($changedNode)->getDefaultValuesForProperties(); $renderer = new HtmlArrayRenderer(); + + $actualOriginalTags = $originalNode?->tags->withoutInherited()->all(); + $actualChangedTags = $changedNode->tags->withoutInherited()->all(); + + if ($actualOriginalTags?->equals($actualChangedTags) === false) { + $contentChanges['tags'] = new ContentChangeItem( + properties: new ContentChangeProperties( + type: 'tags', + propertyLabel: $this->getModuleLabel('workspaces.changedTags'), + ), + changes: new TagContentChange( + addedTags: $actualChangedTags->difference($actualOriginalTags)->toStringArray(), + removedTags: $actualOriginalTags->difference($actualChangedTags)->toStringArray(), + ) + ); + } foreach ($changedNode->properties as $propertyName => $changedPropertyValue) { if ( ($originalNode === null && empty($changedPropertyValue)) @@ -835,15 +958,11 @@ protected function renderContentChanges( $originalPropertyValue = ($originalNode?->getProperty($propertyName)); if ($changedPropertyValue === $originalPropertyValue) { - // TODO && !$changedNode->isRemoved() continue; } if (!is_object($originalPropertyValue) && !is_object($changedPropertyValue)) { $originalSlimmedDownContent = $this->renderSlimmedDownContent($originalPropertyValue); - // TODO $changedSlimmedDownContent = $changedNode->isRemoved() - // ? '' - // : $this->renderSlimmedDownContent($changedPropertyValue); $changedSlimmedDownContent = $this->renderSlimmedDownContent($changedPropertyValue); $diff = new Diff( @@ -855,11 +974,16 @@ protected function renderContentChanges( $this->postProcessDiffArray($diffArray); if (count($diffArray) > 0) { - $contentChanges[$propertyName] = [ - 'type' => 'text', - 'propertyLabel' => $this->getPropertyLabel($propertyName, $changedNode), - 'diff' => $diffArray - ]; + + $contentChanges[$propertyName] = new ContentChangeItem( + properties: new ContentChangeProperties( + type: 'text', + propertyLabel: $this->getPropertyLabel($propertyName, $changedNode) + ), + changes: new TextContentChange( + diff: $diffArray + ) + ); } // The && in belows condition is on purpose as creating a thumbnail for comparison only works // if actually BOTH are ImageInterface (or NULL). @@ -867,22 +991,30 @@ protected function renderContentChanges( ($originalPropertyValue instanceof ImageInterface || $originalPropertyValue === null) && ($changedPropertyValue instanceof ImageInterface || $changedPropertyValue === null) ) { - $contentChanges[$propertyName] = [ - 'type' => 'image', - 'propertyLabel' => $this->getPropertyLabel($propertyName, $changedNode), - 'original' => $originalPropertyValue, - 'changed' => $changedPropertyValue - ]; + $contentChanges[$propertyName] = new ContentChangeItem( + properties: new ContentChangeProperties( + type: 'text', + propertyLabel: $this->getPropertyLabel($propertyName, $changedNode) + ), + changes: new ImageContentChange( + original: $originalPropertyValue, + changed: $changedPropertyValue + ) + ); } elseif ( $originalPropertyValue instanceof AssetInterface || $changedPropertyValue instanceof AssetInterface ) { - $contentChanges[$propertyName] = [ - 'type' => 'asset', - 'propertyLabel' => $this->getPropertyLabel($propertyName, $changedNode), - 'original' => $originalPropertyValue, - 'changed' => $changedPropertyValue - ]; + $contentChanges[$propertyName] = new ContentChangeItem( + properties: new ContentChangeProperties( + type: 'text', + propertyLabel: $this->getPropertyLabel($propertyName, $changedNode) + ), + changes: new AssetContentChange( + original: $originalPropertyValue, + changed: $changedPropertyValue + ) + ); } elseif ($originalPropertyValue instanceof \DateTime || $changedPropertyValue instanceof \DateTime) { $changed = false; if (!$changedPropertyValue instanceof \DateTime || !$originalPropertyValue instanceof \DateTime) { @@ -891,16 +1023,20 @@ protected function renderContentChanges( $changed = true; } if ($changed) { - $contentChanges[$propertyName] = [ - 'type' => 'datetime', - 'propertyLabel' => $this->getPropertyLabel($propertyName, $changedNode), - 'original' => $originalPropertyValue, - 'changed' => $changedPropertyValue - ]; + $contentChanges[$propertyName] = new ContentChangeItem( + properties: new ContentChangeProperties( + type: 'text', + propertyLabel: $this->getPropertyLabel($propertyName, $changedNode) + ), + changes: new DateTimeContentChange( + original: $originalPropertyValue, + changed: $changedPropertyValue + ) + ); } } } - return $contentChanges; + return ContentChangeItems::fromArray($contentChanges); } /** @@ -910,11 +1046,8 @@ protected function renderContentChanges( * Note: It's clear that this method needs to be extracted and moved to a more universal service at some point. * However, since we only implemented diff-view support for this particular controller at the moment, it stays * here for the time being. Once we start displaying diffs elsewhere, we should refactor the diff rendering part. - * - * @param mixed $propertyValue - * @return string */ - protected function renderSlimmedDownContent($propertyValue) + protected function renderSlimmedDownContent(mixed $propertyValue): string { $content = ''; if (is_string($propertyValue)) { @@ -928,21 +1061,17 @@ protected function renderSlimmedDownContent($propertyValue) /** * Tries to determine a label for the specified property - * - * @param string $propertyName - * @param Node $changedNode - * @return string */ - protected function getPropertyLabel($propertyName, Node $changedNode) + protected function getPropertyLabel(string $propertyName, Node $changedNode): string { $properties = $this->getNodeType($changedNode)->getProperties(); - if ( - !isset($properties[$propertyName]) - || !isset($properties[$propertyName]['ui']['label']) - ) { + $label = $properties[$propertyName]['ui']['label'] ?? null; + if ($label === null) { return $propertyName; } - return $properties[$propertyName]['ui']['label']; + + // hack, we use the eel helper here to support the shorthand syntax: PackageKey:Source:trans-unit-id + return (new TranslationHelper())->translate($label) ?: $label; } /** @@ -953,7 +1082,6 @@ protected function getPropertyLabel($propertyName, Node $changedNode) * do that in these cases. * * @param array &$diffArray - * @return void */ protected function postProcessDiffArray(array &$diffArray): void { @@ -976,69 +1104,151 @@ protected function postProcessDiffArray(array &$diffArray): void } /** - * Creates an array of workspace names and their respective titles which are possible base workspaces for other - * workspaces. - * If $excludedWorkspace is set, this workspace and all its base workspaces will be excluded from the list of returned workspaces + * Creates an array of workspace names and their respective titles which are possible base workspaces + * + * If $editedWorkspace is set, this workspace and all its nested workspaces will be excluded from the list of returned workspaces * - * @param ContentRepository $contentRepository - * @param WorkspaceName|null $excludedWorkspace - * @return array + * @return array */ protected function prepareBaseWorkspaceOptions( ContentRepository $contentRepository, - WorkspaceName $excludedWorkspace = null, + WorkspaceName|null $editedWorkspaceName ): array { - $currentUser = $this->userService->getCurrentUser(); + $user = $this->userService->getCurrentUser(); $baseWorkspaceOptions = []; $workspaces = $contentRepository->findWorkspaces(); + $editedWorkspace = $editedWorkspaceName ? $workspaces->get($editedWorkspaceName) : null; + if ($editedWorkspace?->baseWorkspaceName !== null) { + // ensure that the current base workspace is always part of the list even if permissions are not granted + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata( + $contentRepository->id, + $editedWorkspace->baseWorkspaceName + ); + $baseWorkspaceOptions[$editedWorkspace->baseWorkspaceName->value] = $workspaceMetadata->title->value; + } + foreach ($workspaces as $workspace) { - if ($excludedWorkspace !== null) { - if ($workspace->workspaceName->equals($excludedWorkspace)) { + if ($editedWorkspaceName !== null) { + if ($workspace->workspaceName->equals($editedWorkspaceName)) { continue; } - if ($workspaces->getBaseWorkspaces($workspace->workspaceName)->get($excludedWorkspace) !== null) { + if ($workspaces->getBaseWorkspaces($workspace->workspaceName)->get($editedWorkspaceName) !== null) { continue; } } - $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepository->id, $workspace->workspaceName); + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata( + $contentRepository->id, + $workspace->workspaceName + ); if (!in_array($workspaceMetadata->classification, [WorkspaceClassification::SHARED, WorkspaceClassification::ROOT], true)) { continue; } - $permissions = $this->contentRepositoryAuthorizationService->getWorkspacePermissions($contentRepository->id, $workspace->workspaceName, $this->securityContext->getRoles(), $currentUser?->getId()); + $permissions = $this->authorizationService->getWorkspacePermissions( + $contentRepository->id, + $workspace->workspaceName, + $this->securityContext->getRoles(), + $user?->getId() + ); if (!$permissions->read) { continue; } $baseWorkspaceOptions[$workspace->workspaceName->value] = $workspaceMetadata->title->value; } + // Sort the base workspaces by title, but make sure the live workspace is always on top + uksort($baseWorkspaceOptions, static function (string $a, string $b) { + if ($a === 'live') { + return -1; + } + if ($b === 'live') { + return 1; + } + return strcasecmp($a, $b); + }); + return $baseWorkspaceOptions; } + private function requireBaseWorkspace( + Workspace $workspace, + ContentRepository $contentRepository, + ): Workspace { + if ($workspace->isRootWorkspace()) { + throw new \RuntimeException(sprintf('Workspace %s does not have a base-workspace.', $workspace->workspaceName->value), 1734019485); + } + $baseWorkspace = $contentRepository->findWorkspaceByName($workspace->baseWorkspaceName); + if ($baseWorkspace === null) { + throw new \RuntimeException(sprintf('Base-workspace %s of %s does not exist.', $workspace->baseWorkspaceName->value, $workspace->workspaceName->value), 1734019720); + } + return $baseWorkspace; + } + /** - * Creates an array of user names and their respective labels which are possible owners for a workspace. - * - * @return array + * @param array $arguments */ - protected function prepareOwnerOptions(): array + public function getModuleLabel(string $id, array $arguments = [], mixed $quantity = null): string { - $ownerOptions = ['' => '-']; - foreach ($this->userService->getUsers() as $user) { - /** @var User $user */ - $ownerOptions[$this->persistenceManager->getIdentifierByObject($user)] = $user->getLabel(); - } - - return $ownerOptions; + return $this->translator->translateById( + $id, + $arguments, + $quantity, + null, + 'Main', + 'Neos.Workspace.Ui' + ) ?: $id; } - private function getBaseWorkspaceWhenSureItExists( - Workspace $workspace, + protected function getWorkspaceListItems( ContentRepository $contentRepository, - ): Workspace { - /** @var WorkspaceName $baseWorkspaceName We expect this to exist */ - $baseWorkspaceName = $workspace->baseWorkspaceName; - /** @var Workspace $baseWorkspace We expect this to exist */ - $baseWorkspace = $contentRepository->findWorkspaceByName($baseWorkspaceName); + ): WorkspaceListItems { + $workspaceListItems = []; + $allWorkspaces = $contentRepository->findWorkspaces(); - return $baseWorkspace; + // add other, accessible workspaces + foreach ($allWorkspaces as $workspace) { + $workspaceMetadata = $this->workspaceService->getWorkspaceMetadata($contentRepository->id, $workspace->workspaceName); + $workspaceRoleAssignments = $this->workspaceService->getWorkspaceRoleAssignments($contentRepository->id, $workspace->workspaceName); + $workspacesPermissions = $this->authorizationService->getWorkspacePermissions( + $contentRepository->id, + $workspace->workspaceName, + $this->securityContext->getRoles(), + $this->userService->getCurrentUser()?->getId() + ); + + // ignore root workspaces, because they will not be shown in the UI + if ($workspace->isRootWorkspace()) { + continue; + } + + if ($workspacesPermissions->read === false) { + continue; + } + + $workspaceOwner = $workspaceMetadata->ownerUserId + ? $this->userService->findUserById($workspaceMetadata->ownerUserId) + : null; + + $workspaceListItems[$workspace->workspaceName->value] = new WorkspaceListItem( + $workspace->workspaceName->value, + $workspaceMetadata->classification->value, + $workspace->status->value, + $workspaceMetadata->title->value, + $workspaceMetadata->description->value, + $workspace->baseWorkspaceName->value, + $this->computePendingChanges($workspace, $contentRepository), + !$allWorkspaces->getDependantWorkspaces($workspace->workspaceName)->isEmpty(), + $workspaceOwner?->getLabel(), + $workspacesPermissions, + $workspaceRoleAssignments, + ); + } + return WorkspaceListItems::fromArray($workspaceListItems); + } + + protected function getChangesFromWorkspace(Workspace $selectedWorkspace,ContentRepository $contentRepository ): Changes{ + return $contentRepository->projectionState(ChangeFinder::class) + ->findByContentStreamId( + $selectedWorkspace->currentContentStreamId + ); } } diff --git a/Neos.Workspace.Ui/Classes/Mvc/HtmxRequestPattern.php b/Neos.Workspace.Ui/Classes/Mvc/HtmxRequestPattern.php new file mode 100644 index 00000000000..f85bae459d0 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/Mvc/HtmxRequestPattern.php @@ -0,0 +1,29 @@ +getFormat() === 'htmx' && $request->getControllerPackageKey() === 'Neos.Workspace.Ui'; + } +} diff --git a/Neos.Workspace.Ui/Classes/Mvc/HttpHeaderFlashMessageStorage.php b/Neos.Workspace.Ui/Classes/Mvc/HttpHeaderFlashMessageStorage.php new file mode 100644 index 00000000000..4a7690bfd22 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/Mvc/HttpHeaderFlashMessageStorage.php @@ -0,0 +1,51 @@ +flashMessageContainer === null) { + $this->flashMessageContainer = new FlashMessageContainer(); + } + return $this->flashMessageContainer; + } + + public function persist(HttpResponseInterface $response): HttpResponseInterface + { + $messages = array_map(static fn(Message $message) => [ + 'title' => $message->getTitle(), + 'message' => $message->render(), + 'severity' => $message->getSeverity(), + 'code' => $message->getCode(), + ], $this->flashMessageContainer?->getMessagesAndFlush() ?? []); + if ($messages === []) { + return $response; + } + return $response->withAddedHeader('X-Flow-FlashMessages', json_encode($messages, JSON_THROW_ON_ERROR)); + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/ChangeItem.php b/Neos.Workspace.Ui/Classes/ViewModel/ChangeItem.php new file mode 100644 index 00000000000..ec962f36eb8 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/ChangeItem.php @@ -0,0 +1,40 @@ + $dimensions */ + public function __construct( + public string $serializedNodeAddress, + public bool $hidden, + public bool $isRemoved, + public bool $isNew, + public bool $isMoved, + public array $dimensions, + public ?string $lastModificationDateTime, + public ?string $createdDateTime, + public string $label, + public ?string $icon, + public ContentChangeItems $contentChanges, + ) { + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/ChangeItems.php b/Neos.Workspace.Ui/Classes/ViewModel/ChangeItems.php new file mode 100644 index 00000000000..4c6f685df6a --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/ChangeItems.php @@ -0,0 +1,56 @@ + + * @internal for communication within the Workspace UI only + */ +#[Flow\Proxy(false)] +final readonly class ChangeItems implements \IteratorAggregate, \Countable +{ + /** + * @param array $items + */ + private function __construct( + private array $items, + ) { + } + + /** + * @param array $items + */ + public static function fromArray(array $items): self + { + foreach ($items as $item) { + if (!$item instanceof ChangeItem) { + throw new \InvalidArgumentException(sprintf('Expected instance of %s, got: %s', ChangeItem::class, get_debug_type($item)), 1718295710); + } + } + return new self($items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/ContentChangeItem.php b/Neos.Workspace.Ui/Classes/ViewModel/ContentChangeItem.php new file mode 100644 index 00000000000..9e55703f668 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/ContentChangeItem.php @@ -0,0 +1,35 @@ + + * @internal for communication within the Workspace UI only + */ +#[Flow\Proxy(false)] +final readonly class ContentChangeItems implements \IteratorAggregate, \Countable +{ + /** + * @param array $items + */ + private function __construct( + private array $items, + ) { + } + + /** + * @param array $items + */ + public static function fromArray(array $items): self + { + foreach ($items as $item) { + if (!$item instanceof ContentChangeItem) { + throw new \InvalidArgumentException(sprintf('Expected instance of %s, got: %s', ContentChangeItem::class, get_debug_type($item)), 1718295710); + } + } + return new self($items); + } + + public function getIterator(): \Traversable + { + yield from $this->items; + } + + public function count(): int + { + return count($this->items); + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/ContentChangeProperties.php b/Neos.Workspace.Ui/Classes/ViewModel/ContentChangeProperties.php new file mode 100644 index 00000000000..33ebb1fadc8 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/ContentChangeProperties.php @@ -0,0 +1,30 @@ + $addedTags + * @param list $removedTags + */ + public function __construct( + public array $addedTags, + public array $removedTags, + ) { + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/ContentChanges/TextContentChange.php b/Neos.Workspace.Ui/Classes/ViewModel/ContentChanges/TextContentChange.php new file mode 100644 index 00000000000..35e4e3d5de9 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/ContentChanges/TextContentChange.php @@ -0,0 +1,30 @@ + $diff */ + public function __construct( + public array $diff, + ) { + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/DocumentChangeItem.php b/Neos.Workspace.Ui/Classes/ViewModel/DocumentChangeItem.php new file mode 100644 index 00000000000..be1a7514414 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/DocumentChangeItem.php @@ -0,0 +1,32 @@ + $documentBreadCrumb */ + public function __construct( + public array $documentBreadCrumb, + public string $aggregateId, + public string $documentNodeAddress, + public ?string $documentIcon + ) { + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/EditWorkspaceFormData.php b/Neos.Workspace.Ui/Classes/ViewModel/EditWorkspaceFormData.php new file mode 100644 index 00000000000..471e9840538 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/EditWorkspaceFormData.php @@ -0,0 +1,41 @@ + $baseWorkspaceOptions Options for the baseWorkspace selector where the key is the workspace name and the value is the workspace title. + */ + public function __construct( + public WorkspaceName $workspaceName, + public WorkspaceTitle $workspaceTitle, + public WorkspaceDescription $workspaceDescription, + public bool $workspaceHasChanges, + public WorkspaceName $baseWorkspaceName, + public array $baseWorkspaceOptions, + public bool $isShared, + ) { + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php b/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php index ad999538841..4e6dfad1fcf 100644 --- a/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php +++ b/Neos.Workspace.Ui/Classes/ViewModel/PendingChanges.php @@ -16,6 +16,9 @@ use Neos\Flow\Annotations as Flow; +/** + * @internal for communication within the Workspace UI only + */ #[Flow\Proxy(false)] final readonly class PendingChanges { @@ -29,6 +32,7 @@ public function __construct( $this->total = $this->new + $this->changed + $this->removed; } + // todo check if necessary public function getNewCountRatio(): float { return $this->new / $this->total * 100; diff --git a/Neos.Workspace.Ui/Classes/ViewModel/Sorting.php b/Neos.Workspace.Ui/Classes/ViewModel/Sorting.php new file mode 100644 index 00000000000..c9ec1a85b45 --- /dev/null +++ b/Neos.Workspace.Ui/Classes/ViewModel/Sorting.php @@ -0,0 +1,53 @@ + $array + */ + public static function fromArray(array $array): self + { + return new self( + sortBy: $array['sortBy'], + sortAscending: (bool)$array['sortAscending'], + ); + } + + public function withInvertedSorting(): self + { + return new self( + sortBy: $this->sortBy, + sortAscending: !$this->sortAscending + ); + } + + public function jsonSerialize(): mixed + { + return get_object_vars($this); + } + + public function allowsCallOfMethod($methodName) + { + return in_array($methodName, ['withInvertedSorting', 'jsonSerialize'], true); + } +} diff --git a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php index b7c54aa3d6e..b3f11d0cc65 100644 --- a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php +++ b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItem.php @@ -15,20 +15,57 @@ namespace Neos\Workspace\Ui\ViewModel; use Neos\Flow\Annotations as Flow; +use Neos\Neos\Domain\Model\WorkspaceClassification; use Neos\Neos\Domain\Model\WorkspacePermissions; +use Neos\Neos\Domain\Model\WorkspaceRole; +use Neos\Neos\Domain\Model\WorkspaceRoleAssignments; +/** + * @internal for communication within the Workspace UI only + */ #[Flow\Proxy(false)] final readonly class WorkspaceListItem { public function __construct( public string $name, public string $classification, + public string $status, public string $title, public string $description, public ?string $baseWorkspaceName, public PendingChanges $pendingChanges, public bool $hasDependantWorkspaces, + public ?string $owner, public WorkspacePermissions $permissions, + public WorkspaceRoleAssignments $roleAssignments, ) { } + + public function isPersonal(): bool + { + return $this->classification === WorkspaceClassification::PERSONAL->value; + } + + public function isPrivate(): bool + { + if ($this->classification !== WorkspaceClassification::SHARED->value) { + return false; + } + foreach ($this->roleAssignments as $roleAssignment) { + if ($roleAssignment->role === WorkspaceRole::COLLABORATOR) { + return false; + } + } + return true; + } + + public function isShared(): bool + { + foreach ($this->roleAssignments as $roleAssignment) { + if ($roleAssignment->role === WorkspaceRole::COLLABORATOR) { + return true; + } + } + return false; + } } diff --git a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php index 39408c94bf9..626794cee9e 100644 --- a/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php +++ b/Neos.Workspace.Ui/Classes/ViewModel/WorkspaceListItems.php @@ -18,6 +18,7 @@ /** * @implements \IteratorAggregate + * @internal for communication within the Workspace UI only */ #[Flow\Proxy(false)] final readonly class WorkspaceListItems implements \IteratorAggregate, \Countable @@ -43,6 +44,15 @@ public static function fromArray(array $items): self return new self($items); } + public function sortByTitle(bool $ascending = true): self + { + $items = $this->items; + usort($items, static function (WorkspaceListItem $a, WorkspaceListItem $b) { + return strcasecmp($a->title, $b->title); + }); + return new self($ascending ? $items : array_reverse($items)); + } + public function getIterator(): \Traversable { yield from $this->items; diff --git a/Neos.Workspace.Ui/Configuration/Settings.Flow.yaml b/Neos.Workspace.Ui/Configuration/Settings.Flow.yaml new file mode 100644 index 00000000000..c13ff742363 --- /dev/null +++ b/Neos.Workspace.Ui/Configuration/Settings.Flow.yaml @@ -0,0 +1,10 @@ +Neos: + Flow: + mvc: + flashMessages: + containers: + 'Neos.Workspace.Ui:httpHeaderFlashMessages': + storage: 'Neos\Workspace\Ui\Mvc\HttpHeaderFlashMessageStorage' + requestPatterns: + 'htmx': + pattern: 'Neos\Workspace\Ui\Mvc\HtmxRequestPattern' diff --git a/Neos.Workspace.Ui/Configuration/Settings.Neos.yaml b/Neos.Workspace.Ui/Configuration/Settings.Neos.yaml index a52bfc23045..5a6eded8a75 100644 --- a/Neos.Workspace.Ui/Configuration/Settings.Neos.yaml +++ b/Neos.Workspace.Ui/Configuration/Settings.Neos.yaml @@ -9,6 +9,13 @@ Neos: description: 'Neos.Workspace.Ui:Main:workspaces.description' icon: fas fa-th-large mainStylesheet: 'Lite' + additionalResources: + javaScripts: + 'HtmxLibrary': 'resource://Neos.Workspace.Ui/Public/Scripts/htmx.min.js' + 'Module': 'resource://Neos.Workspace.Ui/Public/Scripts/Module.js' + 'Review': 'resource://Neos.Workspace.Ui/Public/Scripts/Review.js' + styleSheets: + 'Module': 'resource://Neos.Workspace.Ui/Public/Styles/Module.css' userInterface: translation: diff --git a/Neos.Workspace.Ui/Configuration/Views.yaml b/Neos.Workspace.Ui/Configuration/Views.yaml index 2754bc057b0..90a59214b4f 100644 --- a/Neos.Workspace.Ui/Configuration/Views.yaml +++ b/Neos.Workspace.Ui/Configuration/Views.yaml @@ -1,6 +1,8 @@ -- requestFilter: 'isPackage("Neos.Workspace.Ui") && isController("Workspace") && isFormat("html")' +- requestFilter: 'isPackage("Neos.Workspace.Ui") && isController("Workspace")' + viewObjectName: 'Neos\Fusion\View\FusionView' options: - layoutRootPathPattern: 'resource://Neos.Neos/Private/Layouts' - partialRootPaths: - - 'resource://Neos.Workspace.Ui/Private/Partials' - - 'resource://Neos.Neos/Private/Partials' + fusionPathPatterns: + - 'resource://Neos.Neos/Private/Fusion' + - 'resource://Neos.Fusion/Private/Fusion' + - 'resource://Neos.Fusion.Form/Private/Fusion' + - 'resource://Neos.Workspace.Ui/Private/Fusion' diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/FlashMessages.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/FlashMessages.fusion new file mode 100644 index 00000000000..1844c52fc2b --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/FlashMessages.fusion @@ -0,0 +1,40 @@ +prototype(Neos.Workspace.Ui:Component.FlashMessages) < prototype(Neos.Fusion:Component) { + flashMessages = ${[]} + + renderer = afx` +
+ + + +
+ ` +} + +prototype(Neos.Workspace.Ui:Component.FlashMessages.Message) < prototype(Neos.Fusion:Component) { + message = ${{}} + + severity = ${String.toLowerCase(this.message.severity)} + severity.@process.replaceOKStatus = ${value == 'ok' ? 'success' : value} + severity.@process.replaceNoticeStatus = ${value == 'notice' ? 'info' : value} + + renderer = afx` +
+
+ +
+ {props.message.title || props.message.message} +
+
+ {props.message.message} +
+
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Footer.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Footer.fusion new file mode 100644 index 00000000000..306c206ef7d --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Footer.fusion @@ -0,0 +1,9 @@ +prototype(Neos.Workspace.Ui:Component.Footer) < prototype(Neos.Fusion:Component) { + children = '' + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/HtmxConfig.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/HtmxConfig.fusion new file mode 100644 index 00000000000..147783dd929 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/HtmxConfig.fusion @@ -0,0 +1,40 @@ +prototype(Neos.Workspace.Ui:Component.HTMXConfig) < prototype(Neos.Fusion:Component) { + htmxConfig = Neos.Fusion:DataStructure { + // See https://htmx.org/docs/#response-handling + responseHandling { + // 204 - No Content by default does nothing, but is not an error + noContent { + code = 204 + swap = false + } + // 200 & 300 responses are non-errors and are swapped + nonErrors { + code = '[23]..' + swap = true + } + // 422 responses are swapped + invalidRequest { + code = 422 + swap = true + } + // 400 & 500 responses are not swapped and are errors + errors { + code = '[45]..' + swap = false + error = true + } + // 30X responses are swapped + redirects { + code = '[30]..' + swap = true + } + @process.toArray = ${Array.values(value)} + } + // See https://htmx.org/attributes/hx-swap-oob/#nested-oob-swaps + allowNestedOobSwaps = false + } + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/ModuleWrapper.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/ModuleWrapper.fusion new file mode 100644 index 00000000000..ff99d993d3d --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/ModuleWrapper.fusion @@ -0,0 +1,24 @@ +## +# Wrapper to be used for each "full" view like "index" or "review" +# +prototype(Neos.Workspace.Ui:Component.ModuleWrapper) < prototype(Neos.Fusion:Component) { + /// array + flashMessages = ${[]} + + /// string + content = '' + + renderer = afx` + + + +
+ {props.content} +
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Badge.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Badge.fusion new file mode 100644 index 00000000000..a2bc948fc44 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Badge.fusion @@ -0,0 +1,14 @@ +prototype(Neos.Workspace.Ui:Component.Badge) < prototype(Neos.Fusion:Component) { + /// string + label = '' + /// string + title = '' + /// string - one of 'added', 'changed', 'removed' + type = 'default' + + renderer = afx` + {props.label} + ` + + @if.hasLabel = ${this.label} +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Button.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Button.fusion new file mode 100644 index 00000000000..fe74214c64a --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Button.fusion @@ -0,0 +1,37 @@ +prototype(Neos.Workspace.Ui:Component.Button) < prototype(Neos.Fusion:Component) { + /// string + title = '' + /// string + label = '' + /// boolean + isDanger = false + /// boolean + isWarning = false + /// boolean + isPrimary = false + ///boolean + isSuccess = false + /// boolean + disabled = false + /// string + icon = '' + /// boolean + autofocus = false + /// array + attributes = Neos.Fusion:DataStructure + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Icon.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Icon.fusion new file mode 100644 index 00000000000..756dedff4ac --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Icon.fusion @@ -0,0 +1,22 @@ +prototype(Neos.Workspace.Ui:Component.Icon) < prototype(Neos.Fusion:Component) { + icon = '' + secondaryIcon = '' + spin = false + rotate = false + // TODO: Support colors + style = '' + + renderer = afx` + + + + + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Indicator.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Indicator.fusion new file mode 100644 index 00000000000..900e81e2e5b --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Common/Presentationals/Indicator.fusion @@ -0,0 +1,9 @@ +prototype(Neos.Workspace.Ui:Component.Indicator) < prototype(Neos.Fusion:Component) { + renderer = afx` +
+
+
+
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmDiscardAllChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmDiscardAllChanges.fusion new file mode 100644 index 00000000000..49716589c7a --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmDiscardAllChanges.fusion @@ -0,0 +1,9 @@ +Neos.Workspace.Ui.WorkspaceController.confirmDiscardAllChanges = Neos.Fusion:Component { + workspaceTitle = ${workspaceTitle} + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmDiscardSelectedChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmDiscardSelectedChanges.fusion new file mode 100644 index 00000000000..9c573dfa31f --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmDiscardSelectedChanges.fusion @@ -0,0 +1,9 @@ +Neos.Workspace.Ui.WorkspaceController.confirmDiscardSelectedChanges = Neos.Fusion:Component { + workspaceTitle = ${workspaceTitle} + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmPublishAllChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmPublishAllChanges.fusion new file mode 100644 index 00000000000..01a8387973f --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmPublishAllChanges.fusion @@ -0,0 +1,9 @@ +Neos.Workspace.Ui.WorkspaceController.confirmPublishAllChanges = Neos.Fusion:Component { + workspaceTitle = ${workspaceTitle} + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmPublishSelectedChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmPublishSelectedChanges.fusion new file mode 100644 index 00000000000..6e49efaf472 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/ConfirmPublishSelectedChanges.fusion @@ -0,0 +1,9 @@ +Neos.Workspace.Ui.WorkspaceController.confirmPublishSelectedChanges = Neos.Fusion:Component { + baseWorkspaceTitle = ${baseWorkspaceTitle} + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/Review.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/Review.fusion new file mode 100644 index 00000000000..c51d850f0d9 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Actions/Review.fusion @@ -0,0 +1,64 @@ +Neos.Workspace.Ui.WorkspaceController.review = Neos.Fusion:Component { + /// string + selectedWorkspaceName = ${selectedWorkspaceName} + /// string + selectedWorkspaceLabel = ${selectedWorkspaceLabel} + /// \Neos\ContentRepository\Core\SharedModel\Workspace\WorkspaceName + baseWorkspaceName = ${baseWorkspaceName} + /// string + baseWorkspaceLabel = ${baseWorkspaceLabel} + /// bool + canPublishToBaseWorkspace = ${canPublishToBaseWorkspace} + /// bool + canPublishToWorkspace = ${canPublishToWorkspace} + /// array + siteChanges = ${siteChanges} + /// array + contentDimensions = ${contentDimensions} + /// array + flashMessages = ${flashMessages} + + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + + renderer = afx` + +
+
+ + {props.i18n.id('workspaces.unpublishedChanges').arguments([selectedWorkspaceLabel]).translate()} + + + + +
+ {props.i18n.id('workspaces.reviewWorkspace.disabled').arguments([selectedWorkspaceLabel]).translate()} +
+
+
+
+ +
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewActions.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewActions.fusion new file mode 100644 index 00000000000..c13cf4929ea --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewActions.fusion @@ -0,0 +1,114 @@ +## +# Renders a document for review +# +prototype(Neos.Workspace.Ui:Component.ReviewActions) < prototype(Neos.Fusion:Component) { + hasSiteChanges = false + canPublishToBaseWorkspace = false + canPublishToWorkspace = false + selectedWorkspaceName = '' + baseWorkspaceLabel = '' + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + indexWorkspaceUri = Neos.Fusion:ActionUri { + action = 'index' + format = 'html' + } + confirmDiscardAllChanges = Neos.Fusion:ActionUri { + action = 'confirmDiscardAllChanges' + format = 'htmx' + arguments { + workspaceName = ${props.selectedWorkspaceName} + } + } + confirmPublishAllChanges = Neos.Fusion:ActionUri { + action = 'confirmPublishAllChanges' + format = 'htmx' + arguments { + workspaceName = ${props.selectedWorkspaceName} + } + } + confirmDiscardSelectedChanges = Neos.Fusion:ActionUri { + action = 'confirmDiscardSelectedChanges' + format = 'htmx' + arguments { + workspaceName = ${props.selectedWorkspaceName} + } + } + confirmPublishSelectedChanges= Neos.Fusion:ActionUri { + action = 'confirmPublishSelectedChanges' + format = 'htmx' + arguments { + workspaceName = ${props.selectedWorkspaceName} + } + } + + discardAllChangesPopoverId = 'confirm-discard-all-changes-popover' + publishAllChangesPopoverId = 'confirm-publish-all-changes-popover' + publishSelectedChangesPopoverId = 'confirm-publish-selected-changes-popover' + discardSelectedChangesPopoverId = 'confirm-discard-selected-changes-popover' + } + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewChangeDiff.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewChangeDiff.fusion new file mode 100644 index 00000000000..be26496aa42 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewChangeDiff.fusion @@ -0,0 +1,106 @@ +## +# Renders a document for review +# +prototype(Neos.Workspace.Ui:Component.ReviewChangeDiff) < prototype(Neos.Fusion:Component) { + + relativePath = '' + change = '' + document = '' + selectedWorkspaceName = ${selectedWorkspaceName} + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + + } + + renderer = afx` + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + +
+ {change.label} +
+
+ + {dimension} + +
+
+ + {change.lastModificationDateTime} +
+
+
+ + {contentChanges.properties.propertyLabel} +
+ + {line} + + + + {line} + + + + + + + + + {contentChanges.original.resource.filename} + + + + + + {contentChanges.changes.changed.resource.filename} + + + + {Date.format(contentChanges.changes.original, 'Y-m-d H:i')} + + {Date.format(contentChanges.changes.changed, 'Y-m-d H:i')} + + + {tag} + + + {tag} + + +
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewChangeTableRow.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewChangeTableRow.fusion new file mode 100644 index 00000000000..1f51fcbe89f --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewChangeTableRow.fusion @@ -0,0 +1,33 @@ +## +# Renders a document for review +# +prototype(Neos.Workspace.Ui:Component.ReviewChangeTableRow) < prototype(Neos.Fusion:Component) { + + relativePath = '' + change = ${change} + document = ${document} + canPublishToBaseWorkspace = ${canPublishToBaseWorkspace} + selectedWorkspaceName = ${selectedWorkspaceName} + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + } + + renderer = afx` + + + + + + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewDocumentTableRow.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewDocumentTableRow.fusion new file mode 100644 index 00000000000..6497a08b249 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewDocumentTableRow.fusion @@ -0,0 +1,115 @@ +## +# Renders a document for review +# +prototype(Neos.Workspace.Ui:Component.ReviewDocumentTableRow) < prototype(Neos.Fusion:Component) { + + document = null + documentPath = '' + selectedWorkspaceName = ${selectedWorkspaceName} + selectedWorkspaceLabel = ${selectedWorkspaceLabel} + canPublishToBaseWorkspace = ${canPublishToBaseWorkspace} + canPublishToWorkspace = ${canPublishToWorkspace} + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + + openNodeInPreviewAction = Neos.Fusion:ActionUri { + request=${request.mainRequest} + package='Neos.Neos' + controller='Frontend\\\Node' + action = 'preview' + // todo fixme + @process.addQuery = ${value + '?node=' + document.document.documentNodeAddress} + } + discardDocumentAction = Neos.Fusion:ActionUri { + action = 'discardDocument' + format = 'htmx' + arguments { + nodeAddress = ${document.document.documentNodeAddress} + selectedWorkspace = ${selectedWorkspaceName} + } + } + publishDocumentAction = Neos.Fusion:ActionUri { + action = 'publishDocument' + format = 'htmx' + arguments { + nodeAddress = ${document.document.documentNodeAddress} + selectedWorkspace = ${selectedWorkspaceName} + } + } + + } + + renderer = afx` + + + +
+ +
+ + +
+ +
+ + + + + + + + + + + {crumb} + + + + + + + + + + + + + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewTable.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewTable.fusion new file mode 100644 index 00000000000..7d64c851b97 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Components/ReviewTable.fusion @@ -0,0 +1,59 @@ +## +# Shows a list of changes in a workspace +# +prototype(Neos.Workspace.Ui:Component.ReviewTable) < prototype(Neos.Fusion:Component) { + + siteChanges = null + canPublishToBaseWorkspace = true + canPublishToWorkspace = true + selectedWorkspaceName = null + selectedWorkspaceLabel = '' + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + } + + renderer = afx` + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ +
+
{private.i18n.id('workspaces.workspace.title')}{private.i18n.id('workspaces.workspace.actions')}
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmDiscardAllChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmDiscardAllChanges.fusion new file mode 100644 index 00000000000..c6ee329e7ce --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmDiscardAllChanges.fusion @@ -0,0 +1,52 @@ +prototype(Neos.Workspace.Ui:Component.Modal.ConfirmDiscardAllChanges) < prototype(Neos.Fusion:Component) { + workspaceTitle = '' + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'confirm-discard-all-changes-popover' + + discardWorkspaceUri = Neos.Fusion:ActionUri { + action = 'discardWorkspace' + format = 'htmx' + arguments { + workspace = ${workspaceName} + } + } + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.discardAllChangesInWorkspaceConfirmation').arguments([props.workspaceTitle])} +
+
+
+ + +
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmDiscardSelectedChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmDiscardSelectedChanges.fusion new file mode 100644 index 00000000000..fd9551f660c --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmDiscardSelectedChanges.fusion @@ -0,0 +1,59 @@ +prototype(Neos.Workspace.Ui:Component.Modal.ConfirmDiscardSelectedChanges) < prototype(Neos.Fusion:Component) { + workspaceTitle='' + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'confirm-discard-selected-changes-popover' + + confirmDiscardSelectedChanges = Neos.Fusion:ActionUri { + action = 'publishOrDiscardNodes' + format = 'htmx' + arguments { + workspace = ${workspaceName} + action = 'discard' + } + } + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.discardSelectedChanges')} +
+
+
+ +
+ +
+
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmPublishAllChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmPublishAllChanges.fusion new file mode 100644 index 00000000000..0b3e42207dd --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmPublishAllChanges.fusion @@ -0,0 +1,52 @@ +prototype(Neos.Workspace.Ui:Component.Modal.ConfirmPublishAllChanges) < prototype(Neos.Fusion:Component) { + workspaceTitle = '' + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'confirm-publish-all-changes-popover' + + publishWorkspaceUri = Neos.Fusion:ActionUri { + action = 'publishWorkspace' + format = 'htmx' + arguments { + workspace = ${workspaceName} + } + } + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.publishAllChangesInWorkspaceConfirmation').arguments([props.workspaceTitle])} +
+
+
+ + +
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmPublishSelectedChanges.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmPublishSelectedChanges.fusion new file mode 100644 index 00000000000..52076b64d76 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Review/Modals/ConfirmPublishSelectedChanges.fusion @@ -0,0 +1,59 @@ +prototype(Neos.Workspace.Ui:Component.Modal.ConfirmPublishSelectedChanges) < prototype(Neos.Fusion:Component) { + baseWorkspaceTitle='' + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'confirm-publish-selected-changes-popover' + + publishSelectedChanges = Neos.Fusion:ActionUri { + action = 'publishOrDiscardNodes' + format = 'htmx' + arguments { + workspace = ${workspaceName} + action = 'publish' + } + } + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.publishSelectedChangesTo').arguments([props.baseWorkspaceTitle])} +
+
+
+ +
+ +
+
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Delete.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Delete.fusion new file mode 100644 index 00000000000..18600d3651c --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Delete.fusion @@ -0,0 +1,22 @@ +## +# Delete confirmation modal with empty POST response +# +Neos.Workspace.Ui.WorkspaceController.delete = Neos.Fusion:Component { + /// string + workspaceName = ${workspaceName} + /// string + workspaceTitle = ${workspaceTitle} + + renderer = Neos.Fusion:Match { + @subject = ${request.httpRequest.method} + # Render the delete modal + @default = afx` + + ` + # Empty template for the delete response as the payload is contained in the HTTP headers + POST = '' + } +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Edit.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Edit.fusion new file mode 100644 index 00000000000..805d829c88f --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Edit.fusion @@ -0,0 +1,16 @@ +Neos.Workspace.Ui.WorkspaceController.edit = Neos.Fusion:Component { + /// \Neos\Workspace\Ui\ViewModel\EditWorkspaceFormData + editWorkspaceFormData = ${editWorkspaceFormData} + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Index.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Index.fusion new file mode 100644 index 00000000000..fc1bf0db025 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/Index.fusion @@ -0,0 +1,49 @@ +Neos.Workspace.Ui.WorkspaceController.index = Neos.Fusion:Component { + /// Neos\Workspace\Ui\ViewModel\WorkspaceListItems + workspaceListItems = ${workspaceListItems} + /// array + flashMessages = ${flashMessages} + /// Neos\Workspace\Ui\ViewModel\Sorting + sorting = ${sorting} + + newAction = Neos.Fusion:UriBuilder { + action = 'new' + format = 'htmx' + } + + createNewPopoverId = 'create-workspace-popover' + + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + + renderer = afx` + +
+
+ +
+
+ + + + +
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/New.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/New.fusion new file mode 100644 index 00000000000..04854a99cdd --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Actions/New.fusion @@ -0,0 +1,10 @@ +Neos.Workspace.Ui.WorkspaceController.new = Neos.Fusion:Component { + /// array + baseWorkspaceOptions = ${baseWorkspaceOptions} + + renderer = afx` + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceCount.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceCount.fusion new file mode 100644 index 00000000000..b528640ffb9 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceCount.fusion @@ -0,0 +1,13 @@ +prototype(Neos.Workspace.Ui:Component.WorkspaceCount) < prototype(Neos.Fusion:Component) { + workspaceCount = 0 + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + } + + renderer = afx` +
+ {private.i18n.id('workspaces.numberOfWorkspaces').arguments([props.workspaceCount]).translate()} +
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTable.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTable.fusion new file mode 100644 index 00000000000..89d2838df27 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTable.fusion @@ -0,0 +1,57 @@ +## +# Renders a list of workspaces +# +prototype(Neos.Workspace.Ui:Component.WorkspaceTable) < prototype(Neos.Fusion:Component) { + /// \Neos\Workspace\Ui\ViewModel\WorkspaceListItems + workspaceListItems = null + /// Neos\Workspace\Ui\ViewModel\Sorting + sorting = null + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + workspacesUri = Neos.Fusion:ActionUri { + action = 'index' + format = 'html' + arguments { + // Todo hack convert to array manually, flows routing chokes on value objects: Tried to convert an object of type "Neos\Workspace\Ui\ViewModel\Sorting" to an identity array, but it is unknown to the Persistence Manager. + sorting = ${sorting.withInvertedSorting().jsonSerialize()} + } + } + } + + renderer = afx` + + + + + + + + + + + + + + entryWorkspace.baseWorkspaceName == 'live' + || !Array.some(props.workspaceListItems, (workspace) => entryWorkspace.baseWorkspaceName == workspace.name) + )} + /> + +
+ + {private.i18n.id('workspaces.workspace.description')}{private.i18n.id('workspaces.workspace.status')}{private.i18n.id('workspaces.workspace.changes')}{private.i18n.id('workspaces.workspace.actions')}
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTableRow.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTableRow.fusion new file mode 100644 index 00000000000..287e3402f2e --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTableRow.fusion @@ -0,0 +1,137 @@ +## +# Renders a single workspace list item +# +prototype(Neos.Workspace.Ui:Component.WorkspaceTableRow) < prototype(Neos.Fusion:Component) { + /// Neos\Workspace\Ui\ViewModel\WorkspaceListItem + workspaceListItem = ${[]} + /// integer + level = 0 + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + workspace = ${props.workspaceListItem.workspace} + workspaceTableRowId = ${'workspace-row-' + props.workspaceListItem.name} + workspaceStatus = Neos.Fusion:Case { + personal { + condition = ${props.workspaceListItem.personal} + renderer = 'personal' + } + private { + condition = ${props.workspaceListItem.private} + renderer = 'private' + } + default { + condition = true + renderer = 'shared' + } + } + workspaceStatusLabel = Neos.Fusion:Match { + @subject = ${private.workspaceStatus} + @default = ${private.i18n.id('table.column.access.shared')} + personal = ${private.i18n.id('table.column.access.personal').arguments([props.workspaceListItem.owner])} + private = ${private.i18n.id('table.column.access.private')} + } + workspaceStatusIcon = Neos.Fusion:Match { + @subject = ${private.workspaceStatus} + @default = 'user' + shared = 'users' + private = 'user-shield' + with-acl = 'user-plus' + } + reviewWorkspaceUri = Neos.Fusion:ActionUri { + action = 'review' + format = 'html' + arguments { + workspace = ${props.workspaceListItem.name} + } + } + editWorkspaceUri = Neos.Fusion:ActionUri { + action = 'edit' + format = 'htmx' + arguments { + workspaceName = ${props.workspaceListItem.name} + } + } + deleteWorkspaceUri = Neos.Fusion:ActionUri { + action = 'delete' + format = 'htmx' + arguments { + workspaceName = ${props.workspaceListItem.name} + } + } + deleteWorkspacePopoverId = 'workspace-delete-modal' + editWorkspacePopoverId = 'workspace-edit-modal' + } + + renderer = afx` + + + + + + 1}/> + {props.workspaceListItem.title} + + ({private.i18n.id('workspaces.workspace.userWorkspace')}) + + + + {props.workspaceListItem.description || '﹘'} + + {private.i18n.id('workspaces.workspace.status.' + props.workspaceListItem.status).translate()} + + + + + + + + + + 0 || props.workspaceListItem.permissions.manage == false} + attributes.hx-get={private.deleteWorkspaceUri} + attributes.hx-target='#popover-container' + attributes.hx-swap='innerHTML' + attributes.hx-on--after-request={'document.getElementById("' + private.deleteWorkspacePopoverId + '").showPopover()'} + /> + + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTreeNode.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTreeNode.fusion new file mode 100644 index 00000000000..78a5c2cccc2 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Components/WorkspaceTreeNode.fusion @@ -0,0 +1,29 @@ +## +# Renders a single workspace list item and its subworkspaces +# +prototype(Neos.Workspace.Ui:Component.WorkspaceTreeNode) < prototype(Neos.Fusion:Component) { + /// Neos\Workspace\Ui\ViewModel\WorkspaceListItem|null + workspaceListItem = null + /// Neos\Workspace\Ui\ViewModel\WorkspaceListItems + workspaceListItems = null + // array + baseWorkspaceListItems = null + /// int + level = 0 + + renderer = afx` + + + workspace.baseWorkspaceName == workspaceListItem.name)} + level={props.level + 1} + /> + + ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Create.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Create.fusion new file mode 100644 index 00000000000..6ca1a371958 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Create.fusion @@ -0,0 +1,128 @@ +prototype(Neos.Workspace.Ui:Component.Modal.Create) < prototype(Neos.Fusion:Component) { + /// array + baseWorkspaceOptions = null + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'create-workspace-popover' + } + + prototype(Neos.Fusion.Form:LabelRenderer) { + translationPackage = 'Neos.Workspace.Ui' + translationSource = 'Main' + } + + prototype(Neos.Fusion.Form:Neos.BackendModule.FieldContainer) { + translation.label { + package = 'Neos.Workspace.Ui' + source = 'Main' + } + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.createNewWorkspace')} +
+
+ +
+
+ + + + + + + + + + + + {workspaceTitle} + + + + + + + {private.i18n.id('workspaces.workspace.visibility.shared')} + +

+ {private.i18n.id('workspaces.workspace.visibility.shared.help')} +

+
+ + {private.i18n.id('workspaces.workspace.visibility.private')} + +

+ {private.i18n.id('workspaces.workspace.visibility.private.help')} +

+
+
+
+
+ + + {private.i18n.id('workspaces.createWorkspace')} + +
+
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Delete.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Delete.fusion new file mode 100644 index 00000000000..6fe25e1582e --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Delete.fusion @@ -0,0 +1,62 @@ +prototype(Neos.Workspace.Ui:Component.Modal.Delete) < prototype(Neos.Fusion:Component) { + /// string + workspaceName = null + /// string + workspaceTitle = null + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'workspace-delete-modal' + workspaceTableRowId = ${'workspace-row-' + props.workspaceName} + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.dialog.confirmWorkspaceDeletion').arguments([props.workspaceTitle])} +
+
+
+

{private.i18n.id('workspaces.dialog.thisWillDeleteTheWorkspace')}

+
+
+ + + + +
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Edit.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Edit.fusion new file mode 100644 index 00000000000..9bb88339344 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Features/Workspace/Modals/Edit.fusion @@ -0,0 +1,156 @@ +prototype(Neos.Workspace.Ui:Component.Modal.Edit) < prototype(Neos.Fusion:Component) { + /// string + workspaceName = '' + /// string + workspaceTitle = '' + /// string + workspaceDescription = '' + /// string + baseWorkspaceName = '' + /// boolean + workspaceHasChanges = false + /// array + baseWorkspaceOptions = ${[]} + /// boolean + isShared = false + + @private { + i18n = ${I18n.id('').source('Main').package('Neos.Workspace.Ui')} + popoverId = 'workspace-edit-modal' + workspaceTableRowId = ${'workspace-row-' + props.workspaceName} + } + + prototype(Neos.Fusion.Form:LabelRenderer) { + translationPackage = 'Neos.Workspace.Ui' + translationSource = 'Main' + } + + prototype(Neos.Fusion.Form:Neos.BackendModule.FieldContainer) { + translation.label { + package = 'Neos.Workspace.Ui' + source = 'Main' + } + } + + renderer = afx` +
+
+ +
+ {private.i18n.id('workspaces.editWorkspace').arguments([props.workspaceTitle])} +
+
+ +
+
+ + + + + + + + + + + + + + {workspaceTitle} + + + +
+

+ + {' '} + {private.i18n.id('workspaces.cantChangeBaseWorkspaceBecauseWorkspaceContainsChanges')} +

+
+ + + + {private.i18n.id('workspaces.workspace.visibility.shared')} + +

+ {private.i18n.id('workspaces.workspace.visibility.shared.help')} +

+
+ + {private.i18n.id('workspaces.workspace.visibility.private')} + +

+ {private.i18n.id('workspaces.workspace.visibility.private.help')} +

+
+
+
+
+ + + {private.i18n.id('applyChanges')} + +
+
+
+ ` +} diff --git a/Neos.Workspace.Ui/Resources/Private/Fusion/Root.fusion b/Neos.Workspace.Ui/Resources/Private/Fusion/Root.fusion new file mode 100644 index 00000000000..a92a174cc05 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Private/Fusion/Root.fusion @@ -0,0 +1 @@ +include: **/* diff --git a/Neos.Workspace.Ui/Resources/Private/Partials/Workspaces/ContentChangeAttributes.html b/Neos.Workspace.Ui/Resources/Private/Partials/Workspaces/ContentChangeAttributes.html deleted file mode 100644 index 0568d7d1c06..00000000000 --- a/Neos.Workspace.Ui/Resources/Private/Partials/Workspaces/ContentChangeAttributes.html +++ /dev/null @@ -1,20 +0,0 @@ -{namespace neos=Neos\Neos\ViewHelpers} - - class="neos-content-change legend-deleted" data-nodepath="{nodepath}" title="{neos:backend.translate(id: 'workspaces.legend.deleted', source: 'Modules', package: 'Neos.Neos')}" - - - class="neos-content-change legend-created" data-nodepath="{nodepath}" title="{neos:backend.translate(id: 'workspaces.legend.created', source: 'Modules', package: 'Neos.Neos')}" - - - class="neos-content-change legend-moved" data-nodepath="{nodepath}" title="{neos:backend.translate(id: 'workspaces.legend.moved', source: 'Modules', package: 'Neos.Neos')}" - - - class="neos-content-change legend-hidden" data-nodepath="{nodepath}" title="{neos:backend.translate(id: 'workspaces.legend.hidden', source: 'Modules', package: 'Neos.Neos')}" - class="neos-content-change legend-edited" data-nodepath="{nodepath}" title="{neos:backend.translate(id: 'workspaces.legend.edited', source: 'Modules', package: 'Neos.Neos')}" - - - - - - - diff --git a/Neos.Workspace.Ui/Resources/Private/Partials/Workspaces/ContentChangeDiff.html b/Neos.Workspace.Ui/Resources/Private/Partials/Workspaces/ContentChangeDiff.html deleted file mode 100644 index d29dbd43dde..00000000000 --- a/Neos.Workspace.Ui/Resources/Private/Partials/Workspaces/ContentChangeDiff.html +++ /dev/null @@ -1,95 +0,0 @@ -{namespace neos=Neos\Neos\ViewHelpers} -{namespace m=Neos\Media\ViewHelpers} -
- - - - - - - - - - - - - - - - -
- - - - {change.node.label} - - - - {change.node.lastModificationDateTime} -
{neos:backend.translate(id: contentChanges.propertyLabel)}
- - - - - - - - - - -
- - {line -> f:format.raw()} - - - - {line -> f:format.raw()} - -
-
- - - - - - -
- - - - - - - -
-
- - - - - - -
- - {contentChanges.original.resource.filename} - - - - {contentChanges.changed.resource.filename} - -
-
- - - - - - -
- - - -
-
-
-
diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Edit.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Edit.html deleted file mode 100644 index 1307f394d60..00000000000 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Edit.html +++ /dev/null @@ -1,65 +0,0 @@ -{namespace neos=Neos\Neos\ViewHelpers} - - - -

{neos:backend.translate(id: 'workspaces.editWorkspace', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: workspace.workspaceTitle.value})}

-
- - - - -
-
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- - -
- -
- -
-
-
- -
- -
- -
-
- -
-
-
- - -
-
diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html deleted file mode 100644 index b76c5a2a837..00000000000 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Index.html +++ /dev/null @@ -1,161 +0,0 @@ -{namespace neos=Neos\Neos\ViewHelpers} - - - -
- - -
- -
- -
-
-
-
- - - - - - - - - - - - - - - - - - - - - - -
 {neos:backend.translate(id: 'workspaces.workspace.title', source: 'Main', package: 'Neos.Workspace.Ui')}{neos:backend.translate(id: 'workspaces.workspace.baseWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}{neos:backend.translate(id: 'workspaces.workspace.owner', source: 'Main', package: 'Neos.Workspace.Ui')}{neos:backend.translate(id: 'workspaces.workspace.changes', source: 'Main', package: 'Neos.Workspace.Ui')} 
- - - - - - - - - - - - - - - - - - {workspace.title -> f:format.crop(maxCharacters: 25, append: '…')} - - - {workspace.baseWorkspaceName -> f:format.crop(maxCharacters: 25, append: '…')} - - - - - - - - - - TODO - - - TODO - - - - - -
- - - - - - - - -
-
- - - - {neos:backend.translate(id: 'workspaces.review', source: 'Main', package: 'Neos.Workspace.Ui')} - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- -
{neos:backend.translate(id: 'workspaces.dialog.confirmWorkspaceDeletion', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: workspace.workspaceTitle.value})}
-
-
-

{neos:backend.translate(id: 'workspaces.dialog.thisWillDeleteTheWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}

-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html deleted file mode 100644 index 65ad3753f05..00000000000 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/New.html +++ /dev/null @@ -1,41 +0,0 @@ -{namespace neos=Neos\Neos\ViewHelpers} - - - -

{neos:backend.translate(id: 'workspaces.createNewWorkspace', source: 'Main', package: 'Neos.Workspace.Ui')}

-
- - - -
-
- -
- - -
-
- -
- -
- - -
-
- -
- -
- -
-
-
- - -
-
diff --git a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Show.html b/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Show.html deleted file mode 100644 index c1be4adb2a0..00000000000 --- a/Neos.Workspace.Ui/Resources/Private/Templates/Workspace/Show.html +++ /dev/null @@ -1,247 +0,0 @@ -{namespace neos=Neos\Neos\ViewHelpers} - - - - - - - - - - - - {neos:backend.translate(id: 'workspaces.unpublishedChanges', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: selectedWorkspaceLabel.value})} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - {neos:backend.translate(id: 'workspaces.selectAllCurrentChanges', source: 'Main', package: 'Neos.Workspace.Ui')}
- - - - - - - -
-
- {neos:backend.translate(id: 'pathCaption', source: 'Main', package: 'Neos.Workspace.Ui')}: - -
- -
- - - - - -
-
-
- -
- - - - - - - -
- -
-
- - - -
-
- -
-
- -
- -
-
-
-
- -
{neos:backend.translate(id: 'workspaces.discardAllChangesInWorkspaceConfirmation', arguments: {0: selectedWorkspaceLabel.value}, source: 'Main', package: 'Neos.Workspace.Ui')}
-
- -
-
-
-
- -
-
-
-
- -
{neos:backend.translate(id: 'workspaces.discardSelectedChangesInWorkspaceConfirmation', arguments: {0: selectedWorkspaceLabel.value}, source: 'Main', package: 'Neos.Workspace.Ui')}
-
- -
-
-
-
- - - - - -
- - {neos:backend.translate(id: 'workspaces.unpublishedChanges', source: 'Main', package: 'Neos.Workspace.Ui', arguments: {0: selectedWorkspaceLabel.value})} -

{neos:backend.translate(id: 'workspaces.thereAreNoUnpublishedChanges', source: 'Main', package: 'Neos.Workspace.Ui')}

- -
-
-
- - - - diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf index 69c51c14e80..0b75a74ab66 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/ar/Main.xlf @@ -59,8 +59,8 @@ Private workspace مجال العمل الخاص - - Internal workspace + + Shared workspace فضاء العمل الداخلي @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace فضاء العمل هذا لا يمكن الوصول إليه وتعديله إلا من طرف المراجعين والمسؤولين فقط - - Internal + + Shared داخلي - + Any logged in editor can see and modify this workspace. يمكن لأي محرر تسجيل الدخول رؤية وتعديل فضاء العمل هذا. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf index 4f0d177bfb2..8e473662d88 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/cs/Main.xlf @@ -59,9 +59,9 @@ Private workspace Private workspace - - Internal workspace - Internal workspace + + Shared workspace + Shared workspace Read-only workspace @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - - Internal - Internal + + Shared + Shared - + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf index fcd1ef9604e..fa0c330dafc 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/da/Main.xlf @@ -59,8 +59,8 @@ Private workspace Privat arbejdsrum - - Internal workspace + + Shared workspace Internt arbejdsrum @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Kun korrekturlæsere og administratorer kan få adgang til og ændre dette arbejdsrum - - Internal + + Shared Internt - + Any logged in editor can see and modify this workspace. Enhver redaktør som er logget ind kan se og ændre dette arbejdsrum. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf index 2b56aa46d05..cb17c88104a 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/de/Main.xlf @@ -59,8 +59,8 @@ Private workspace Privater Arbeitsbereich - - Internal workspace + + Shared workspace Interner Arbeitsbereich @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Nur Reviewer und Administratoren können auf diesen Arbeitsbereich zugreifen und ihn bearbeiten - - Internal + + Shared Intern - + Any logged in editor can see and modify this workspace. Jeder angemeldete Redakteur kann diesen Arbeitsbereich sehen und bearbeiten. @@ -269,6 +269,18 @@ Select all current changes Alle aktuellen Änderungen auswählen + + This is a shared workspace + Dies ist ein gemeinsamer Arbeitsbereich + + + This is a private workspace + Dies ist ein privater Arbeitsbereich + + + This is the personal workspace of "{0}" + Dies ist der persönliche Arbeitsbereich von "{0}" + diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf index b52d42417cb..c62edb246b9 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/el/Main.xlf @@ -59,9 +59,9 @@ Private workspace Private workspace - - Internal workspace - Internal workspace + + Shared workspace + Shared workspace Read-only workspace @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - - Internal + + Shared Εσωτερικό - + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf index bd8b046baef..0af84808b38 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/en/Main.xlf @@ -8,6 +8,9 @@ Cancel + + Apply changes + Path @@ -18,8 +21,14 @@ Workspaces - - Details for "{0}" + + Review workspace "{0}" + + + Review + + + Workspace "{0}" has no unpublished changes Create new workspace @@ -28,7 +37,13 @@ Create workspace - Delete workspace + Delete workspace "{0}" + + + {0} workspaces + + + Workspace "{0}" has unpublished changes Yes, delete the workspace @@ -42,8 +57,8 @@ Private workspace - - Internal workspace + + Shared workspace Read-only workspace @@ -57,9 +72,51 @@ Base workspace + + This is your workspace + Owner + + Last modified + + + Managed by Users + + + Managed by Groups + + + Permissions + + + Workspace "{0}" does not exist + + + + {0} node was added + + + {0} nodes were added + + + + + {0} node was changed + + + {0} nodes were changed + + + + + {0} node was removed + + + {0} nodes were removed + + Visibility @@ -69,15 +126,27 @@ Only reviewers and administrators can access and modify this workspace - - Internal + + Shared - + Any logged in editor can see and modify this workspace. + + Status + + + Up to date + + + Outdated + Changes + + Actions + Open page in "{0}" workspace @@ -129,6 +198,12 @@ Publish all changes to {0} + + Publish all changes in this document + + + Discard all changes in this document + Changed Content @@ -156,11 +231,14 @@ Do you really want to discard all changes in the "{0}" workspace? + + Do you really want to publish all changes in the "{0}" workspace? + A workspace with this title already exists. - - The workspace "{0}" has been created. + + Workspace could not be created. The workspace "{0}" has been updated. @@ -171,6 +249,9 @@ Your personal workspace contains changes, please publish or discard them first. + + You cannot change the base workspace of workspace with unpublished changes. + Did not delete workspace "{0}" because it currently contains {1} node. @@ -203,6 +284,30 @@ Select all current changes + + The workspace "{0}" has been created. + + + New element tags + + + You do not have permission to publish to the base workspace + + + You do not have permission to publish to the this workspace + + + You do not have permission to see this workspace + + + This is a shared workspace + + + This is a private workspace + + + This is the personal workspace of "{0}" + diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf index 1447062369f..0700abae071 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/es/Main.xlf @@ -59,8 +59,8 @@ Private workspace Espacio de trabajo privado - - Internal workspace + + Shared workspace Espacio de trabajo interno @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Solo los revisores y administradores pueden acceder y modificar este espacio de trabajo - - Internal + + Shared Interno - + Any logged in editor can see and modify this workspace. Cualquiera que inicie sesión en el editor puede ver y modificar este espacio de trabajo. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf index 4b686ba606f..a9f77b58bf3 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/fi/Main.xlf @@ -59,8 +59,8 @@ Private workspace Yksityinen työtila - - Internal workspace + + Shared workspace Sisäinen työtila @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Ainoastaan arvioijat ja pääkäyttäjät voivat käyttää ja muokata tätä työtilaa - - Internal + + Shared Sisäinen - + Any logged in editor can see and modify this workspace. Kaikki sisäänkirjautuneet julkaisijat näkevät tämän työtilan ja voivat muokata sitä. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf index 9bae769a3ab..d3aa9c7c9cc 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/fr/Main.xlf @@ -59,8 +59,8 @@ Private workspace Espace de travail privé - - Internal workspace + + Shared workspace Espace de travail interne @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Seulement les relecteurs et les administrateurs peuvent accéder et modifier cet espace de travail - - Internal + + Shared Interne - + Any logged in editor can see and modify this workspace. N'importe quel éditeur connecté peut voir et modifier cet espace de travail. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf index e77621b6658..a8ef0c3cbdd 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/hu/Main.xlf @@ -59,8 +59,8 @@ Private workspace Privát munkafelület - - Internal workspace + + Shared workspace Belső munkafelület @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Csak moderátorok és adminisztrátoroknak van engedélye a tartalom szerkesztéséhez ezen a munkafelületen - - Internal + + Shared Belső - + Any logged in editor can see and modify this workspace. Minden bejelentkezett szerkesztő láthatja és módosíthatja a munkafelületet. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf index e2208d1b025..30e28a4f233 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/id_ID/Main.xlf @@ -59,9 +59,9 @@ Private workspace Bidang kerja Privat - - Internal workspace - Bidang kerja internal + + Shared workspace + Bidang kerja shared Read-only workspace @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Hanya pengulas dan administrator yang dapat mengakses dan memodifikasi bidang kerja ini - - Internal - Internal + + Shared + Shared - + Any logged in editor can see and modify this workspace. Editor login yang dapat melihat dan mengubah bidang kerja ini. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf index e263dbdbd25..df86b5f092c 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/it/Main.xlf @@ -59,8 +59,8 @@ Private workspace Spazio di lavoro privato - - Internal workspace + + Shared workspace Spazio di lavoro interno @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Solo i revisori e gli amministratori possono accedere e modificare questo spazio di lavoro - - Internal + + Shared Interno - + Any logged in editor can see and modify this workspace. Ogni editore loggato può vedere e modificare questo spazio di lavoro. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf index 771aed88384..963d9240b32 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/ja/Main.xlf @@ -59,8 +59,8 @@ Private workspace 民間のワークスペース - - Internal workspace + + Shared workspace 内部のワークスペース @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace のみ応募には管理者アクセスし、修正するこのワークスペース - - Internal + + Shared 内部 - + Any logged in editor can see and modify this workspace. 他のログインエディタで見をいつでも変更することが含まれます。 diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf index ae3342c7b9a..3ab0f2680d6 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/km/Main.xlf @@ -59,8 +59,8 @@ Private workspace តំបន់ការងារឯកជន - - Internal workspace + + Shared workspace តំបន់ការងារផ្ទៃក្នុង @@ -96,11 +96,11 @@ Only reviewers and administrators can access and modify this workspace មាន​តែអ្នក​ត្រួត​ពិនិត្យនិងអ្នកគ្រប់គ្រងអាចចូលដំណើរការនិងកែប្រែតំបន់ការងារនេះ - - Internal + + Shared ផ្ទៃក្នុង - + Any logged in editor can see and modify this workspace. រាល់ការចូលទេនៅក្នុងកម្មវិធីនិពន្ធអាចមើលឃើញនិងកែប្រែតំបន់ការងារនេះ diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf index 650d7922d09..c5e733d8343 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/lv/Main.xlf @@ -59,8 +59,8 @@ Private workspace Privāta darba virsma - - Internal workspace + + Shared workspace Iekšējā darba virsma @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Tikai recenzenti un administratori var piekļūt un modificēt šo darba virsmu - - Internal + + Shared Iekšēji - + Any logged in editor can see and modify this workspace. Autorizēts redaktors var redzēt un rediģēt šo darba virsmu. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf index 4417d0fff6a..57b1c1bf665 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/nl/Main.xlf @@ -51,8 +51,8 @@ Private workspace Privé workspace - - Internal workspace + + Shared workspace Interne workspace @@ -87,11 +87,11 @@ Only reviewers and administrators can access and modify this workspace Alleen reviewers en administrators hebben toegang en kunnen wijzigingen aanbrengen in deze workspace - - Internal + + Shared Intern - + Any logged in editor can see and modify this workspace. Iedere ingelogde redacteur kan deze workspace bekijken en wijzigen. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf index 2190e1ba404..d3d127af595 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/no/Main.xlf @@ -59,8 +59,8 @@ Private workspace Privat arbeidsområde - - Internal workspace + + Shared workspace Internt arbeidsområde @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Bare korrekturlesere og administratorer kan få tilgang til og endre dette arbeidsområdet - - Internal + + Shared Intern - + Any logged in editor can see and modify this workspace. Alle påloggede redaktører kan se og endre dette arbeidsområdet. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf index 02c192ab401..57d358f0d87 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/pl/Main.xlf @@ -59,8 +59,8 @@ Private workspace Prywatny obszar roboczy - - Internal workspace + + Shared workspace Wewnętrzny obszar roboczy @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Tylko recenzenci i administratorzy mogą otwierać i modyfikować ten obszar roboczy - - Internal + + Shared Wewnętrzny - + Any logged in editor can see and modify this workspace. Każdy zalogowany redaktor może zobaczyć i modyfikować ten obszar roboczy. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf index e5546413fc7..1a11ef1017f 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/pt/Main.xlf @@ -59,8 +59,8 @@ Private workspace Área de trabalho privada - - Internal workspace + + Shared workspace Área de trabalho interna @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Apenas os revisores e os administradores podem aceder e modificar esta área de trabalho - - Internal + + Shared Interno - + Any logged in editor can see and modify this workspace. Qualquer editor pode ver e modificar esta área de trabalho. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf index 657ecb3c219..d69b93eaf8a 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/pt_BR/Main.xlf @@ -59,8 +59,8 @@ Private workspace Espaço de trabalho privado - - Internal workspace + + Shared workspace Espaço de trabalho interno @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Apenas os revisores e os administradores podem acessar e modificar este espaço de trabalho - - Internal + + Shared Interno - + Any logged in editor can see and modify this workspace. Qualquer editor logado pode ver e modificar este espaço de trabalho. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf index 6d3ff3831e7..f3478465495 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/ru/Main.xlf @@ -59,8 +59,8 @@ Private workspace Приватная рабочая область - - Internal workspace + + Shared workspace Внутренняя рабочая область @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Только рецензенты и администраторы имеют доступ к этой рабочей области и могут изменять её - - Internal + + Shared Внутренняя - + Any logged in editor can see and modify this workspace. Любой вошедший в систему редактор может просматривать и редактировать эту рабочую область. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf index 24f4182c0ac..b59c208d8aa 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/sr/Main.xlf @@ -59,9 +59,9 @@ Private workspace Private workspace - - Internal workspace - Internal workspace + + Shared workspace + Shared workspace Read-only workspace @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - - Internal - Internal + + Shared + Shared - + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf index e268756ca14..d437d0e1c22 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/sv/Main.xlf @@ -59,8 +59,8 @@ Private workspace Privat arbetsyta - - Internal workspace + + Shared workspace Intern arbetsyta @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Endast förhandsgranskare och administratörer kan komma åt och ändra den här arbetsytan - - Internal + + Shared Internt - + Any logged in editor can see and modify this workspace. En inloggade redigerare kan se och ändra den här arbetsytan. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf index 689160914ac..59ea912d751 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/tl_PH/Main.xlf @@ -59,9 +59,9 @@ Private workspace Private workspace - - Internal workspace - Internal workspace + + Shared workspace + Shared workspace Read-only workspace @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Only reviewers and administrators can access and modify this workspace - - Internal + + Shared Panloob - + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf index 027eabc46df..3a900426176 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/tr/Main.xlf @@ -59,8 +59,8 @@ Private workspace Özel çalışma alanı - - Internal workspace + + Shared workspace İç çalışma alanı @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Bu çalışma alanına yalnızca inceleyiciler ve yöneticiler erişebilir ve değiştirebilir - - Internal + + Shared Dahili - + Any logged in editor can see and modify this workspace. Oturum açmış herhangi bir düzenleyici bu çalışma alanını görebilir ve değiştirebilir. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf index 55fc034cc0b..a54fd268511 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/uk/Main.xlf @@ -59,8 +59,8 @@ Private workspace Приватне робоче середовище - - Internal workspace + + Shared workspace Внутрішнє робоче середовище @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Робоча область тільки для рецензентів і адміністраторів - - Internal + + Shared Внутрішній - + Any logged in editor can see and modify this workspace. Будь-хто зареєстрований в редакторі може бачити та змінювати робоче середовище. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf index f2671f863a6..24f06209f02 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/vi/Main.xlf @@ -59,9 +59,9 @@ Private workspace Không gian làm việc riêng - - Internal workspace - Internal workspace + + Shared workspace + Shared workspace Read-only workspace @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace Chỉ có người đánh giá và quản trị viên có thể truy cập và chỉnh sửa không gian làm việc này - - Internal + + Shared Nội bộ - + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf index 8f73159f416..1cd31a4e05f 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/zh/Main.xlf @@ -59,8 +59,8 @@ Private workspace 私有工作区 - - Internal workspace + + Shared workspace 内部工作区 @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace 只有审阅者和管理员可以访问和修改此工作区 - - Internal - Internal + + Shared + Shared - + Any logged in editor can see and modify this workspace. Any logged in editor can see and modify this workspace. diff --git a/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf b/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf index df4624c22c2..53238365792 100644 --- a/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf +++ b/Neos.Workspace.Ui/Resources/Private/Translations/zh_TW/Main.xlf @@ -59,8 +59,8 @@ Private workspace 私人工作區 - - Internal workspace + + Shared workspace 內部工作區 @@ -95,11 +95,11 @@ Only reviewers and administrators can access and modify this workspace 只有審查者和管理員可以連結和修改此工作區 - - Internal + + Shared 內部的 - + Any logged in editor can see and modify this workspace. 任何登入的編輯皆可檢視和修改此工作區。 diff --git a/Neos.Workspace.Ui/Resources/Public/Scripts/Module.js b/Neos.Workspace.Ui/Resources/Public/Scripts/Module.js new file mode 100644 index 00000000000..c7723c51846 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Public/Scripts/Module.js @@ -0,0 +1,58 @@ +/** + * @typedef {Object} Notification + * @property {string} severity + * @property {string} title + * @property {string} message + * @property {number} code + */ + +/** + * @typedef {Object} EventDetails + * @property {XMLHttpRequest} xhr + */ + +/** + * @typedef {Object} HtmxEvent + * @property {EventDetails} detail + */ + +document.addEventListener('DOMContentLoaded', () => { + + if (!window.htmx) { + console.error('htmx is not loaded'); + return; + } + + /** + * Show flash messages after successful requests + */ + htmx.on('htmx:afterRequest', /** @param {HtmxEvent} e */(e) => { + const flashMessagesJson = e.detail.xhr.getResponseHeader('X-Flow-FlashMessages'); + if (!flashMessagesJson) { + return; + } + + /** @type Notification[] */ + const flashMessages = JSON.parse(flashMessagesJson); + flashMessages.forEach(({severity, title, message}) => { + if (title) { + NeosCMS.Notification[severity.toLowerCase()](title, message); + } else { + NeosCMS.Notification[severity.toLowerCase()](message); + } + }); + }); + + /** + * Show error notifications for failed requests if no flash messages are present + */ + htmx.on('htmx:responseError', /** @param {HtmxEvent} e */(e) => { + const flashMessagesJson = e.detail.xhr.getResponseHeader('X-Flow-FlashMessages'); + if (flashMessagesJson) { + return; + } + + const {status, statusText} = e.detail.xhr; + NeosCMS.Notification.error(`Error ${status}: ${statusText}`); + }); +}); diff --git a/Neos.Workspace.Ui/Resources/Public/Scripts/Review.js b/Neos.Workspace.Ui/Resources/Public/Scripts/Review.js new file mode 100644 index 00000000000..c584d8dfaa1 --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Public/Scripts/Review.js @@ -0,0 +1,109 @@ + +window.addEventListener('DOMContentLoaded', (event) => { + document.body.addEventListener('htmx:afterOnLoad', function (evt) { + initReviewFunctions(); + }); +}); +window.addEventListener('DOMContentLoaded', (event) => { + initReviewFunctions(); +}); +function initReviewFunctions(){ + const input = document.getElementById("check-all"); + + // Attach event listener after input is loaded + if (input) { + input.addEventListener( + 'change', function (event) { + for (const checkbox of document.querySelectorAll('tbody input[type="checkbox"]')) { + checkbox.checked = input.checked; + } + if(document.querySelectorAll('tbody input[type="checkbox"]:checked').length === document.querySelectorAll('tbody input[type="checkbox"]').length + || document.querySelectorAll('tbody input[type="checkbox"]:checked').length === 0){ + document.getElementById('batch-actions').classList.add('neos-hidden'); + document.getElementById('all-actions').classList.remove('neos-hidden'); + } else { + document.getElementById('batch-actions').classList.remove('neos-hidden'); + document.getElementById('all-actions').classList.add('neos-hidden'); + } + } + ) + for (const checkbox of document.querySelectorAll('tbody input[type="checkbox"]')) { + checkbox.addEventListener( 'change', function(){ + if(!checkbox.checked){ + input.checked = false; + } + if(document.querySelectorAll('tbody input[type="checkbox"]:checked').length === document.querySelectorAll('tbody input[type="checkbox"]').length + || document.querySelectorAll('tbody input[type="checkbox"]:checked').length === 0){ + document.getElementById('batch-actions').classList.add('neos-hidden'); + document.getElementById('all-actions').classList.remove('neos-hidden'); + } else { + document.getElementById('batch-actions').classList.remove('neos-hidden'); + document.getElementById('all-actions').classList.add('neos-hidden'); + } + const neosDocument = checkbox.closest('.neos-document'); + + if(neosDocument.hasAttribute('data-isNew') || neosDocument.hasAttribute('data-isMoved')){ + if(checkbox.checked){ + neosDocument.dataset.documentpath.split('/').forEach(function (parentDocumentId) { + const parentElement = checkbox.closest('table').querySelector('.neos-document[data-isMoved][data-documentpath$="'+parentDocumentId+'"], .neos-document[data-isNew][data-documentpath$="'+parentDocumentId+'"]'); + if (parentElement !== null) { + parentElement.querySelector('input').checked = checkbox.checked; + } + }) + } else { + for (const childElement of document.querySelectorAll('.neos-document[data-documentpath^="'+neosDocument.dataset.documentpath+'"]')) { + childElement.querySelector('input').checked = checkbox.checked + } + } + } + }); + + } + for (const toggleDocument of document.querySelectorAll('.toggle-document')) { + toggleDocument.addEventListener( 'click', function(){ + + toggleDocument.children[0].classList.toggle('fa-chevron-down'); + toggleDocument.children[0].classList.toggle('fa-chevron-up'); + + let nextElement = toggleDocument.closest('.neos-document').nextElementSibling; + do{ + nextElement.classList.toggle('neos-hidden') + nextElement = nextElement.nextElementSibling; + } + while (nextElement && !nextElement.classList.contains('neos-document')) + }); + + } + document.getElementById('collapse-all').addEventListener( + 'click', function (event) { + const collapseButton = document.getElementById('collapse-all'); + let status = (collapseButton.dataset.toggled === 'true'); + if(status){ + for (const toggle of document.querySelectorAll('.toggle-document')) { + toggle.children[0].classList.remove('fa-chevron-down'); + toggle.children[0].classList.add('fa-chevron-up'); + } + for (const change of document.querySelectorAll('.neos-change')) { + change.classList.add('neos-hidden'); + } + + } else { + for (const toggle of document.querySelectorAll('.toggle-document')) { + toggle.children[0].classList.add('fa-chevron-down'); + toggle.children[0].classList.remove('fa-chevron-up'); + } + for (const change of document.querySelectorAll('.neos-change')) { + change.classList.remove('neos-hidden'); + } + } + + collapseButton.childNodes[0].classList.toggle('fa-up-right-and-down-left-from-center'); + collapseButton.childNodes[0].classList.toggle('fa-down-left-and-up-right-to-center') + collapseButton.dataset.toggled = !status; + + } + ) + + + } +} diff --git a/Neos.Workspace.Ui/Resources/Public/Scripts/htmx.min.js b/Neos.Workspace.Ui/Resources/Public/Scripts/htmx.min.js new file mode 100644 index 00000000000..2d49c568a8b --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Public/Scripts/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.0"};Q.onLoad=$;Q.process=Dt;Q.on=be;Q.off=we;Q.trigger=he;Q.ajax=Hn;Q.find=r;Q.findAll=p;Q.closest=g;Q.remove=K;Q.addClass=W;Q.removeClass=o;Q.toggleClass=Y;Q.takeClass=ge;Q.swap=ze;Q.defineExtension=Un;Q.removeExtension=Bn;Q.logAll=z;Q.logNone=J;Q.parseInterval=d;Q._=_;const n={addTriggerHandler:Et,bodyContains:le,canAccessLocalStorage:j,findThisElement:Ee,filterValues:dn,swap:ze,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:T,getExpressionVars:Cn,getHeaders:hn,getInputValues:cn,getInternalData:ie,getSwapSpecification:pn,getTriggerSpecs:lt,getTarget:Ce,makeFragment:D,mergeObjects:ue,makeSettleInfo:xn,oobSwap:Te,querySelectorExt:fe,settleImmediately:Gt,shouldCancel:dt,triggerEvent:he,triggerErrorEvent:ae,withExtensions:Ut};const v=["get","post","put","delete","patch"];const R=v.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");const O=e("head");function e(e,t=false){return new RegExp(`<${e}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${e}>`,t?"gim":"im")}function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function H(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function T(e,t){while(e&&!t(e)){e=u(e)}return e||null}function q(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;T(t,function(e){return!!(r=q(t,ce(e),n))});if(r!=="unset"){return r}}function a(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function L(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function N(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function A(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function I(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function P(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(P(e)){const t=I(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){w(e)}finally{e.remove()}}})}function D(e){const t=e.replace(O,"");const n=L(t);let r;if(n==="html"){r=new DocumentFragment;const i=N(e);A(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=N(t);A(r,i.body);r.title=i.title}else{const i=N('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){k(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function M(e){return typeof e==="function"}function X(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function le(e){const t=e.getRootNode&&e.getRootNode();if(t&&t instanceof window.ShadowRoot){return ne().body.contains(t.host)}else{return ne().body.contains(e)}}function B(e){return e.trim().split(/\s+/)}function ue(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){w(e);return null}}function j(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function V(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function _(e){return vn(ne().body,function(){return eval(e)})}function $(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function z(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function J(){Q.logger=null}function r(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return r(ne(),e)}}function p(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return p(ne(),e)}}function E(){return window}function K(e,t){e=y(e);if(t){E().setTimeout(function(){K(e);e=null},t)}else{u(e).removeChild(e)}}function ce(e){return e instanceof Element?e:null}function G(e){return e instanceof HTMLElement?e:null}function Z(e){return typeof e==="string"?e:null}function h(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function W(e,t,n){e=ce(y(e));if(!e){return}if(n){E().setTimeout(function(){W(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function o(e,t,n){let r=ce(y(e));if(!r){return}if(n){E().setTimeout(function(){o(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function Y(e,t){e=y(e);e.classList.toggle(t)}function ge(e,t){e=y(e);se(e.parentElement.children,function(e){o(e,t)});W(ce(e),t)}function g(e,t){e=ce(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||a(e,t)){return e}}while(e=e&&ce(u(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function pe(e,t){return e.substring(e.length-t.length)===t}function i(e){const t=e.trim();if(l(t,"<")&&pe(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(e,t,n){e=y(e);if(t.indexOf("closest ")===0){return[g(ce(e),i(t.substr(8)))]}else if(t.indexOf("find ")===0){return[r(h(e),i(t.substr(5)))]}else if(t==="next"){return[ce(e).nextElementSibling]}else if(t.indexOf("next ")===0){return[me(e,i(t.substr(5)),!!n)]}else if(t==="previous"){return[ce(e).previousElementSibling]}else if(t.indexOf("previous ")===0){return[ye(e,i(t.substr(9)),!!n)]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else if(t==="root"){return[H(e,!!n)]}else if(t.indexOf("global ")===0){return m(e,t.slice(7),true)}else{return F(h(H(e,!!n)).querySelectorAll(i(t)))}}var me=function(t,e,n){const r=h(H(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function fe(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return r(h(t)||document,e)}else{return e}}function xe(e,t,n){if(M(t)){return{target:ne().body,event:Z(e),listener:t}}else{return{target:y(e),event:Z(t),listener:n}}}function be(t,n,r){_n(function(){const e=xe(t,n,r);e.target.addEventListener(e.event,e.listener)});const e=M(n);return e?n:r}function we(t,n,r){_n(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return M(n)?n:r}const ve=ne().createElement("output");function Se(e,t){const n=re(e,t);if(n){if(n==="this"){return[Ee(e,t)]}else{const r=m(e,n);if(r.length===0){w('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Ee(e,t){return ce(T(e,function(e){return te(ce(e),t)!=null}))}function Ce(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Ee(e,"hx-target")}else{return fe(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Re(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{s=e}const n=ne().querySelectorAll(t);if(n){se(n,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!He(s,e)){t=h(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){_e(s,e,e,t,i)}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ae(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function qe(e){se(p(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){e.parentNode.replaceChild(n,e)}})}function Le(l,e,u){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=h(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);u.tasks.push(function(){Oe(t,s)})}}})}function Ne(e){return function(){o(e,Q.config.addedClass);Dt(ce(e));Ae(h(e));he(e,"htmx:load")}}function Ae(e){const t="[autofocus]";const n=G(a(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;W(ce(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ne(o))}}}function Ie(e,t){let n=0;while(n0){E().setTimeout(l,r.settleDelay)}else{l()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(!X(e)){e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ae(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function b(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=b(e,et).trim();e.shift()}else{t=b(e,x)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{b(o,Ye);const l=o.length;const u=b(o,/[,\[\s]/);if(u!==""){if(u==="every"){const c={trigger:"every"};b(o,Ye);c.pollInterval=d(b(o,/[,\[\s]/));b(o,Ye);var i=rt(e,o,"event");if(i){c.eventFilter=i}r.push(c)}else{const f={trigger:u};var i=rt(e,o,"event");if(i){f.eventFilter=i}while(o.length>0&&o[0]!==","){b(o,Ye);const a=o.shift();if(a==="changed"){f.changed=true}else if(a==="once"){f.once=true}else if(a==="consume"){f.consume=true}else if(a==="delay"&&o[0]===":"){o.shift();f.delay=d(b(o,x))}else if(a==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=b(o,x);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}f.from=s}else if(a==="target"&&o[0]===":"){o.shift();f.target=ot(o)}else if(a==="throttle"&&o[0]===":"){o.shift();f.throttle=d(b(o,x))}else if(a==="queue"&&o[0]===":"){o.shift();f.queue=b(o,x)}else if(a==="root"&&o[0]===":"){o.shift();f[a]=ot(o)}else if(a==="threshold"&&o[0]===":"){o.shift();f[a]=b(o,x)}else{ae(e,"htmx:syntax:error",{token:o.shift()})}}r.push(f)}}if(o.length===l){ae(e,"htmx:syntax:error",{token:o.shift()})}b(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(a(e,"form")){return[{trigger:"submit"}]}else if(a(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(a(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ut(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!pt(n,e,Xt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ft(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&ft(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";if(r==="get"){}o=ee(t,"action")}e.forEach(function(e){mt(t,function(e,t){const n=ce(e);if(at(n)){f(n);return}de(r,o,n,t)},n,e,true)})}}function dt(e,t){const n=ce(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(a(n,'input[type="submit"], button')&&g(n,"form")!==null){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function gt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function pt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ae(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(s,l,e,u,c){const f=ie(s);let t;if(u.from){t=m(s,u.from)}else{t=[s]}if(u.changed){t.forEach(function(e){const t=ie(e);t.lastValue=e.value})}se(t,function(o){const i=function(e){if(!le(s)){o.removeEventListener(u.trigger,i);return}if(gt(s,e)){return}if(c||dt(e,s)){e.preventDefault()}if(pt(u,s,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(s)<0){t.handledFor.push(s);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!a(ce(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=ie(o);const r=o.value;if(n.lastValue===r){return}n.lastValue=r}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){l(s,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){l(s,e)},u.delay)}else{he(s,"htmx:trigger");l(s,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:i,on:o});o.addEventListener(u.trigger,i)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);setInterval(function(){if(yt){yt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){wt(e)})}},200)}}function wt(e){if(!s(e,"data-hx-revealed")&&U(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;se(v,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=ce(e);if(g(n,Q.config.disableSelector)){f(n);return}de(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);wt(ce(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=fe(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ce(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=ce(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=g(ce(e.target),"button, input[type='submit']");const n=Nt(e);if(n){n.lastButtonClicked=t}}function Lt(e){const t=Nt(e);if(t){t.lastButtonClicked=null}}function Nt(e){const t=g(ce(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",Lt)}function It(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ae(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(t){if(!j()){return null}t=V(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=D(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=jt();const r=xn(n);Dn(e.title);Ve(n,t,r);Gt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{ae(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=_t(e);if(t){const n=D(t.content);const r=jt();const o=xn(r);Dn(n.title);Ve(r,n,o);Gt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Yt(e){let t=Se(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Qt(e){let t=Se(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function en(e,t){se(e,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function tn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function sn(t,n,r,o,i){if(o==null||tn(t,o)){return}else{t.push(o)}if(nn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=F(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=F(o.files)}rn(s,e,n);if(i){ln(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){on(e.name,e.value,n)}else{t.push(e)}if(i){ln(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}rn(t,e,n)})}}function ln(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function un(t,e){for(const n of e.keys()){t.delete(n);e.getAll(n).forEach(function(e){t.append(n,e)})}return t}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){sn(n,o,i,g(e,"form"),l)}sn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const c=s.lastButtonClicked||e;const f=ee(c,"name");rn(f,c.value,o)}const u=Se(e,"hx-include");se(u,function(e){sn(n,r,i,ce(e),l);if(!a(e,"form")){se(h(e).querySelectorAll(it),function(e){sn(n,r,i,e,l)})}});un(r,o);return{errors:i,formData:r,values:An(r)}}function fn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=Ln(e);let n="";e.forEach(function(e,t){n=fn(n,t,e)});return n}function hn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};wn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function dn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.substr(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function gn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function pn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!gn(e)){r.show="top"}if(n){const s=B(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=c;r.scrollTarget=i}else if(l.indexOf("show:")===0){const f=l.substr(5);var o=f.split(":");const a=o.pop();var i=o.length>0?o.join(":"):null;r.show=a;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.substr("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{w("Unknown modifier in hx-swap: "+l)}}}}return r}function mn(e){return re(e,"hx-encoding")==="multipart/form-data"||a(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function yn(t,n,r){let o=null;Ut(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(mn(n)){return un(new FormData,Ln(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function bn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ce(fe(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ce(fe(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function wn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.substr(11);t=true}else if(e.indexOf("js:")===0){e=e.substr(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return wn(ce(u(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{ae(e,"htmx:evalDisallowedError");return n}}function Sn(e,t){return wn(e,"hx-vars",true,t)}function En(e,t){return wn(e,"hx-vals",false,t)}function Cn(e){return ue(Sn(e),En(e))}function Rn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ae(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function C(e,t){return t.test(e.getAllResponseHeaders())}function Hn(e,t,n){e=e.toLowerCase();if(n){if(n instanceof Element||typeof n==="string"){return de(e,t,null,null,{targetOverride:y(n),returnPromise:true})}else{return de(e,t,y(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:y(n.target),swapOverride:n.swap,select:n.select,returnPromise:true})}}else{return de(e,t,null,null,{returnPromise:true})}}function Tn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function qn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ue({url:o,sameHost:r},n))}function Ln(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Nn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(r){return new Proxy(r,{get:function(e,t){if(typeof t==="symbol"){return Reflect.get(e,t)}if(t==="toJSON"){return()=>Object.fromEntries(r)}if(t in e){if(typeof e[t]==="function"){return function(){return r[t].apply(r,arguments)}}else{return e[t]}}const n=r.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Nn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Mn;const X=i.select||null;if(!le(r)){oe(s);return e}const u=i.targetOverride||ce(Ce(r));if(u==null||u==ve){ae(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let c=ie(r);const f=c.lastButtonClicked;if(f){const L=ee(f,"formaction");if(L!=null){n=L}const N=ee(f,"formmethod");if(N!=null){if(N.toLowerCase()!=="dialog"){t=N}}}const a=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:u,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:a};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const A=d.split(":");const I=A[0].trim();if(I==="this"){h=Ee(r,"hx-sync")}else{h=ce(fe(r,I))}d=(A[1]||"drop").trim();c=ie(h);if(d==="drop"&&c.xhr&&c.abortable!==true){oe(s);return e}else if(d==="abort"){if(c.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const Z=d.split(" ");g=(Z[1]||"last").trim()}}if(c.xhr){if(c.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(c.queuedRequests==null){c.queuedRequests=[]}if(g==="first"&&c.queuedRequests.length===0){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){c.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){c.queuedRequests=[];c.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;c.xhr=p;c.abortable=F;const m=function(){c.xhr=null;c.abortable=false;if(c.queuedRequests!=null&&c.queuedRequests.length>0){const e=c.queuedRequests.shift();e()}};const U=re(r,"hx-prompt");if(U){var y=prompt(U);if(y===null||!he(r,"htmx:prompt",{prompt:y,target:u})){oe(s);m();return e}}if(a&&!D){if(!confirm(a)){oe(s);m();return e}}let x=hn(r,u,y);if(t!=="get"&&!mn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=ue(x,i.headers)}const B=cn(r,t);let b=B.errors;const j=B.formData;if(i.values){un(j,Ln(i.values))}const V=Ln(Cn(r));const w=un(j,V);let v=dn(w,r);if(Q.config.getCacheBusterParam&&t==="get"){v.set("org.htmx.cache-buster",ee(u,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=wn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:v,parameters:An(v),unfilteredFormData:w,unfilteredParameters:An(w),headers:x,target:u,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;x=C.headers;v=Ln(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const $=n.split("#");const z=$[0];const R=$[1];let O=n;if(E){O=z;const W=!v.keys().next().done;if(W){if(O.indexOf("?")<0){O+="?"}else{O+="&"}O+=an(v);if(R){O+="#"+R}}}if(!qn(r,O,C)){ae(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),O,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in x){if(x.hasOwnProperty(k)){const Y=x[k];Rn(p,k,Y)}}}const H={xhr:p,target:u,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:O,responsePath:null,anchor:R}};p.onload=function(){try{const t=Tn(r);H.pathInfo.responsePath=On(p);M(r,H);en(T,q);he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){ae(r,"htmx:onLoadError",ue({error:e},H));throw e}};p.onerror=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){en(T,q);ae(r,"htmx:afterRequest",H);ae(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Yt(r);var q=Qt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:yn(p,r,v);p.send(J);return e}function In(e,t){const n=t.xhr;let r=null;let o=null;if(C(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(C(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(C(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const u=re(e,"hx-replace-url");const c=ie(e).boosted;let f=null;let a=null;if(l){f="push";a=l}else if(u){f="replace";a=u}else if(c){f="push";a=s||i}if(a){if(a==="false"){return{}}if(a==="true"){a=s||i}if(t.pathInfo.anchor&&a.indexOf("#")===-1){a=a+"#"+t.pathInfo.anchor}return{type:f,path:a}}else{return{}}}function Pn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function kn(e){for(var t=0;t0){E().setTimeout(e,y.swapDelay)}else{e()}}if(a){ae(o,"htmx:responseError",ue({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Xn={};function Fn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Un(e,t){if(t.init){t.init(n)}Xn[e]=ue(Fn(),t)}function Bn(e){delete Xn[e]}function jn(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Xn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return jn(ce(u(e)),n,r)}var Vn=false;ne().addEventListener("DOMContentLoaded",function(){Vn=true});function _n(e){if(Vn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function $n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Jn(){const e=zn();if(e){Q.config=ue(Q.config,e)}}_n(function(){Jn();$n();let e=ne().body;Dt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/Neos.Workspace.Ui/Resources/Public/Styles/Module.css b/Neos.Workspace.Ui/Resources/Public/Styles/Module.css new file mode 100644 index 00000000000..8e01ee7aeef --- /dev/null +++ b/Neos.Workspace.Ui/Resources/Public/Styles/Module.css @@ -0,0 +1,426 @@ +.workspace-table { + --column-color: var(--grayMedium); + --indent-level: 0; + + margin-top: 1em; + border-spacing: 0; + position: relative; + width: 100%; +} + +.workspace-table th { + padding: 0.5em; + position: sticky; + top: 40px; + user-select: none; + background: var(--grayDark); + border-bottom: 1px solid var(--grayDark); + z-index: 1; +} + +.workspace-table td { + padding: 0 0.5em; + border-top: 1px solid var(--grayDark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + background-color: var(--column-color); + min-height: 40px; +} + +.workspace-type-column { + padding: 0 0.5ch; + text-align: center; + width: 20px; +} + +.workspace-type-column > * { + width: 20px !important; +} + +/** + * The arrow icon to show sub-workspace indentation + */ +td.workspace-description-column, +td.workspace-label-column { + width: 25%; +} + +td.workspace-label-column .fas { + transform: rotate(90deg); + vertical-align: middle; + margin-left: calc((var(--indent-level, 0) - 2) * 1.2ch); + margin-right: 0.5ch; +} + +td.workspace-action-column { + background-color: var(--grayMedium); + width: 1%; + white-space: nowrap; +} + +.workspace__info-text { + font-size: 0.8em; + font-style: italic; + margin-left: 0.5ch; + user-select: none; +} + +.workspace-count { + display: inline-block; + color: var(--textSubtleLight); + font-size: var(--generalFontSize); + line-height: var(--unit); + vertical-align: middle; + margin-left: var(--spacing-Full); +} + +.icon-button { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + outline: none; + color: var(--textOnGray); +} + +.icon--secondary { + left: 0.5em; + top: 0.5em; + text-shadow: -1px -1px 0 rgba(0, 0, 0, 0.4); +} + +/* Add space after icon in button if there is a label */ +button .icon:not(:last-child) { + margin-right: 1ch; +} + +/** + * Badges + */ +.badge { + background-color: var(--grayLight); + border-radius: 15%; + color: var(--textOnGray); + padding: 0.2em 0.5em; + user-select: none; + cursor: help; +} + +.badge:not(:last-child) { + margin-right: 1ch; +} + +.badge--success { + background-color: var(--green); +} + +.badge--warning { + background-color: var(--warningText); +} + +.badge--danger { + background-color: var(--errorText); +} + +/** + * Workspace entry states and types + */ +.workspace--stale { + --column-color: var(--grayDark); +} + +.workspace--personal-workspace { + --column-color: var(--blueDark); +} + +/** + * Popover styles + */ +[popover] { + /* Undo css reset */ + margin: auto; + + /* Component styles */ + color: white; + background: var(--grayDark); + border: 1px solid var(--grayLight); + padding: 0; + border-radius: 0; + outline: none; +} + +[popover]::backdrop { + backdrop-filter: blur(3px); +} + +[popover] header { + font-size: calc(var(--generalFontSize) + 2px); + margin: var(--defaultMargin); + line-height: calc(var(--generalFontSize) + 4px); + box-sizing: border-box; +} + +[popover] section { + margin: var(--defaultMargin); + color: var(--textSubtleLight); +} + +[popover] form { + width: 400px; + max-width: 90vw; +} + +[popover] .neos-close { + color: white; + font-size: calc(var(--generalFontSize) + 4px); + background: transparent; + width: var(--unit); + height: var(--unit); + position: absolute; + right: 0; + top: 0; + border-left: none; + text-shadow: none; +} + +[popover] footer { + background: transparent; + margin: var(--defaultMargin); + display: flex; + gap: var(--spacing-Full); + justify-content: flex-end; +} + +/** + * Form styles + */ +.neos-control-label { + margin-bottom: var(--spacing-Full); +} + +.neos-control-group textarea, +.neos-control-group select, +.neos-control-group input[type="text"] { + width: 100%; + max-width: 500px; +} + +/** + * Indicator styles + */ +.loadingIndicator__container { + left: 0; + top: var(--unit); + height: 2px; + position: fixed; + width: 100vw; + z-index: var(--zIndex-LoadingIndicatorContainer); +} + +.loadingIndicator { + height: 2px; + position: relative; + width: 100%; +} + +.loadingIndicator__bar { + height: 100%; + position: relative; + background-color: #ff8700; + animation: cssload-width 2s cubic-bezier(.45, 0, 1, 1) infinite; +} + +@keyframes cssload-width { + 0%, + 100% { + transition-timing-function: cubic-bezier(1, 0, .65, .85); + } + 0% { + width: 0; + } + 100% { + width: 100%; + } +} + +.neos.neos-module #workspaceReview table.neos-table td:not(.diff-property-label):not(.neos-content-change) { + border-top-width: 0; +} + +.neos-content-diff { + width: 100%; + margin: 0 var(--spacing-Full); +} + +#workspaceReview .diff-property-label, +#workspaceReview .diff-node-details { + padding-left: var(--spacing-Half) !important; +} + +#workspaceReview .diff-node-details .neos-label { + margin-top: 11px; +} + +#workspaceReview .diff-property-label { + height: auto !important; + padding-bottom: 0 !important; +} + +#workspaceReview .neos-content-change { + padding-left: 0 !important; +} + +#workspaceReview i + span { + padding-left: var(--spacing-Half); +} + +#workspaceReview .neos-document, +tr.neos-change + tr.neos-change td.neos-content-change { + border-top: 1px solid var(--grayDark); +} + +#workspaceReview .workspace-action i { + padding-right: var(--spacing-Half); +} + +#workspaceReview .toggle-document { + display: inline-block; + padding: 0 var(--spacing-Full); + cursor: pointer; +} + +#workspaceReview .document-details > i { + padding: 0 var(--spacing-Half); +} + +#workspaceReview .document-details > span > i { + padding-right: var(--spacing-Half); +} + +#workspaceReview thead th, +#workspaceReview .neos-document td { + padding: 0 !important; +} + +#workspaceReview .check-document + span { + margin-right: 0; +} + +#workspaceReview .table-action { + padding: 0 var(--spacing-Full); +} + +#workspaceReview .table-action:hover { + color: var(--blue); + cursor: pointer; +} + +#workspaceReview .document-actions { + width: 184px; +} + +#workspaceReview .neos-content-diff table { + table-layout: fixed; + width: 100%; +} + +.neos.neos-module .neos-content-diff td { + height: auto; + width: 50%; + vertical-align: top; + line-height: 20px; + padding: 10px 20px 10px 10px !important; + border-top: none; + white-space: normal; +} + +.neos-content-diff table.neos-content-diff td img, +.neos-content-diff table.neos-content-diff th img { + max-width: 100%; + min-width: 50%; + max-height: 500px; + border: 20px solid var(--white); + box-sizing: border-box; +} + +#workspaceReview .document-details { + padding-left: 0 !important; + padding-right: 0; +} + +.neos-content-change, +.neos-document{ + border-left: 4px solid var(--orange); +} + +#workspaceReview .legend-deleted { + border-left: 4px solid var(--warning); +} + +#workspaceReview .legend-created { + border-left: 4px solid var(--green); +} + +#workspaceReview .legend-moved { + border-left: 4px solid var(--blue); +} + +#workspaceReview .legend-hidden { + border-left: 4px solid var(--grayLighter) +} + +#workspaceReview td.actions { + width: 144px; +} + +#workspaceReview ins, +#workspaceReview ins a { + color: var(--green); + text-decoration: none; +} + +#workspaceReview del, +#workspaceReview del a { + color: var(--warning); + text-decoration: none; +} + +#workspaceReview .diff-property-content td:first-child, +#workspaceReview th.neos-action { + width: 30px +} + +#workspaceReview tr.diff-property-content > td { + line-height: 20px !important; +} + + #workspaceReview .fa-level-up-alt { + transform: rotate(90deg); +} + +#workspaceReview .node-icons { + display: inline-block; + position: relative; +} + +#workspaceReview .node-icons .icon-red { + position: absolute; + top: 50%; + left: 40%; + color: var(--warning); + font-size: 12px; +} + +#workspaceReview .node-icons { + margin-right: 7px; +} + +#workspaceReview #publishOrDiscardNodesForm { + min-width: 100%; +} + +#workspaceReview .no-unpublished-changes { + margin-top: 1em; +}