- Big WIP on forms component

This commit is contained in:
Dave M. 2025-12-01 15:46:02 +00:00
parent c0b0681fee
commit 3d50cfaccb
14 changed files with 114 additions and 20 deletions

View File

@ -3,6 +3,7 @@
namespace Lean\Api\ApplicationStrategy; namespace Lean\Api\ApplicationStrategy;
use DI\Attribute\Inject; use DI\Attribute\Inject;
use Lean\Api\Middleware\ApiRenderer;
use Lean\Factory\HttpFactoryInterface; use Lean\Factory\HttpFactoryInterface;
use Picea\Picea; use Picea\Picea;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
@ -15,14 +16,14 @@ class NotFoundDecorator extends \Lean\ApplicationStrategy\NotFoundDecorator
{ {
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
if (php_sapi_name() !== 'cli' and ! defined('STDIN')) { if (php_sapi_name() !== 'cli' and ! defined('STDIN') and ApiRenderer::awaitingJson($request)) {
return $this->throw404($request); return $this->throw404api($request);
} }
return parent::process($request, $handler); return parent::process($request, $handler);
} }
public function throw404(ServerRequestInterface $request) : ResponseInterface public function throw404api(ServerRequestInterface $request) : ResponseInterface
{ {
return $this->checkAssetTrigger($request) ?: $this->httpFactory->createJsonResponse([ return $this->checkAssetTrigger($request) ?: $this->httpFactory->createJsonResponse([
'status' => 'error', 'status' => 'error',

View File

@ -2,6 +2,11 @@
namespace Lean\Api\Attribute; namespace Lean\Api\Attribute;
use Lean\Api\Exception\MandatoryFieldException;
use Lean\Api\Exception\MaximumLengthException;
use Lean\Api\Exception\MinimumLengthException;
use Lean\Api\Exception\RegexFormatException;
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)] #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)]
class ContextField class ContextField
{ {
@ -13,5 +18,34 @@ class ContextField
public ?string $regexFormat = null, public ?string $regexFormat = null,
public ?string $example = null, public ?string $example = null,
public bool $hidden = false, public bool $hidden = false,
public mixed $default = false,
) {} ) {}
public function assertValueSpecs(mixed $value, bool $isNew) : void
{
if ($isNew && ( $this->mandatory && $value === null )) {
throw new MandatoryFieldException("Mandatory field must be provided a value.");
}
elseif ($value !== null) {
if ($this->minLength || $this->maxLength) {
if (is_string($value)) {
if (mb_strlen($value) < $this->minLength) {
throw new MinimumLengthException("Minimum {$this->minLength} string length is required.");
}
elseif (mb_strlen($value) > $this->maxLength) {
throw new MaximumLengthException("Maximum {$this->maxLength} string length has been exceeded.");
}
}
}
if ($this->regexFormat) {
if (! is_string($value)) {
throw new RegexFormatException("Property type must be a string if a regexFormat is provided.");
}
elseif (! preg_match($this->regexFormat, $value)) {
throw new RegexFormatException("Regex format {$this->regexFormat} is not valid for value {$value}.");
}
}
}
}
} }

View File

@ -0,0 +1,5 @@
<?php
namespace Lean\Api\Exception;
class MandatoryFieldException extends \InvalidArgumentException {}

View File

@ -0,0 +1,5 @@
<?php
namespace Lean\Api\Exception;
class MaximumLengthException extends \InvalidArgumentException {}

View File

@ -0,0 +1,5 @@
<?php
namespace Lean\Api\Exception;
class MinimumLengthException extends \InvalidArgumentException {}

View File

@ -0,0 +1,5 @@
<?php
namespace Lean\Api\Exception;
class RegexFormatException extends \InvalidArgumentException {}

View File

@ -6,12 +6,11 @@ use CSSLSJ\ExamenFga\Api\{Lib};
use Picea\Ui\Method\{FormContextInterface}; use Picea\Ui\Method\{FormContextInterface};
use Ulmus\Entity\EntityInterface; use Ulmus\Entity\EntityInterface;
abstract class Delete implements \Picea\Ui\Method\FormInterface { abstract class Delete extends Form implements \Picea\Ui\Method\FormInterface {
use FormTrait;
public function validate(FormContextInterface $context) : bool public function validate(FormContextInterface $context) : bool
{ {
if ( ! $this->entity->isLoaded() ) { if ( ! $this->getEntity()->isLoaded() ) {
$context->pushMessage($this->message::generateError( $context->pushMessage($this->message::generateError(
$this->lang('lean.api.form.delete.error.entity') $this->lang('lean.api.form.delete.error.entity')
)); ));

View File

@ -8,7 +8,7 @@ use Picea\Ui\Method\FormContextInterface;
use Ulmus\Entity\EntityInterface; use Ulmus\Entity\EntityInterface;
use Ulmus\EntityCollection; use Ulmus\EntityCollection;
trait FormTrait abstract class Form
{ {
public function __construct( public function __construct(
protected LanguageHandler $languageHandler, protected LanguageHandler $languageHandler,

View File

@ -5,20 +5,38 @@ namespace Lean\Api\Form;
use CSLSJ\Lean\Form\Session\EmailContext; use CSLSJ\Lean\Form\Session\EmailContext;
use Picea\Ui\Method\{ FormContextInterface, }; use Picea\Ui\Method\{ FormContextInterface, };
use Lean\Api\Attribute\ContextField;
use Lean\Api\Exception\MandatoryFieldException;
use Notes\ObjectReflection;
use Ulmus\Attribute\Property\Field; use Ulmus\Attribute\Property\Field;
use Lean\Api\Attribute\EntityField; use Lean\Api\Attribute\EntityField;
use Ulmus\Entity\EntityInterface; use Ulmus\Entity\EntityInterface;
use Ulmus\Entity\Field\Datetime; use Ulmus\Entity\Field\Datetime;
abstract class Save implements \Picea\Ui\Method\FormInterface { abstract class Save extends Form implements \Picea\Ui\Method\FormInterface {
use FormTrait;
protected string $contextClass; protected string $contextClass;
public function initialize(FormContextInterface $context) : void
{
if ( ! $this->getEntity()->isLoaded() || $context->formSent() ) {
if (method_exists($context, 'initializeEntity')) {
$context->initializeEntity($this->getEntity());
}
else {
$this->assignContextToEntity($context);
}
}
parent::initialize($context);
}
public function validate(FormContextInterface $context) : bool public function validate(FormContextInterface $context) : bool
{ {
# Context is validating inputs # Context validates ContextField attributes containing properties on empty entity
$this->validateContextFields($context);
return $context->valid($this->getEntity()->isLoaded() ? $this->getEntity() : null); return $context->valid($this->getEntity()->isLoaded() ? $this->getEntity() : null);
} }
@ -61,13 +79,30 @@ abstract class Save implements \Picea\Ui\Method\FormInterface {
return $saved; return $saved;
} }
protected function validateContextFields(FormContextInterface $context) : void
{
foreach (ObjectReflection::fromClass($context::class)->reflectProperties(\ReflectionProperty::IS_PUBLIC) as $name => $property) {
$attribute = $property->getAttribute(ContextField::class);
if ($attribute) {
try {
$attribute->object->assertValueSpecs($context->$name ?? null, $this->getEntity()->isLoaded());
} catch (MandatoryFieldException $e) {
throw new MandatoryFieldException("An error occured with field '$name': " . $e->getMessage());
}
}
}
}
protected function assignContextToEntity(FormContextInterface $context) : void protected function assignContextToEntity(FormContextInterface $context) : void
{ {
$entity = $this->getEntity(); $entity = $this->getEntity();
foreach($entity::resolveEntity()->fieldList() as $key => $property) { foreach($entity::resolveEntity()->fieldList() as $key => $property) {
$field = $property->getAttribute(Field::class)->object; $field = $property->getAttribute(Field::class)->object;
if (! $field->readonly || ! $entity->isLoaded()) { if (! $field->readonly || ! $entity->isLoaded()) {
$apiField = $property->getAttribute(EntityField::class)->object ?? null; $apiField = $property->getAttribute(EntityField::class)->object ?? null;
if ($apiField) { if ($apiField) {

View File

@ -5,6 +5,7 @@ namespace Lean\Api\Lib;
use Lean\Api\Factory\MessageFactoryInterface; use Lean\Api\Factory\MessageFactoryInterface;
use Lean\LanguageHandler; use Lean\LanguageHandler;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Ulmus\Entity\EntityInterface;
class FormContext extends \Picea\Ui\Method\FormContext { class FormContext extends \Picea\Ui\Method\FormContext {
@ -18,6 +19,8 @@ class FormContext extends \Picea\Ui\Method\FormContext {
parent::__construct($request, $formName); parent::__construct($request, $formName);
} }
# public function initializeEntity(EntityInterface $entity) : void {}
public function lang(string $key, array $variables = []) public function lang(string $key, array $variables = [])
{ {
return $this->languageHandler->languageFromKey($key, $variables); return $this->languageHandler->languageFromKey($key, $variables);

View File

@ -26,7 +26,7 @@ class ApiRenderer implements MiddlewareInterface {
catch(\Throwable $ex) { catch(\Throwable $ex) {
# $errorId = $this->saveError($request, $ex)->id; # $errorId = $this->saveError($request, $ex)->id;
if ($this->awaitingJson($request)) { if (static::awaitingJson($request)) {
return HttpFactory::createJsonResponse([ return HttpFactory::createJsonResponse([
'status' => 'failed', 'status' => 'failed',
'ts' => time(), 'ts' => time(),
@ -80,7 +80,7 @@ class ApiRenderer implements MiddlewareInterface {
return $entity; return $entity;
} }
protected function awaitingJson(ServerRequestInterface $request) : bool public static function awaitingJson(ServerRequestInterface $request) : bool
{ {
return str_contains(strtolower($request->getHeaderLine('content-type')), 'json'); return str_contains(strtolower($request->getHeaderLine('content-type')), 'json');
} }

View File

@ -35,13 +35,13 @@
</div> </div>
<div class="request-response hide"> <div class="request-response hide">
<hr>
<pre id="response" class="code" ace-theme="ace/theme/cloud9_day"></pre>
<div class="response-head"> <div class="response-head">
<span class="response-code"></span> <span class="response-code"></span>
<span class="response-message"></span> <span class="response-message"></span>
</div> </div>
<hr>
<pre id="response" class="code" ace-theme="ace/theme/cloud9_day"></pre>
</div> </div>
</div> </div>
@ -55,8 +55,8 @@
.request-head .url button {font-size: 80%;width: 80px;border-radius:0;border: 1px solid #ccc;height:26px;padding: 0;background:#f9f9f9;color: #6f6f6f;cursor: pointer;} .request-head .url button {font-size: 80%;width: 80px;border-radius:0;border: 1px solid #ccc;height:26px;padding: 0;background:#f9f9f9;color: #6f6f6f;cursor: pointer;}
.request-head .url button:active {filter:contrast(85%);} .request-head .url button:active {filter:contrast(85%);}
.response-head {display: flex;border:1px solid #9f9f9f;margin-bottom: 5px;} .response-head {display: flex;border:1px solid #ececec;margin-bottom: 5px;border-top:0}
.response-head .response-code {background:#dfdfdf;padding:0 5px;font-weight: bold;line-height: 29px;font-size: 80%;} .response-head .response-code {background:#dfdfdf;padding:0 8px;font-weight: bold;line-height: 29px;font-size: 80%;}
.response-head .response-message {padding:0 10px;background:#fbfbfb;line-height: 30px;font-size: 80%;} .response-head .response-message {padding:0 10px;background:#fbfbfb;line-height: 30px;font-size: 80%;}
.request-compose {position:relative;} .request-compose {position:relative;}

View File

@ -10,8 +10,10 @@
<span class="route-link"> <span class="route-link">
<a href="{{ $route['route'] }}" title="{{ $route['path'] }}">{{ $route['cleaned'] }}</a> <a href="{{ $route['route'] }}" title="{{ $route['path'] }}">{{ $route['cleaned'] }}</a>
{% if ! empty($route['description']) %}
<span>-</span> <span>-</span>
<span>{{= $route['description'] }}</span> <span>{{= $route['description'] }}</span>
{% endif %}
</span> </span>
<small class="route-name"> <small class="route-name">