Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Edit preview mode support for Neos 9 #4067

Closed
wants to merge 8 commits into from
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

/*
* This file is part of the Neos.Neos package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Neos\Controller\Exception;

use Neos\Neos\Controller\Exception;

/**
* A "Node Creation" exception
*
*/
class InvalidEditPreviewModeException extends Exception
{
}
54 changes: 48 additions & 6 deletions Neos.Neos/Classes/Controller/Frontend/NodeController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
use Neos\Flow\Security\Context as SecurityContext;
use Neos\Flow\Session\SessionInterface;
use Neos\Flow\Utility\Now;
use Neos\Neos\Controller\Exception\InvalidEditPreviewModeException;
use Neos\Neos\Domain\Model\EditPreviewMode;
use Neos\Neos\Domain\Repository\EditPreviewModeRepository;
use Neos\Neos\Domain\Service\NodeSiteResolvingService;
use Neos\Neos\FrontendRouting\Exception\InvalidShortcutException;
use Neos\Neos\FrontendRouting\Exception\NodeNotFoundException;
Expand Down Expand Up @@ -107,8 +110,15 @@ class NodeController extends ActionController
*/
protected $nodeSiteResolvingService;

/**
* @Flow\Inject
* @var EditPreviewModeRepository
*/
protected $editPreviewModeRepository;

/**
* @param string $node Legacy name for backwards compatibility of route components
* @param string|null $editPreviewMode Rendering mode like "rawContent" defaults to defaultEditPreviewMode from settings
* @throws NodeNotFoundException
* @throws \Neos\Flow\Mvc\Exception\StopActionException
* @throws \Neos\Flow\Mvc\Exception\UnsupportedRequestTypeException
Expand All @@ -119,7 +129,38 @@ class NodeController extends ActionController
* with unsafe requests from widgets or plugins that are rendered on the node
* - For those the CSRF token is validated on the sub-request, so it is safe to be skipped here
*/
public function previewAction(string $node): void
public function previewAction(string $node, string $editPreviewMode = null): void
{
$editPreviewModeObject = $editPreviewMode ? $this->editPreviewModeRepository->findByName($editPreviewMode) : $this->editPreviewModeRepository->findDefault();
if ($editPreviewModeObject->isPreviewMode === false) {
throw new InvalidEditPreviewModeException(sprintf('"%s" is not a preview mode', $editPreviewMode), 1683127314);
Comment on lines +134 to +136
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also prefer to move this logic closer to the domain, by letting the EditPreviewModeRepository provide more specific retrieval methods:

final class EditPreviewModeRepository
{
    /** @throws NotAnEditModeException */
    public function findOneEditModeByName(string $name): ?EditPreviewMode;

    /** @throws DefaultEditModeNotFoundException */
    public function findDefaultEditMode(): EditPreviewMode;

    /** @throws NotAPreviewModeException */
    public function findOnePreviewModeByName(string $name): ?EditPreviewMode;

    /** @throws DefaultPreviewModeNotFoundException */
    public function findDefaultPreviewMode(): EditPreviewMode;
}

}
$this->renderEditPreviewMode($node, $editPreviewModeObject);
}

/**
* @param string $node Legacy name for backwards compatibility of route components
* @param string|null $editPreviewMode Rendering mode like "rawContent" defaults to defaultEditPreviewMode from settings
* @throws NodeNotFoundException
* @throws \Neos\Flow\Mvc\Exception\StopActionException
* @throws \Neos\Flow\Mvc\Exception\UnsupportedRequestTypeException
* @throws \Neos\Flow\Mvc\Routing\Exception\MissingActionNameException
* @throws \Neos\Flow\Session\Exception\SessionNotStartedException
* @throws \Neos\Neos\Exception
* @Flow\SkipCsrfProtection We need to skip CSRF protection here because this action could be called
* with unsafe requests from widgets or plugins that are rendered on the node
* - For those the CSRF token is validated on the sub-request, so it is safe to be skipped here
*/
public function editAction(string $node, ?string $editPreviewMode = null): void
{
$editPreviewModeObject = $editPreviewMode ? $this->editPreviewModeRepository->findByName($editPreviewMode) : $this->editPreviewModeRepository->findDefault();
if ($editPreviewModeObject->isEditMode === false) {
throw new InvalidEditPreviewModeException(sprintf('"%s" is not an edit mode', $editPreviewModeObject->name), 1683127295);
Comment on lines +156 to +158
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise

}
$this->renderEditPreviewMode($node, $editPreviewModeObject);
}

