Skip to content

Commit

Permalink
TASK: Improve HTMX integration in workspace UI
Browse files Browse the repository at this point in the history
Use http headers for proper status codes and
transport flash messages
  • Loading branch information
Sebobo committed Jul 18, 2024
1 parent 3a3ea13 commit 6d30803
Show file tree
Hide file tree
Showing 12 changed files with 207 additions and 60 deletions.
24 changes: 5 additions & 19 deletions Neos.Workspace.Ui/Classes/Controller/WorkspaceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
use Neos\Flow\I18n\Exception\InvalidFormatPlaceholderException;
use Neos\Flow\Mvc\ActionRequest;
use Neos\Flow\Mvc\Exception\StopActionException;
use Neos\Flow\Mvc\View\ViewInterface;
use Neos\Flow\Package\PackageManager;
use Neos\Flow\Property\PropertyMapper;
use Neos\Flow\Security\Account;
Expand Down Expand Up @@ -107,18 +106,6 @@ class WorkspaceController extends AbstractModuleController
#[Flow\Inject]
protected WorkspaceProvider $workspaceProvider;

protected function initializeView(ViewInterface $view): void
{
parent::initializeView($view);
// If we're in a HTMX-request...
if ($this->request->getHttpRequest()->hasHeader('HX-Request')) {
// We append an "/htmx" segment to the fusion path, changing it from "<PackageKey>/<ControllerName>/<ActionName>" to "<PackageKey>/<ControllerName>/<ActionName>/htmx"
$htmxFusionPath = str_replace(['\\Controller\\', '\\'], ['\\', '/'], $this->request->getControllerObjectName());
$htmxFusionPath .= '/' . $this->request->getControllerActionName() . '/htmx';
$view->setOption('fusionPath', $htmxFusionPath);
}
}

/**
* Display a list of unpublished content
*/
Expand Down Expand Up @@ -383,11 +370,11 @@ public function deleteAction(WorkspaceName $workspaceName): void
'',
Message::SEVERITY_ERROR
);
$this->redirect('index');
$this->throwStatus(404, 'Workspace does not exist');
}

if ($workspace->isPersonalWorkspace()) {
$this->redirect('index');
$this->throwStatus(403, 'Personal workspaces cannot be deleted');
}

$dependentWorkspaces = $contentRepository->getWorkspaceFinder()
Expand All @@ -408,7 +395,7 @@ public function deleteAction(WorkspaceName $workspaceName): void
'Neos.Workspace.Ui'
) ?: 'workspaces.workspaceCannotBeDeletedBecauseOfDependencies';
$this->addFlashMessage($message, '', Message::SEVERITY_WARNING);
$this->redirect('index');
$this->throwStatus(403, 'Workspace has dependencies');
}

$nodesCount = 0;
Expand All @@ -428,7 +415,7 @@ public function deleteAction(WorkspaceName $workspaceName): void
'Neos.Workspace.Ui'
) ?: 'workspaces.notDeletedErrorWhileFetchingUnpublishedNodes';
$this->addFlashMessage($message, '', Message::SEVERITY_WARNING);
$this->redirect('index');
$this->throwStatus(500, 'Error while fetching unpublished nodes');
}
if ($nodesCount > 0) {
$message = $this->translator->translateById(
Expand All @@ -440,7 +427,7 @@ public function deleteAction(WorkspaceName $workspaceName): void
'Neos.Workspace.Ui'
) ?: 'workspaces.workspaceCannotBeDeletedBecauseOfUnpublishedNodes';
$this->addFlashMessage($message, '', Message::SEVERITY_WARNING);
$this->redirect('index');
$this->throwStatus(403, 'Workspace has unpublished nodes');
}

$contentRepository->handle(
Expand All @@ -457,7 +444,6 @@ public function deleteAction(WorkspaceName $workspaceName): void
'Main',
'Neos.Workspace.Ui'
) ?: 'workspaces.workspaceHasBeenRemoved');
$this->redirect('index');
}

