Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file.

### Bug Fixes

- **Workspace Draft Preview**: The preview button of a workspace-aware controller opened the live version instead of the draft. The preview URI listener of `EXT:workspaces` evaluates the workspace aspect of the `Context` rather than the backend user, so the module workspace is now passed to `PreviewUriBuilder::buildUri()` via a dedicated `Context` per record. `CurrentFrontendWorkspaceManipulation` now runs before page resolution and `PreviewSimulator` so that preview mode and the cache bypass are actually activated — previously workspace content could be written into the live page cache. The middleware additionally verifies that the backend user may access the requested workspace.
- **Workspace Preview from the Editing Form**: The view button of the record editing form (route `record_edit`) linked to the native workspace split preview module, which expects the workspace to be actively selected, and resulted in an error. The new `WorkspacePreviewUriRewriter` event listener keeps `EXT:workspaces` from redirecting there, so every preview URI TYPO3 builds for a manipulated workspace is a direct frontend URI and behaves like the record list view button. Scoped to requests of this extension via `WorkspacePreviewState`, regular workspace usage of the installation is unaffected.
- **Root/First Page Selectable on Create**: The new-record modal now lists every accessible page (including the configured root/first page), which was previously excluded so only subpages could be chosen.
- **First Directory Filterable**: The directory dropdown now has an explicit "All directories" entry and uses a `scope` query parameter, so selecting the first directory filters to it instead of implicitly showing all pages.

Expand Down
32 changes: 32 additions & 0 deletions Classes/Context/WorkspacePreviewState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Xima\XimaTypo3Recordlist\Context;

use TYPO3\CMS\Core\SingletonInterface;

/**
* Marks the current request as a record list managed workspace preview.
*
* Backend modules of this extension handle the publishing workflow themselves and deliberately bypass the native
* workspace GUI, so preview URIs have to point to the frontend directly instead of the workspace split preview
* module. Since TYPO3 core builds preview URIs in several places, the rewriting happens in an event listener, which
* uses this state to tell "our" preview requests apart from regular workspace usage of the same installation.
*
* @see \Xima\XimaTypo3Recordlist\EventListener\WorkspacePreviewUriRewriter
*/
class WorkspacePreviewState implements SingletonInterface
{
private bool $active = false;

public function isActive(): bool
{
return $this->active;
}

public function setActive(bool $active): void
{
$this->active = $active;
}
}
27 changes: 20 additions & 7 deletions Classes/Controller/AbstractBackendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\VisibilityAspect;
use TYPO3\CMS\Core\Context\WorkspaceAspect;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
Expand Down Expand Up @@ -46,6 +49,7 @@
use TYPO3\CMS\Extbase\Mvc\RequestInterface;
use TYPO3\CMS\Workspaces\Authorization\WorkspacePublishGate;
use TYPO3\CMS\Workspaces\Service\WorkspaceService;
use Xima\XimaTypo3Recordlist\Context\WorkspacePreviewState;
use Xima\XimaTypo3Recordlist\Dto\RecordSource;
use Xima\XimaTypo3Recordlist\Pagination\EditableArrayPaginator;
use Xima\XimaTypo3Recordlist\Utility\RelationFilterResult;
Expand Down Expand Up @@ -1990,16 +1994,26 @@ protected function addPreviewButton(): void
return;
}

// save current workspace
// save current workspace and preview state
$currentWorkspace = $this->getBackendAuthentication()->workspace;
$workspacePreviewState = GeneralUtility::makeInstance(WorkspacePreviewState::class);
$previousPreviewState = $workspacePreviewState->isActive();

// The visibility aspect mirrors the one PreviewUriBuilder sets up internally for its default Context.
$previewContext = null;
if ($this::WORKSPACE_ID !== 0) {
$previewContext = clone GeneralUtility::makeInstance(Context::class);
$previewContext->setAspect('visibility', new VisibilityAspect(true, false, false, true));
$previewContext->setAspect('workspace', new WorkspaceAspect($this::WORKSPACE_ID));
}

foreach ($this->records as &$record) {
// check if controller + record is workspace aware
$isWorkspaceAware = $this::WORKSPACE_ID !== 0 && isset($record['t3ver_wsid']) && $record['t3ver_wsid'] > 0;

// override user workspace
// the record resolution of PreviewUriBuilder still relies on the backend user workspace
if ($isWorkspaceAware) {
$this->getBackendAuthentication()->workspace = $this::WORKSPACE_ID;
$workspacePreviewState->setActive(true);
}

// A configured previewPageId (TSconfig) wins; otherwise fall back to the record's own pid
Expand All @@ -2020,14 +2034,13 @@ protected function addPreviewButton(): void
$this->getTableName(),
$record['uid'],
$previewPageId
)->buildUri();
)->buildUri(null, $isWorkspaceAware ? clone $previewContext : null);
}