protected function renderEditPreviewMode(string $node, EditPreviewMode $editPreviewMode): void
{
$visibilityConstraints = VisibilityConstraints::frontend();
if ($this->privilegeManager->isPrivilegeTargetGranted('Neos.Neos:Backend.GeneralAccess')) {
Expand Down Expand Up @@ -160,9 +201,13 @@ public function previewAction(string $node): void
$this->handleShortcutNode($nodeAddress, $contentRepository);
}

if ($editPreviewMode->fusionPath) {
$this->view->setFusionPath($editPreviewMode->fusionPath);
}

$this->view->assignMultiple([
'value' => $nodeInstance,
'site' => $site,
'site' => $site
]);

if (!$nodeAddress->isInLiveWorkspace()) {
Expand Down Expand Up @@ -192,7 +237,7 @@ public function previewAction(string $node): void
* with unsafe requests from widgets or plugins that are rendered on the node
* - For those the CSRF token is validated on the sub-request, so it is safe to be skipped here
*/
public function showAction(string $node, bool $showInvisible = false): void
public function showAction(string $node): void
{
$siteDetectionResult = SiteDetectionResult::fromRequest($this->request->getHttpRequest());
$contentRepository = $this->contentRepositoryRegistry->get($siteDetectionResult->contentRepositoryId);
Expand All @@ -203,9 +248,6 @@ public function showAction(string $node, bool $showInvisible = false): void
}

$visibilityConstraints = VisibilityConstraints::frontend();
if ($showInvisible && $this->privilegeManager->isPrivilegeTargetGranted('Neos.Neos:Backend.GeneralAccess')) {
$visibilityConstraints = VisibilityConstraints::withoutRestrictions();
}

$subgraph = $contentRepository->getContentGraph()->getSubgraph(
$nodeAddress->contentStreamId,
Expand Down
43 changes: 43 additions & 0 deletions Neos.Neos/Classes/Domain/Model/EditPreviewMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the Neos.Neos package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Neos\Domain\Model;

final class EditPreviewMode
{
protected function __construct(
public readonly string $name,
public readonly string $title,
public readonly ?string $fusionPath,
public readonly bool $isEditMode,
public readonly bool $isPreviewMode
) {
}
Comment on lines +25 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We were talking about this model. It should contain a check, so that $isEditMode and $isPreviewMode cannot both be true at once (and that one of them must be true).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont say enum ;)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't :D

But yeah, that would probably be better.


/**
* @param string $name
* @param array{'title'?:string, 'fusionRenderingPath'?:string, 'isEditingMode'?:bool, 'isPreviewMode'?:bool} $configuration
* @return self
*/
public static function fromNameAndConfiguration(string $name, array $configuration): self
{
return new static(
$name,
$configuration['title'] ?? $name,
$configuration['fusionRenderingPath'] ?? null,
$configuration['isEditingMode'] ?? false,
$configuration['isPreviewMode'] ?? false
);
}
}
44 changes: 44 additions & 0 deletions Neos.Neos/Classes/Domain/Repository/EditPreviewModeRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

/*
* This file is part of the Neos.Neos package.
*
* (c) Contributors of the Neos Project - www.neos.io
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

declare(strict_types=1);

namespace Neos\Neos\Domain\Repository;

use Neos\Neos\Controller\Exception\InvalidEditPreviewModeException;
use Neos\Neos\Domain\Model\EditPreviewMode;
use Neos\Flow\Annotations as Flow;

class EditPreviewModeRepository
{
#[Flow\InjectConfiguration(path:"userInterface.defaultEditPreviewMode")]
protected string $defaultEditPreviewMode;

/**
* @var array<string, array{'title'?:string, 'fusionRenderingPath'?:string, 'isEditingMode'?:bool, 'isPreviewMode'?:bool}>
*/
#[Flow\InjectConfiguration(path:"userInterface.editPreviewModes")]
protected array $editPreviewModeConfigurations;

public function findDefault(): EditPreviewMode
{
return EditPreviewMode::fromNameAndConfiguration($this->defaultEditPreviewMode, $this->editPreviewModeConfigurations[$this->defaultEditPreviewMode]);
}

public function findByName(string $name): EditPreviewMode
{
if (array_key_exists($name, $this->editPreviewModeConfigurations)) {
return EditPreviewMode::fromNameAndConfiguration($name, $this->editPreviewModeConfigurations[$name]);
}
throw new InvalidEditPreviewModeException(sprintf('"%s" is not a valid editPreviewMode', $name), 1683790077);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new InvalidEditPreviewModeException(sprintf('"%s" is not a valid editPreviewMode', $name), 1683790077);
throw new InvalidEditPreviewModeException(sprintf('An editPreviewMode with the name "%s" does not exist', $name), 1683790077);

}
}
54 changes: 51 additions & 3 deletions Neos.Neos/Classes/FrontendRouting/NodeUriBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ public static function fromUriBuilder(UriBuilder $uriBuilder): self
public function uriFor(NodeAddress $nodeAddress): UriInterface
{
if (!$nodeAddress->isInLiveWorkspace()) {
$request = $this->uriBuilder->getRequest();
if (
$request->getControllerPackageKey() === 'Neos.Neos'
&& $request->getControllerName() === "Frontend\Node"
) {
if ($request->getControllerActionName() == 'edit') {
$editPreviewModeArgument = $request->hasArgument('editPreviewMode') ? $request->getArgument('editPreviewMode') : null;
return $this->editUriFor($nodeAddress, is_string($editPreviewModeArgument) ? $editPreviewModeArgument : null);
} elseif ($request->getControllerActionName() == 'preview') {
$editPreviewModeArgument = $request->hasArgument('editPreviewMode') ? $request->getArgument('editPreviewMode') : null;
return $this->previewUriFor($nodeAddress, is_string($editPreviewModeArgument) ? $editPreviewModeArgument : null);
}
}

return $this->previewUriFor($nodeAddress);
}
return new Uri($this->uriBuilder->uriFor('show', ['node' => $nodeAddress], 'Frontend\Node', 'Neos.Neos'));
Expand All @@ -75,16 +89,50 @@ public function uriFor(NodeAddress $nodeAddress): UriInterface
* A preview URI is used to display a node that is not public yet (i.e. not in a live workspace).
*
* @param NodeAddress $nodeAddress
* @param string|null $editPreviewMode
* @return UriInterface
* @throws NoMatchingRouteException | MissingActionNameException | HttpException
*/
public function previewUriFor(NodeAddress $nodeAddress): UriInterface
public function previewUriFor(NodeAddress $nodeAddress, ?string $editPreviewMode = null): UriInterface
{
return new Uri($this->uriBuilder->uriFor(
$uri = new Uri($this->uriBuilder->uriFor(
'preview',
['node' => $nodeAddress->serializeForUri()],
[],
'Frontend\Node',
'Neos.Neos'
));

$queryParameters = ['node' => $nodeAddress->serializeForUri()];
if ($editPreviewMode) {
$queryParameters['editPreviewMode'] = $editPreviewMode;
}

return $uri->withQuery(http_build_query($queryParameters));
}

/**
* Renders a stable "edit" URI for the given $nodeAddress
* A edit URI is used to render a node for inline editing that is not public yet (i.e. not in a live workspace).
*
* @param NodeAddress $nodeAddress
* @param string|null $editPreviewMode
* @return UriInterface
* @throws NoMatchingRouteException | MissingActionNameException | HttpException
*/
public function editUriFor(NodeAddress $nodeAddress, ?string $editPreviewMode = null): UriInterface
{
$uri = new Uri($this->uriBuilder->uriFor(
'edit',
[],
'Frontend\Node',
'Neos.Neos'
));

$queryParameters = ['node' => $nodeAddress->serializeForUri()];
if ($editPreviewMode) {
$queryParameters['editPreviewMode'] = $editPreviewMode;
}

return $uri->withQuery(http_build_query($queryParameters));
}
}
30 changes: 30 additions & 0 deletions Neos.Neos/Classes/Fusion/Helper/BackendHelper.php
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm very unsure whether it's wise to check for the current edit/preview mode via this EEL-Helper and by analyzing the request. As a consequence of this strategy, this is what needs to be done for out-of-band rendering in the UI:

https://github.com/neos/neos-ui/blob/0a858bb1cd5a31e4143f9b06144b0704c3acaad7/Classes/Domain/Model/Feedback/Operations/ReloadContentOutOfBand.php#L136-L146
(These lines show the creation of a fake controller context, so this EEL-Helper is convinced to be executed in a different request than it actually is)

Intuitively, I'd say it'd be better to just throw the current EditPreviewMode object into the global fusion context, so it can just be used like

[email protected] = ${editPreviewMode.isEditMode}

On the other hand, that fake controller context may not be the worst idea after all, because usually integrators wouldn't expect (and shouldn't notice) their code to go through such different execution paths.

However, semantically it seems very arbitrary to me to bind the current edit preview mode to a specific controller action...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes i rather have this additional context too. I remember that we discussed this at the Dresden sprint, but the discussion came up that in fusion we can't currently add new context variables because users would have to account for them in caching.

There is a way though: We need to pass the context variable behind fusions back like request so its always available in eel.

I will prepare a draft pr to show you what i mean ;)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@grebaldi i prepared a concept over here: #4425

this way we open the api of the runtime to allow other variables like request who are always present (in the instance of the runtime) and thus not affected by caching.

This would allow us to add a global editPreviewMode object with no dependency on the request. ;)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new Runtime(
  defaultContextVariables: [
      'request' => $request,
      'Neos' => [
        'UiMode' => new UiModeHelper($isBackend)
      ]
  ]
)

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use Neos\Eel\ProtectedContextAwareInterface;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Mvc\ActionRequest;
use Neos\Neos\Service\UserService;