/**
Expand Down
26 changes: 26 additions & 0 deletions Neos.Workspace.Ui/Classes/Mvc/HtmxRequestPattern.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

/*
* This file is part of the Neos.Workspace.Ui 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\Workspace\Ui\Mvc;

use Neos\Flow\Mvc\ActionRequest;
use Neos\Flow\Security\RequestPatternInterface;

final class HtmxRequestPattern implements RequestPatternInterface
{
public function matchRequest(ActionRequest $request): bool
{
return $request->getFormat() === 'htmx';
}
}
48 changes: 48 additions & 0 deletions Neos.Workspace.Ui/Classes/Mvc/HttpHeaderFlashMessageStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

/*
* This file is part of the Neos.Workspace.Ui 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\Workspace\Ui\Mvc;

use Neos\Error\Messages\Message;
use Neos\Flow\Mvc\FlashMessage\FlashMessageContainer;
use Neos\Flow\Mvc\FlashMessage\FlashMessageStorageInterface;
use Psr\Http\Message\ResponseInterface as HttpResponseInterface;
use Psr\Http\Message\ServerRequestInterface as HttpRequestInterface;

final class HttpHeaderFlashMessageStorage implements FlashMessageStorageInterface
{
private FlashMessageContainer|null $flashMessageContainer = null;

public function load(HttpRequestInterface $request): FlashMessageContainer
{
if ($this->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));
}
}
10 changes: 10 additions & 0 deletions Neos.Workspace.Ui/Configuration/Settings.Flow.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Neos:
Flow:
mvc:
flashMessages:
containers:
'httpHeaderFlashMessages':
storage: 'Neos\Workspace\Ui\Mvc\HttpHeaderFlashMessageStorage'
requestPatterns:
'htmx':
pattern: 'Neos\Workspace\Ui\Mvc\HtmxRequestPattern'
5 changes: 3 additions & 2 deletions Neos.Workspace.Ui/Configuration/Settings.Neos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ Neos:
mainStylesheet: 'Lite'
additionalResources:
javaScripts:
'Neos.Workspace.Ui': 'resource://Neos.Workspace.Ui/Public/Scripts/htmx.min.js'
'HtmxLibrary': 'resource://Neos.Workspace.Ui/Public/Scripts/htmx.min.js'
'Module': 'resource://Neos.Workspace.Ui/Public/Scripts/Module.js'
styleSheets:
'Neos.Workspace.Ui': 'resource://Neos.Workspace.Ui/Public/Styles/Module.css'
'Module': 'resource://Neos.Workspace.Ui/Public/Styles/Module.css'

userInterface:
translation:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
prototype(Neos.Workspace.Ui:Component.Indicator) < prototype(Neos.Fusion:Component) {
renderer = afx`
<div id="indicator" class="htmx-indicator loadingIndicator__container">
<div class="loadingIndicator">
<div class="loadingIndicator__bar"></div>
</div>
</div>
`
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ prototype(Neos.Workspace.Ui:Component.Modal.Delete) < prototype(Neos.Fusion:Comp
form.target.action="delete"
form.target.format="htmx"
form.target.arguments.workspaceName={props.workspaceName}
class="neos-inline"
attributes.class="neos-inline"
attributes.hx-post={form.getTarget()}
attributes.hx-target="closest tr"
attributes.hx-swap="delete"
attributes.hx-indicator="#indicator"
attributes.hx-disabled-elt="find button"
attributes.hx-on--after-request={'document.getElementById("' + private.popoverId + '").hidePopover()'}
>
<button
type="submit"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ prototype(Neos.Workspace.Ui:Component.WorkspaceTable) < prototype(Neos.Fusion:Co
workspacesAndChangeCounts = ${{}}

renderer = afx`
<table id="workspaceTable" class="workspace-table">
<table id="workspaceTable" class="workspace-table" hx-swap-oob="true">
<thead>
<tr>
<th></th>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
##
# Empty template for the delete response as the payload is contained in the HTTP headers
#
Neos.Workspace.Ui.WorkspaceController.delete = ''
70 changes: 40 additions & 30 deletions Neos.Workspace.Ui/Resources/Private/Fusion/Views/Index.fusion
Original file line number Diff line number Diff line change
Expand Up @@ -11,35 +11,45 @@ Neos.Workspace.Ui.WorkspaceController.index = Neos.Fusion:Component {
action = 'new'
}

renderer = afx`
<Neos.Workspace.Ui:Component.HTMXConfig/>
<Neos.Workspace.Ui:Component.FlashMessages flashMessages={props.flashMessages}/>
<main class="neos-content neos-container-fluid">
<div class="neos-row-fluid">
<div id="indicator" class="htmx-indicator loadingIndicator__container">
<div class="loadingIndicator">
<div class="loadingIndicator__bar"></div>
</div>
</div>
<Neos.Workspace.Ui:Component.WorkspaceTable
userWorkspace={props.userWorkspace}
workspacesAndChangeCounts={props.workspacesAndChangeCounts}
/>
</div>
</main>
<Neos.Workspace.Ui:Component.Footer>
<a
id="createButton"
type="button"
class="neos-button neos-button-success"
href={props.newAction}
@if={Security.hasAccess('Neos.Workspace.Ui:Backend.CreateWorkspaces')}
renderer = Neos.Fusion:Match {
@subject = ${request.format}
@default = afx`
<section
hx-indicator="#indicator"
hx-boost="true"
>
{I18n.translate('footer.action.create', 'Create new workspace', [], 'Main', 'Neos.Workspace.Ui')}
</a>
<div class="workspace-count">
{I18n.translate('footer.workspaceCount', props.totalNumberOfWorkspaces + ' workspaces (' + props.numberOfPublicWorkspaces + ' public, ' + props.numberOfPrivateWorkspaces + ' private)', [props.numberOfPublicWorkspaces, props.numberOfPrivateWorkspaces], 'Main', 'Neos.Workspace.Ui')}
</div>
</Neos.Workspace.Ui:Component.Footer>
`
<Neos.Workspace.Ui:Component.HTMXConfig/>
<Neos.Workspace.Ui:Component.FlashMessages flashMessages={props.flashMessages}/>
<Neos.Workspace.Ui:Component.Indicator/>
<main class="neos-content neos-container-fluid">
<div class="neos-row-fluid">
<Neos.Workspace.Ui:Component.WorkspaceTable
userWorkspace={props.userWorkspace}
workspacesAndChangeCounts={props.workspacesAndChangeCounts}
/>
</div>
</main>
<Neos.Workspace.Ui:Component.Footer>
<a
id="createButton"
type="button"
class="neos-button neos-button-success"
href={props.newAction}
@if={Security.hasAccess('Neos.Workspace.Ui:Backend.CreateWorkspaces')}
>
{I18n.translate('footer.action.create', 'Create new workspace', [], 'Main', 'Neos.Workspace.Ui')}
</a>
<div class="workspace-count">
{I18n.translate('footer.workspaceCount', props.totalNumberOfWorkspaces + ' workspaces (' + props.numberOfPublicWorkspaces + ' public, ' + props.numberOfPrivateWorkspaces + ' private)', [props.numberOfPublicWorkspaces, props.numberOfPrivateWorkspaces], 'Main', 'Neos.Workspace.Ui')}
</div>
</Neos.Workspace.Ui:Component.Footer>
</section>
`
htmx = afx`
<Neos.Workspace.Ui:Component.WorkspaceTable
userWorkspace={props.userWorkspace}
workspacesAndChangeCounts={props.workspacesAndChangeCounts}
/>
`
}
}

This file was deleted.

58 changes: 58 additions & 0 deletions Neos.Workspace.Ui/Resources/Public/Scripts/Module.js
Original file line number Diff line number Diff line change
@@ -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}`);
});
});

0 comments on commit 6d30803

Please sign in to comment.