OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
63
app/Core/Controller/Composer.php
Normal file
63
app/Core/Controller/Composer.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Fluent;
|
||||
use Illuminate\View\View;
|
||||
|
||||
abstract class Composer
|
||||
{
|
||||
/**
|
||||
* List of views to receive data by this composer
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
public static array $views;
|
||||
|
||||
/**
|
||||
* Current view
|
||||
*/
|
||||
protected View $view;
|
||||
|
||||
/**
|
||||
* Current view data
|
||||
*/
|
||||
protected Fluent $data;
|
||||
|
||||
/**
|
||||
* Compose the view before rendering.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function compose(View $view): void
|
||||
{
|
||||
$this->view = $view;
|
||||
$this->data = new Fluent($view->getData());
|
||||
|
||||
if (method_exists($this, 'init')) {
|
||||
app()->call([$this, 'init']);
|
||||
}
|
||||
|
||||
$view->with($this->merge());
|
||||
}
|
||||
|
||||
/**
|
||||
* Data to be merged and passed to the view before rendering.
|
||||
*/
|
||||
protected function merge(): array
|
||||
{
|
||||
return array_merge(
|
||||
$this->view->getData(),
|
||||
$this->with()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data to be passed to view before rendering
|
||||
*/
|
||||
protected function with(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
91
app/Core/Controller/Controller.php
Normal file
91
app/Core/Controller/Controller.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use BadMethodCallException;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Controller Class - Base class For all controllers
|
||||
*/
|
||||
abstract class Controller
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected Response $response;
|
||||
|
||||
/**
|
||||
* constructor - initialize private variables
|
||||
*
|
||||
*
|
||||
* @param IncomingRequest $incomingRequest The request to be initialized.
|
||||
* @param Template $tpl The template to be initialized.
|
||||
* @param Language $language The language to be initialized.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var IncomingRequest */
|
||||
protected IncomingRequest $incomingRequest,
|
||||
|
||||
/** @var Template */
|
||||
protected Template $tpl,
|
||||
|
||||
/** @var Language */
|
||||
protected Language $language,
|
||||
|
||||
) {
|
||||
self::dispatchEvent('begin');
|
||||
|
||||
// initialize
|
||||
if (method_exists($this, 'init')) {
|
||||
app()->call([$this, 'init']);
|
||||
}
|
||||
|
||||
self::dispatchEvent('end', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* getResponse - returns the response
|
||||
*
|
||||
*
|
||||
* @return Response The response object.
|
||||
*/
|
||||
public function getResponse(): Response
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function callAction($method, $parameters)
|
||||
{
|
||||
return $this->{$method}($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle calls to missing methods on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
throw new BadMethodCallException(sprintf(
|
||||
'Method %s::%s does not exist.', static::class, $method
|
||||
));
|
||||
}
|
||||
}
|
||||
471
app/Core/Controller/Frontcontroller.php
Normal file
471
app/Core/Controller/Frontcontroller.php
Normal file
@@ -0,0 +1,471 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Leantime\Core\Auth\Permissions\PermissionEnforcer;
|
||||
use Leantime\Core\Configuration\Environment;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Http\HtmxRequest;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Exception\RouteNotFoundException;
|
||||
|
||||
/**
|
||||
* Frontcontroller class
|
||||
*/
|
||||
class Frontcontroller
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
private IncomingRequest $incomingRequest;
|
||||
|
||||
protected $defaultRoute = 'dashboard.home';
|
||||
|
||||
protected Environment $config;
|
||||
|
||||
/**
|
||||
* __construct - Set the rootpath of the server
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(IncomingRequest $request, private PermissionEnforcer $permissionEnforcer)
|
||||
{
|
||||
$this->incomingRequest = $request;
|
||||
$this->config = app(Environment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* run - executes the action depending on Request or firstAction
|
||||
*
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function dispatch(IncomingRequest $request): Response
|
||||
{
|
||||
$this->incomingRequest = $request;
|
||||
|
||||
[$moduleName, $controllerType, $controllerName, $method] = $this->parseRequestParts($request);
|
||||
|
||||
$this->dispatchEvent('execute_action_start', ['action' => $controllerName, 'module' => $moduleName]);
|
||||
|
||||
$routeParts = $this->getValidControllerCall($moduleName, $controllerName, $method, $controllerType);
|
||||
|
||||
// Setting default response code to 200, can be changed in controller
|
||||
$this->setResponseCode(200);
|
||||
|
||||
$this->dispatchEvent('execute_action_end', ['action' => $controllerName, 'module' => $moduleName]);
|
||||
|
||||
// execute action
|
||||
return $this->executeAction($routeParts['class'], $routeParts['method']);
|
||||
|
||||
}
|
||||
|
||||
public static function dispatch_request(IncomingRequest $request): Response
|
||||
{
|
||||
// Resolve through the container so constructor dependencies (e.g. PermissionEnforcer)
|
||||
// are injected; dispatch() sets the active request explicitly.
|
||||
$frontcontroller = app()->make(self::class);
|
||||
|
||||
return $frontcontroller->dispatch($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* parseRequestParts - Parses the request segments and sets the necessary values in the IncomingRequest object.
|
||||
*
|
||||
* @param IncomingRequest $request The incoming request object.
|
||||
* @return array An array containing the controller name, action name, and method.
|
||||
*/
|
||||
public function parseRequestParts(IncomingRequest $request)
|
||||
{
|
||||
|
||||
$id = null;
|
||||
|
||||
$segments = $request->segments();
|
||||
$method = strtolower($this->incomingRequest->getMethod());
|
||||
|
||||
if (count($segments) == 0) {
|
||||
$segments = explode('.', $this->defaultRoute);
|
||||
}
|
||||
|
||||
// First part is hx tells us this is a htmx controller request
|
||||
$controllerType = 'Controllers';
|
||||
if ($segments[0] == 'hx') {
|
||||
array_shift($segments);
|
||||
$controllerType = 'Hxcontrollers';
|
||||
}
|
||||
|
||||
// If only one segment part was given the url is mean to be an index placeholder
|
||||
if (count($segments) == 1) {
|
||||
$segments[] = 'index';
|
||||
}
|
||||
|
||||
// First segment is always module
|
||||
$moduleName = $segments[0] ?? '';
|
||||
|
||||
// Second is action
|
||||
$controllerName = $segments[1] ?? '';
|
||||
|
||||
// third is either id or method
|
||||
// we can say that a numeric value always represents an id
|
||||
if (isset($segments[2]) &&
|
||||
(is_numeric($segments[2]) || Str::isUuid($segments[2]))
|
||||
) {
|
||||
$id = $segments[2];
|
||||
}
|
||||
|
||||
// If not numeric, it's quite likely this is a method name
|
||||
// But it needs to be double checked.
|
||||
if (isset($segments[2]) &&
|
||||
! (is_numeric($segments[2]) || Str::isUuid($segments[2]))
|
||||
) {
|
||||
$method = $segments[2];
|
||||
}
|
||||
|
||||
// If a third segment is set it is the id
|
||||
if (isset($segments[3])) {
|
||||
$id = $segments[3];
|
||||
$method = $segments[2];
|
||||
$request_parts = implode('.', array_slice($segments, 3));
|
||||
$this->incomingRequest->query->set('request_parts', $request_parts);
|
||||
}
|
||||
|
||||
$this->incomingRequest->query->set('act', $moduleName.'.'.$controllerName.'.'.$method);
|
||||
$this->incomingRequest->setCurrentRoute($moduleName.'.'.$controllerName);
|
||||
|
||||
if ($id === '0' || ! empty($id)) {
|
||||
$this->incomingRequest->query->set('id', $id);
|
||||
}
|
||||
|
||||
// need to update all controllers to stop using global get and post methods.
|
||||
// In the meantime we are setting it again.
|
||||
$this->incomingRequest->overrideGlobals();
|
||||
|
||||
return [$moduleName, $controllerType, $controllerName, $method];
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* executeAction - includes the class in includes/modules by the Request
|
||||
*
|
||||
* @param string $controller actionname.filename
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function executeAction(string $controller, string $method): Response
|
||||
{
|
||||
|
||||
$parameters = $this->incomingRequest->getRequestParams();
|
||||
|
||||
// Enforce #[RequiresPermission] on the resolved action before instantiating the
|
||||
// controller. This is the single chokepoint for every convention-routed controller,
|
||||
// regardless of which base class (if any) it extends.
|
||||
$this->permissionEnforcer->enforce($controller, $method, $parameters);
|
||||
|
||||
$controllerClass = app()->make($controller);
|
||||
|
||||
$response = $controllerClass->callAction($method, $parameters);
|
||||
|
||||
// A controller may return a Symfony Response directly, a Responsable (e.g. an
|
||||
// ImageResponse / JsonRpcResponse — now honored on this legacy dispatch path the same
|
||||
// way Laravel's router and the ExceptionHandler already do), or a string fragment key
|
||||
// handled by the controller's own getResponse().
|
||||
return match (true) {
|
||||
$response instanceof Response => $response,
|
||||
$response instanceof Responsable => $response->toResponse($this->incomingRequest),
|
||||
default => $controllerClass->getResponse($response),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the type of controller based on the incoming request.
|
||||
*
|
||||
* @return string The type of controller. Possible values are 'Controllers' or 'Hxcontrollers'.
|
||||
*/
|
||||
protected function getControllerType(): string
|
||||
{
|
||||
|
||||
$controllerType = 'Controllers';
|
||||
if (
|
||||
($this->incomingRequest instanceof HtmxRequest) &&
|
||||
$this->incomingRequest->header('is-modal') == false &&
|
||||
$this->incomingRequest->header('hx-boosted') == false
|
||||
) {
|
||||
$controllerType = 'Hxcontrollers';
|
||||
}
|
||||
|
||||
return $controllerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the valid controller call based on the module name, action name, and method name.
|
||||
*
|
||||
* @param string $moduleName The name of the module.
|
||||
* @param string $actionName The name of the action.
|
||||
* @param string $methodName The name of the method.
|
||||
* @return array The valid controller call in the form of an associative array. The "class" key represents the class path of the controller,
|
||||
* and the "method" key represents the method name of the controller.
|
||||
*/
|
||||
public function getValidControllerCall(string $moduleName, string $actionName, string $methodName, string $controllerType): array
|
||||
{
|
||||
|
||||
$moduleName = Str::studly($moduleName);
|
||||
$actionName = Str::studly($actionName);
|
||||
$methodNameLower = Str::lower($methodName);
|
||||
$routepath = $moduleName.'.'.$controllerType.'.'.$actionName;
|
||||
$actionPath = $moduleName.'\\'.$controllerType.'\\'.$actionName;
|
||||
|
||||
if ($this->config->debug == false) {
|
||||
$cachedRoute = Cache::store('installation')->get('routes.'.$routepath.'.'.$methodNameLower);
|
||||
|
||||
// Cached routes can outlive a deploy (e.g. a controller's run() replaced by get()/post()).
|
||||
// Only trust the cache if the class and method still exist; otherwise drop it and re-resolve.
|
||||
if (
|
||||
is_array($cachedRoute)
|
||||
&& isset($cachedRoute['class'], $cachedRoute['method'])
|
||||
&& class_exists($cachedRoute['class'])
|
||||
&& method_exists($cachedRoute['class'], $cachedRoute['method'])
|
||||
) {
|
||||
return $cachedRoute;
|
||||
}
|
||||
|
||||
if ($cachedRoute !== null) {
|
||||
Cache::store('installation')->forget('routes.'.$routepath.'.'.$methodNameLower);
|
||||
}
|
||||
}
|
||||
|
||||
$classPath = $this->getClassPath($controllerType, $moduleName, $actionName);
|
||||
|
||||
if ($classPath === false) {
|
||||
throw new NotFoundHttpException("Can't find a valid controller for ".strip_tags($moduleName).'/'.strip_tags($actionName));
|
||||
}
|
||||
|
||||
$classMethod = $this->getValidControllerMethod($classPath, $methodName);
|
||||
|
||||
Cache::store('installation')->set('routes.'.$routepath.'.'.($classMethod == 'run' ? $methodNameLower : $classMethod), ['class' => $classPath, 'method' => $classMethod]);
|
||||
|
||||
return ['class' => $classPath, 'method' => $classMethod];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the class path of a controller based on the provided controller type, module name, and action name.
|
||||
*
|
||||
* @param string $controllerType The type of controller. Possible values are 'Controllers' or 'Hxcontrollers'.
|
||||
**/
|
||||
public function getClassPath(string $controllerType, string $moduleName, string $actionName): string|false
|
||||
{
|
||||
|
||||
$controllerNs = 'Domain';
|
||||
$classname = 'Leantime\\Domain\\'.$moduleName.'\\'.$controllerType.'\\'.$actionName;
|
||||
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
// Check if hxcontroller exists
|
||||
$classname = 'Leantime\\Domain\\'.$moduleName.'\\Hxcontrollers\\'.$actionName;
|
||||
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
$classname = 'Leantime\\Plugins\\'.$moduleName.'\\'.$controllerType.'\\'.$actionName;
|
||||
|
||||
$enabledPlugins = app()->make(\Leantime\Domain\Plugins\Services\Plugins::class)->getEnabledPlugins();
|
||||
|
||||
$pluginEnabled = false;
|
||||
foreach ($enabledPlugins as $key => $obj) {
|
||||
if (strtolower($obj->foldername) !== strtolower($moduleName)) {
|
||||
continue;
|
||||
}
|
||||
$pluginEnabled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $pluginEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
$classname = 'Leantime\\Plugins\\'.$moduleName.'\\Hxcontrollers\\'.$actionName;
|
||||
if (class_exists($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a valid controller method based on the given controller class and method.
|
||||
*
|
||||
* @param string $controllerClass The fully qualified class name of the controller.
|
||||
* @param string $method The method name to check for validity.
|
||||
* @return string The valid controller method name. If the given method is "head",
|
||||
* it will be converted to "get". If the given method exists in the controller
|
||||
* class, it will be returned. Otherwise, if the "run" method exists in the
|
||||
* controller class, it will be returned. If no valid method is found, a
|
||||
* RouteNotFoundException will be thrown.
|
||||
*
|
||||
* @throws RouteNotFoundException If no valid method is found for the given route.
|
||||
*/
|
||||
public function getValidControllerMethod(string $controllerClass, string $method): string
|
||||
{
|
||||
$methodFormatted = Str::camel($method);
|
||||
$httpMethod = Str::lower($this->incomingRequest->getMethod());
|
||||
|
||||
if (Str::lower($method) == 'head') {
|
||||
$method = 'get';
|
||||
}
|
||||
|
||||
// First check if the given method exists.
|
||||
if (method_exists($controllerClass, $methodFormatted)) {
|
||||
|
||||
return $methodFormatted;
|
||||
// Then check if the http method exists as verb
|
||||
} elseif (method_exists($controllerClass, $httpMethod)) {
|
||||
|
||||
// If this was the case our first assumption around $method was wrong and $method is actually a
|
||||
// id/slug. Let's set id to that slug.
|
||||
$this->incomingRequest->query->set('id', $method);
|
||||
|
||||
return $httpMethod;
|
||||
// Just for backwards compatibility, let's also check if run exists.
|
||||
} elseif (method_exists($controllerClass, 'run')) {
|
||||
return 'run';
|
||||
}
|
||||
|
||||
throw new NotFoundHttpException("Can't find valid method for ".strip_tags($method).' in '.strip_tags($controllerClass));
|
||||
}
|
||||
|
||||
/**
|
||||
* getActionName - split string to get actionName
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getActionName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= currentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? currentRoute() : $completeName);
|
||||
|
||||
// If not action name was given, call index controller
|
||||
if (is_array($actionParts) && count($actionParts) == 1) {
|
||||
return 'index';
|
||||
} elseif (is_array($actionParts) && count($actionParts) >= 2) {
|
||||
return $actionParts[1];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the method name based on the complete name of a route.
|
||||
*
|
||||
* @param string|null $completeName The complete name of the route. Defaults to the current route if not provided.
|
||||
* @return string The method name. If the route name consists of two parts (e.g. "controllers.index"), the method name will be the lowercase representation of the current request method
|
||||
*. If the route name consists of three parts (e.g. "controllers.update"), the method name will be the second part of the route name. Otherwise, an empty string is returned.
|
||||
*
|
||||
* @deprecated
|
||||
**/
|
||||
public static function getMethodName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= currentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? currentRoute() : $completeName);
|
||||
|
||||
// If not action name was given, call index controller
|
||||
if (is_array($actionParts) && count($actionParts) == 2) {
|
||||
return strtolower(app('request')->getMethod());
|
||||
} elseif (is_array($actionParts) && count($actionParts) == 3) {
|
||||
return $actionParts[2];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* getModuleName - split string to get modulename
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public static function getModuleName(?string $completeName = null): string
|
||||
{
|
||||
$completeName ??= currentRoute();
|
||||
$actionParts = explode('.', empty($completeName) ? currentRoute() : $completeName);
|
||||
|
||||
if (is_array($actionParts)) {
|
||||
return $actionParts[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect - redirects to a given url
|
||||
*/
|
||||
public static function redirect(string $url, int $http_response_code = 303, $headers = []): RedirectResponse
|
||||
{
|
||||
|
||||
if (app('request')->headers->get('is-modal')) {
|
||||
Frontcontroller::redirectHtmx($url, $headers);
|
||||
}
|
||||
|
||||
return new RedirectResponse(
|
||||
trim(preg_replace('/\s\s+/', '', strip_tags($url))),
|
||||
$http_response_code,
|
||||
$headers
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* redirect - redirects an htmx page.
|
||||
*
|
||||
* @param array $headers
|
||||
*/
|
||||
public static function redirectHtmx(string $url, $headers = []): Response
|
||||
{
|
||||
// modal redirect
|
||||
if (Str::start($url, '#')) {
|
||||
$hxCurrentUrl = app('request')->headers->get('hx-current-url');
|
||||
$mainPageUrl = Str::before($hxCurrentUrl, '#');
|
||||
$url = $mainPageUrl.''.$url;
|
||||
}
|
||||
|
||||
$headers['HX-Redirect'] = $url;
|
||||
|
||||
// $headers["hx-push-url"] = $url;
|
||||
// $headers["hx-replace-url"] = $url;
|
||||
// $headers["HX-Refresh"] = true;
|
||||
|
||||
// this redirect is actually handled on the client side.
|
||||
// We'll just return an empty response with a few headers
|
||||
return new Response(
|
||||
'redirecting...',
|
||||
200, // Anything else than 200 will fail.
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* getCurrentRoute - gets current route
|
||||
*
|
||||
* @deprecated use request class to get current route
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function getCurrentRoute()
|
||||
{
|
||||
return app('request')->getCurrentRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* setResponseCode - sets the response code
|
||||
*/
|
||||
public function setResponseCode(int $responseCode): void
|
||||
{
|
||||
http_response_code($responseCode);
|
||||
}
|
||||
}
|
||||
151
app/Core/Controller/HtmxController.php
Normal file
151
app/Core/Controller/HtmxController.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use BadMethodCallException;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Leantime\Core\Events\DispatchesEvents;
|
||||
use Leantime\Core\Events\Htmx\HtmxEvent;
|
||||
use Leantime\Core\Events\Htmx\HtmxEvents;
|
||||
use Leantime\Core\Http\IncomingRequest;
|
||||
use Leantime\Core\Language;
|
||||
use Leantime\Core\UI\Template;
|
||||
use LogicException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* HtmxController Class - Base class For all htmx controllers
|
||||
*
|
||||
* @method string|null run() The fallback method to be initialized.
|
||||
*/
|
||||
abstract class HtmxController
|
||||
{
|
||||
use DispatchesEvents;
|
||||
|
||||
protected Response $response;
|
||||
|
||||
protected static string $view;
|
||||
|
||||
protected array $headers = [];
|
||||
|
||||
/**
|
||||
* constructor - initialize private variables
|
||||
*
|
||||
* @param IncomingRequest $incomingRequest The request to be initialized.
|
||||
* @param Template $tpl The template to be initialized.
|
||||
*
|
||||
* @throws BindingResolutionException
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var IncomingRequest $incomingRequest */
|
||||
protected IncomingRequest $incomingRequest,
|
||||
|
||||
/** @var Template $tpl */
|
||||
public Template $tpl,
|
||||
|
||||
/** @var Template $tpl */
|
||||
public Language $language,
|
||||
|
||||
) {
|
||||
self::dispatchEvent('begin');
|
||||
|
||||
$this->incomingRequest = $incomingRequest;
|
||||
$this->tpl = $tpl;
|
||||
$this->response = app()->make(Response::class);
|
||||
|
||||
// initialize
|
||||
if (method_exists($this, 'init')) {
|
||||
app()->call([$this, 'init']);
|
||||
}
|
||||
|
||||
if (! property_exists($this, 'view')) {
|
||||
throw new LogicException('HTMX Controllers must include the "$view" static property');
|
||||
}
|
||||
|
||||
self::dispatchEvent('end', $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the response header to trigger an htmx event
|
||||
*
|
||||
**/
|
||||
public function setHTMXEvent(HtmxEvent|string $eventName): void
|
||||
{
|
||||
$this->headers['HX-Trigger'] ??= [];
|
||||
$this->headers['HX-Trigger'][] = $eventName instanceof HtmxEvent ? $eventName->event() : $eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue one or more client (HTMX) events on the HX-Trigger response header.
|
||||
* Accepts HtmxEvent enum cases (preferred) or raw strings.
|
||||
*/
|
||||
public function emit(HtmxEvent|string ...$events): void
|
||||
{
|
||||
foreach ($events as $event) {
|
||||
$this->setHTMXEvent($event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the response
|
||||
*
|
||||
**/
|
||||
public function getResponse($fragment): Response
|
||||
{
|
||||
$this->response = tap(
|
||||
$this->tpl->displayFragment($this::$view, $fragment ?? ''),
|
||||
function (Response $response): void {
|
||||
// Merge queued HX-Trigger events from BOTH the controller and the template
|
||||
// (set()ing each bag separately would let the second silently overwrite the
|
||||
// first), expand legacy aliases, and emit the comma-separated list once.
|
||||
$triggerEvents = array_merge(
|
||||
$this->headers['HX-Trigger'] ?? [],
|
||||
(array) ($this->tpl->getHeaders()['HX-Trigger'] ?? [])
|
||||
);
|
||||
|
||||
foreach ([$this->headers, $this->tpl->getHeaders()] as $headerBag) {
|
||||
foreach ($headerBag as $key => $value) {
|
||||
if ($key === 'HX-Trigger') {
|
||||
continue;
|
||||
}
|
||||
$response->headers->set($key, is_array($value) ? implode(',', $value) : $value);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($triggerEvents)) {
|
||||
$response->headers->set('HX-Trigger', HtmxEvents::triggerHeader($triggerEvents));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function callAction($method, $parameters)
|
||||
{
|
||||
return $this->{$method}($parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle calls to missing methods on the controller.
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
throw new BadMethodCallException(sprintf(
|
||||
'Method %s::%s does not exist.', static::class, $method
|
||||
));
|
||||
}
|
||||
}
|
||||
55
app/Core/Controller/HxComponent.php
Normal file
55
app/Core/Controller/HxComponent.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Leantime\Core\Controller;
|
||||
|
||||
use Leantime\Core\Events\Htmx\HtmxEvent;
|
||||
|
||||
/**
|
||||
* Base class for HTMX-backed components.
|
||||
*
|
||||
* An HxComponent is an {@see HtmxController} that also declares its event contract — the route it
|
||||
* is fetched from, the events that should make it re-fetch ({@see listensTo}), and the events it
|
||||
* emits when its actions mutate data ({@see emits}). The `<x-global::hx :for="...::class">` mount
|
||||
* component reads this contract to auto-wire `hx-get`/`hx-trigger`, so the emit side and the listen
|
||||
* side reference the SAME enum case and can never drift apart (the class of bug where a template
|
||||
* listens for `subtasksUpdated` while the controller emits `subtasks_update`).
|
||||
*
|
||||
* Plain {@see HtmxController}s remain valid; declaring the contract is opt-in. Components that don't
|
||||
* extend this can still be mounted with explicit `endpoint`/`listen` attributes on `<x-global::hx>`.
|
||||
*
|
||||
* @method string|null run() Inherited fallback action.
|
||||
*/
|
||||
abstract class HxComponent extends HtmxController
|
||||
{
|
||||
/** The action invoked when the component is first mounted (its "render me" endpoint). */
|
||||
public static string $mountAction = 'get';
|
||||
|
||||
/** Default `hx-swap` strategy for the mount wrapper. */
|
||||
public static string $swap = 'outerHTML';
|
||||
|
||||
/**
|
||||
* The hx route segment, e.g. "tickets/timerButton" → /hx/tickets/timerButton/{action}.
|
||||
*/
|
||||
abstract public static function route(): string;
|
||||
|
||||
/**
|
||||
* Events that should cause this component to re-fetch itself.
|
||||
*
|
||||
* @return array<int, HtmxEvent>
|
||||
*/
|
||||
public static function listensTo(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Events this component emits when its actions mutate data (contract/documentation; the actual
|
||||
* emission happens via {@see \Leantime\Core\UI\Template::emit()} inside the action methods).
|
||||
*
|
||||
* @return array<int, HtmxEvent>
|
||||
*/
|
||||
public static function emits(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user