/**
Expand All @@ -40,6 +41,35 @@ public function interfaceLanguage(): string
return $this->userService->getInterfaceLanguage();
}

public static function isEditMode(ActionRequest $request): bool
{
return ($request->getControllerPackageKey() === 'Neos.Neos'
&& $request->getControllerName() === 'Frontend\\Node'
&& $request->getControllerActionName() === 'edit'
);
}

public static function isPreviewMode(ActionRequest $request): bool
{
return ($request->getControllerPackageKey() === 'Neos.Neos'
&& $request->getControllerName() === 'Frontend\\Node'
&& $request->getControllerActionName() === 'preview'
);
}

public static function renderingModeCacheIdentifier(ActionRequest $request): string
{
if (self::isEditMode($request) || self::isPreviewMode($request)) {
$editPreviewModeArgument = $request->hasArgument('editPreviewMode') ? $request->getArgument('editPreviewMode') : null;
if (is_string($editPreviewModeArgument)) {
return $request->getControllerActionName() . ':' . $editPreviewModeArgument;
}
return $request->getControllerActionName();
} else {
return 'show';
}
}

public function allowsCallOfMethod($methodName)
{
return true;
Expand Down
7 changes: 0 additions & 7 deletions Neos.Neos/Classes/Fusion/Helper/NodeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,6 @@ public function labelForNode(Node $node): NodeLabelToken
return new NodeLabelToken($node);
}

public function inBackend(Node $node): bool
{
$contentRepository = $this->contentRepositoryRegistry->get($node->subgraphIdentity->contentRepositoryId);
$nodeAddressFactory = NodeAddressFactory::create($contentRepository);
return !$nodeAddressFactory->createFromNode($node)->isInLiveWorkspace();
}

/**
* @param Node $node
* @return int
Expand Down
15 changes: 13 additions & 2 deletions Neos.Neos/Classes/Service/LinkingService.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use Neos\ContentRepository\Core\Projection\NodeHiddenState\NodeHiddenStateProjection;
use Neos\ContentRepository\Core\SharedModel\Node\NodeAggregateId;
use Neos\ContentRepository\Core\Projection\ContentGraph\NodePath;
use Neos\Flow\Mvc\ActionRequest;
use Neos\Flow\Persistence\Exception\IllegalObjectTypeException;
use Neos\Neos\FrontendRouting\NodeAddressFactory;
use Neos\ContentRepository\Core\Projection\ContentGraph\VisibilityConstraints;
use Neos\ContentRepositoryRegistry\ContentRepositoryRegistry;
Expand All @@ -37,6 +39,7 @@
use Neos\Neos\Exception as NeosException;
use Neos\Neos\FrontendRouting\NodeShortcutResolver;
use Neos\Neos\FrontendRouting\SiteDetection\SiteDetectionResult;
use Neos\Neos\Fusion\Helper\BackendHelper;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;

Expand Down Expand Up @@ -272,7 +275,7 @@ public function convertUriToObject($uri, Node $contextNode = null)
* @throws \Neos\Flow\Property\Exception
* @throws \Neos\Flow\Security\Exception
* @throws HttpException
* @throws \Neos\Flow\Persistence\Exception\IllegalObjectTypeException
* @throws IllegalObjectTypeException
*/
public function createNodeUri(
ControllerContext $controllerContext,
Expand Down Expand Up @@ -367,7 +370,15 @@ public function createNodeUri(
$request = $controllerContext->getRequest()->getMainRequest();
$uriBuilder = clone $controllerContext->getUriBuilder();
$uriBuilder->setRequest($request);
$action = $workspace && $workspace->isPublicWorkspace() && !$hiddenState->isHidden ? 'show' : 'preview';

if (BackendHelper::isEditMode($request) || BackendHelper::isPreviewMode($request)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to move this closer to the domain. Looks like a job for the EditPreviewModeRepository:

final class EditPreviewModeRepository
{
    public function findOneByActionRequest(ActionRequest $request): ?EditPreviewMode;
}

So that this check translates to

Suggested change
if (BackendHelper::isEditMode($request) || BackendHelper::isPreviewMode($request)) {
$editPreviewMode = $this->editPreviewModeRepository->findOneByActionRequest($request);
if ($editPreviewMode) {

Depending on how we move forward with the BackendHelper itself, it should of course use the same method to retrieve the proper EditPreviewMode from the request.

$action = $request->getControllerActionName();
if ($request->hasArgument('editPreviewMode')) {
$arguments['editPreviewMode'] = $request->getArgument('editPreviewMode');
}
} else {
$action = $workspace && $workspace->isPublicWorkspace() && !$hiddenState->isHidden ? 'show' : 'preview';
}

return $uriBuilder
->reset()
Expand Down
Loading