// add workspace id to url + restore user workspace
// restore user workspace and preview state
if ($isWorkspaceAware) {
$record['url'] .= '&workspaceId=' . $this::WORKSPACE_ID;
// restore user workspace
$this->getBackendAuthentication()->workspace = $currentWorkspace;
$workspacePreviewState->setActive($previousPreviewState);
}
}
}
Expand Down
59 changes: 59 additions & 0 deletions Classes/EventListener/WorkspacePreviewUriRewriter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Xima\XimaTypo3Recordlist\EventListener;

use TYPO3\CMS\Backend\Routing\Event\BeforePagePreviewUriGeneratedEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Context\WorkspaceAspect;
use Xima\XimaTypo3Recordlist\Context\WorkspacePreviewState;

/**
* Rewrites workspace preview URIs to direct frontend URIs.
*
* As soon as the workspace aspect of the Context is set, EXT:workspaces rewrites every preview URI to the workspace
* split preview module (Workspaces\Hook\BackendUtilityHook::createPageUriForWorkspaceVersion). That module is the
* native workspace GUI which the backend modules of this extension replace, and it expects the backend user to have
* the workspace actively selected. Reset the workspace aspect for the URI generation instead, so that TYPO3 builds a
* regular frontend URI, and let the CurrentFrontendWorkspaceManipulation middleware apply the workspace in the
* frontend. PreviewUriBuilder passes a Context given to buildUri() into the event unchanged, so callers have to hand
* over a Context they can discard afterwards.
*
* This affects the view button of the record list as well as every preview URI TYPO3 core builds for the manipulated
* workspace, most notably the view button of the record editing form (route "record_edit").
*/
final class WorkspacePreviewUriRewriter
{
public function __construct(
private readonly WorkspacePreviewState $workspacePreviewState,
) {
}

#[AsEventListener(
identifier: 'xima-typo3-recordlist/workspace-preview-uri',
before: 'typo3-workspaces/link-modifier'
)]
public function rewritePreviewUri(BeforePagePreviewUriGeneratedEvent $event): void
{
if (!$this->workspacePreviewState->isActive()) {
return;
}

$workspaceId = (int)$event->getContext()->getPropertyFromAspect('workspace', 'id', 0);
if ($workspaceId === 0) {
return;
}

$event->getContext()->setAspect('workspace', new WorkspaceAspect(0));
// "IGNORE" keeps the regular backend user session instead of initializing a preview user, the workspace
// itself is applied by CurrentFrontendWorkspaceManipulation
$event->setAdditionalQueryParameters(array_replace(
$event->getAdditionalQueryParameters(),
[
'ADMCMD_prev' => 'IGNORE',
'workspaceId' => $workspaceId,
]
));
}
}
11 changes: 11 additions & 0 deletions Classes/Middleware/CurrentBackendWorkspaceManipulation.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Backend\Routing\Route;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\WorkspaceAspect;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Workspaces\Service\WorkspaceService;
use Xima\XimaTypo3Recordlist\Context\WorkspacePreviewState;

/**
* Middleware to manipulate the current backend workspace based on a custom parameter.
Expand Down Expand Up @@ -51,6 +54,14 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
// Overwrite current workspace for this request
$backendUser->workspace = (int)$workspaceId;

// Keep the Context in sync, the workspace aspect is what makes TYPO3 build workspace aware preview URIs
GeneralUtility::makeInstance(Context::class)
->setAspect('workspace', new WorkspaceAspect((int)$workspaceId));

// Mark the request so that WorkspacePreviewUriRewriter points preview URIs to the frontend instead of the
// native workspace split preview module
GeneralUtility::makeInstance(WorkspacePreviewState::class)->setActive(true);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Grant access to workspaces_publish module if not already granted (use more precise check)
$modules = explode(',', $backendUser->groupData['modules'] ?? '');
if (!in_array('workspaces_publish', $modules, true)) {
Expand Down
17 changes: 10 additions & 7 deletions Classes/Middleware/CurrentFrontendWorkspaceManipulation.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@ public function process(ServerRequestInterface $request, RequestHandlerInterface
}

// validate workspaceId is set
$workspaceId = $request->getQueryParams()['workspaceId'] ?? false;
if (!$workspaceId) {
$workspaceId = (int)($request->getQueryParams()['workspaceId'] ?? 0);
if ($workspaceId === 0) {
return $handler->handle($request);
}

// set workspace aspect
// validate the current backend user is allowed to access the requested workspace
$backendUser = $this->getBackendUser();
if ($backendUser instanceof BackendUserAuthentication) {
/** @var Context $context */
$context = GeneralUtility::makeInstance(Context::class);
$context->setAspect('workspace', GeneralUtility::makeInstance(WorkspaceAspect::class, (int)$workspaceId));
if (!$backendUser instanceof BackendUserAuthentication || $backendUser->checkWorkspace($workspaceId) === false) {
return $handler->handle($request);
}

// set workspace aspect
/** @var Context $context */
$context = GeneralUtility::makeInstance(Context::class);
$context->setAspect('workspace', new WorkspaceAspect($workspaceId));

return $handler->handle($request);
}

