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

View File

@ -2,6 +2,11 @@
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)]
class ContextField
{
@ -13,5 +18,34 @@ class ContextField
public ?string $regexFormat = null,
public ?string $example = null,
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 Ulmus\Entity\EntityInterface;
abstract class Delete implements \Picea\Ui\Method\FormInterface {
use FormTrait;
abstract class Delete extends Form implements \Picea\Ui\Method\FormInterface {
public function validate(FormContextInterface $context) : bool
{
if ( ! $this->entity->isLoaded() ) {
if ( ! $this->getEntity()->isLoaded() ) {
$context->pushMessage($this->message::generateError(
$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\EntityCollection;
trait FormTrait
abstract class Form
{
public function __construct(
protected LanguageHandler $languageHandler,

View File

@ -5,20 +5,38 @@ namespace Lean\Api\Form;
use CSLSJ\Lean\Form\Session\EmailContext;
use Picea\Ui\Method\{ FormContextInterface, };
use Lean\Api\Attribute\ContextField;
use Lean\Api\Exception\MandatoryFieldException;
use Notes\ObjectReflection;
use Ulmus\Attribute\Property\Field;
use Lean\Api\Attribute\EntityField;
use Ulmus\Entity\EntityInterface;
use Ulmus\Entity\Field\Datetime;
abstract class Save implements \Picea\Ui\Method\FormInterface {
use FormTrait;
abstract class Save extends Form implements \Picea\Ui\Method\FormInterface {
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
{
# 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);
}
@ -61,13 +79,30 @@ abstract class Save implements \Picea\Ui\Method\FormInterface {
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
{
$entity = $this->getEntity();
foreach($entity::resolveEntity()->fieldList() as $key => $property) {
$field = $property->getAttribute(Field::class)->object;
if (! $field->readonly || ! $entity->isLoaded()) {
$apiField = $property->getAttribute(EntityField::class)->object ?? null;
if ($apiField) {

View File

@ -74,7 +74,7 @@ trait LeanApiTrait
}
#[Ignore]
protected function searchEntityFromRequest(ServerRequestInterface $request, string $entityType, ? string $resultKey = null) : ServerRequestInterface
protected function searchEntityFromRequest(ServerRequestInterface $request, string $entityType, ? string $resultKey = null) : ServerRequestInterface
{
$search = $entityType::searchRequest()->fromRequest($request);
$entity = $entityType::repository()->filterServerRequest($search)->loadOne() ?? false;

View File

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

View File

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

View File

@ -35,13 +35,13 @@
</div>
<div class="request-response hide">
<hr>
<pre id="response" class="code" ace-theme="ace/theme/cloud9_day"></pre>
<div class="response-head">
<span class="response-code"></span>
<span class="response-message"></span>
</div>
<hr>
<pre id="response" class="code" ace-theme="ace/theme/cloud9_day"></pre>
</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:active {filter:contrast(85%);}
.response-head {display: flex;border:1px solid #9f9f9f;margin-bottom: 5px;}
.response-head .response-code {background:#dfdfdf;padding:0 5px;font-weight: bold;line-height: 29px;font-size: 80%;}
.response-head {display: flex;border:1px solid #ececec;margin-bottom: 5px;border-top:0}
.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%;}
.request-compose {position:relative;}

View File

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