Expand Down
9 changes: 8 additions & 1 deletion Configuration/RequestMiddlewares.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@
'xima-typo3-recordlist/current-workspace-manipulation' => [
'target' => \Xima\XimaTypo3Recordlist\Middleware\CurrentFrontendWorkspaceManipulation::class,
'after' => [
'typo3/cms-core/response-propagation',
// The backend user aspect must have been set up, it would otherwise overwrite our workspace aspect
'typo3/cms-frontend/backend-user-authentication',
],
'before' => [
// The workspace aspect has to be in place before the page is resolved and before PreviewSimulator
// evaluates it, otherwise neither the preview mode nor the cache bypass are activated
'typo3/cms-frontend/page-resolver',
'typo3/cms-frontend/preview-simulator',
],
],
],
Expand Down
5 changes: 5 additions & 0 deletions Configuration/Services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ services:

Xima\XimaTypo3Recordlist\Controller\AjaxController:
public: true

# Shared state, has to be retrievable via GeneralUtility::makeInstance() so that middleware, controllers and the
# event listener operate on the same instance
Xima\XimaTypo3Recordlist\Context\WorkspacePreviewState:
public: true
8 changes: 8 additions & 0 deletions Tests/Acceptance/Fixtures/tx_news_domain_model_news.sql
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,11 @@ ON DUPLICATE KEY UPDATE
content_blocks_timestamp = VALUES(content_blocks_timestamp),
content_blocks_date = VALUES(content_blocks_date),
content_blocks_datetime = VALUES(content_blocks_datetime);

-- Draft versions in workspace 1 for the two records below the newest one, so that the record list renders more than
-- one workspace draft on the first page (sorted by datetime DESC) while leaving the first row a live record.
INSERT INTO `tx_news_domain_model_news`
(`uid`, `pid`, `sys_language_uid`, `l10n_parent`, `title`, `path_segment`, `teaser`, `bodytext`, `datetime`, `author`, `sitemap_changefreq`, `t3ver_oid`, `t3ver_wsid`, `t3ver_state`, `t3ver_stage`)
VALUES
(9101, 15, 0, 0, 'Year-End Performance Review', 'year-end-performance-review', 'Strong results position company for future growth', '<p>The year-end performance review shows strong results across all metrics.</p><p>Draft revision awaiting review.</p>', UNIX_TIMESTAMP('2024-08-06 10:00:00'), 'Joe Price', 'hourly', 59, 1, 0, 0),
(9102, 15, 0, 0, 'Knowledge Base Update Completed', 'knowledge-base-update-completed', 'Enhanced documentation improves user support', '<p>The updated knowledge base provides comprehensive documentation for all products.</p><p>Draft revision awaiting review.</p>', UNIX_TIMESTAMP('2024-08-02 13:45:00'), 'Doris Sanders', 'always', 58, 1, 0, 0);
45 changes: 45 additions & 0 deletions Tests/Playwright/news/news-workspace-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect, FrameLocator } from '@playwright/test';
import { loginAsAdmin, openModule } from '../helpers/typo3-backend';
import { resetDatabase, resetUserPreferences } from '../helpers/db-reset';
import { trackConsoleErrors, ConsoleErrorTracker } from '../helpers/console-errors';

// Live uids the fixtures ship a workspace 1 draft version for, in the order the list renders them
const DRAFT_ORIGINAL_UIDS = ['59', '58'];

async function previewUri(contentFrame: FrameLocator, originalUid: string): Promise<string> {
const row = contentFrame.locator(`tr[data-t3ver_oid="${originalUid}"]`);
await expect(row).toHaveCount(1);
return (await row.locator('a[target="_blank"]').first().getAttribute('href')) as string;
}

test.describe('News Workspace Preview', () => {
test.beforeAll(() => { resetDatabase(); resetUserPreferences(); });

let consoleErrors: ConsoleErrorTracker;
test.beforeEach(async ({ page }) => {
// core's workspace-state.js logs this when its in-flight request is aborted by the test teardown
consoleErrors = trackConsoleErrors(page, [/Failed to fetch workspace info/]);
await loginAsAdmin(page);
});
test.afterEach(() => { consoleErrors.assertNoErrors(); });

test('every workspace draft is previewed in the frontend', async ({ page }) => {
const contentFrame = await openModule(page, 'example_news');

for (const originalUid of DRAFT_ORIGINAL_UIDS) {
const uri = await previewUri(contentFrame, originalUid);

expect(uri, `preview URI of draft for record ${originalUid}`).not.toContain('/typo3/workspace/preview-control');
expect(uri, `preview URI of draft for record ${originalUid}`).toContain('ADMCMD_prev=IGNORE');
expect(uri, `preview URI of draft for record ${originalUid}`).toContain('workspaceId=1');
}
});

test('live records keep a plain frontend preview', async ({ page }) => {
const contentFrame = await openModule(page, 'example_news');
const uri = await previewUri(contentFrame, '60');

expect(uri).not.toContain('ADMCMD_prev');
expect(uri).not.toContain('workspaceId');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading