src/Elements/Form.php

Summary

Maintainability
C
1 day
Test Coverage
B
80%
<?php

namespace Galahad\Aire\Elements;

use BadMethodCallException;
use Galahad\Aire\Aire;
use Galahad\Aire\Contracts\BindsToForm;
use Galahad\Aire\Contracts\HasJsonValue;
use Galahad\Aire\Contracts\NonInput;
use Galahad\Aire\Elements\Concerns\CreatesElements;
use Galahad\Aire\Elements\Concerns\CreatesInputTypes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Routing\Router;
use Illuminate\Routing\UrlGenerator;
use Illuminate\Session\Store;
use Illuminate\Support\Arr;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;
use stdClass;

class Form extends \Galahad\Aire\DTD\Form implements NonInput
{
    use CreatesElements, CreatesInputTypes;
    
    /**
     * Data that's bound to the form
     *
     * @var object|\Illuminate\Database\Eloquent\Model|array
     */
    public $bound_data;
    
    /**
     * Forms are validated by default
     *
     * @var bool
     */
    public $validate = true;
    
    /**
     * Validation rules
     *
     * @var array
     */
    public $validation_rules = [];
    
    /**
     * Custom validation messages
     *
     * @var array
     */
    public $validation_messages = [];
    
    /**
     * @inheritdoc
     */
    protected $default_attributes = [
        'action' => '',
        'method' => 'POST',
        'fields' => null,
    ];
    
    /**
     * Forms are not grouped
     *
     * @var bool
     */
    protected $grouped = false;
    
    /**
     * Forms can either be open or closed, which determines how it's rendered
     *
     * @var bool
     */
    protected $opened = false;
    
    /**
     * @var \Galahad\Aire\Elements\Button
     */
    protected $pending_button;
    
    /**
     * @var \Illuminate\Routing\UrlGenerator
     */
    protected $url;
    
    /**
     * @var \Illuminate\Routing\Router
     */
    protected $router;
    
    /**
     * @var \Illuminate\Session\Store
     */
    protected $session_store;
    
    /**
     * Class name of the associated FormRequest object
     *
     * @var string
     */
    protected $form_request;
    
    /**
     * Set to true to load development versions of JS
     *
     * @var bool
     */
    protected $dev_mode = false;
    
    /**
     * If true, we'll set up x-data and x-model attributes for Alpine.js
     * @see https://github.com/alpinejs/alpine
     * 
     * @var bool 
     */
    protected $is_alpine_component = false;
    
    /**
     * We'll store a reference to all the elements created in the form
     * so that if we need to serialize them for Alpine we can. 
     * 
     * @var array 
     */
    protected $json_serializable_elements = [];
    
    /**
     * Called when the form is closed
     * 
     * @var callable
     */
    protected $on_close;
    
    public function __construct(Aire $aire, UrlGenerator $url, Router $router = null, Store $session_store = null)
    {
        parent::__construct($aire);
        
        $this->url = $url;
        $this->router = $router;
        
        if ($session_store) {
            $this->session_store = $session_store;
            $this->view_data['_token'] = $session_store->token();
        }
        
        $this->initValidation();
    }
    
    public function registerElement(Element $element) : self 
    {
        if ($element instanceof HasJsonValue) {
            $this->json_serializable_elements[] = $element;
        }
        
        return $this;
    }
    
    /**
     * Enable dev mode
     *
     * @param bool $dev_mode
     * @return \Galahad\Aire\Elements\Form
     */
    public function dev(bool $dev_mode = true) : self
    {
        $this->dev_mode = $dev_mode;
        
        return $this;
    }
    
    /**
     * Bind data to the form
     *
     * This data will automatically be used to determine an Element's
     * value if a value is not set, and no old input exists
     *
     * @param $bound_data
     * @return \Galahad\Aire\Elements\Form
     */
    public function bind($bound_data) : self
    {
        $this->bound_data = $bound_data;
        
        return $this;
    }
    
    /**
     * Bind data with implicit resource controller routing
     *
     * Form::resourceful(new User()) -> POST route('users.store')
     * Form::resourceful($existing_user) -> PUT route('users.update')
     *
     * @param \Illuminate\Database\Eloquent\Model $model
     * @param string $resource_name
     * @param array $prepend_parameters
     * @return \Galahad\Aire\Elements\Form
     */
    public function resourceful(Model $model, $resource_name = null, $prepend_parameters = []) : self
    {
        $this->bind($model);
        
        if (null === $resource_name) {
            $resource_name = Str::kebab(Str::plural($model->getTable()));
        }
        
        if ($model->exists) {
            $parameters = (array) $prepend_parameters;
            $parameters[] = $model;
            
            $this->action($this->url->route("{$resource_name}.update", $parameters));
            $this->put();
        } else {
            $this->action($this->url->route("{$resource_name}.store", $prepend_parameters));
            $this->post();
        }
        
        return $this;
    }
    
    /**
     * Configure the form for use as an Alpine.js component
     * 
     * @see https://github.com/alpinejs/alpine
     * 
     * @param bool|array $x_data
     * @return $this
     */
    public function asAlpineComponent($x_data = []) : self 
    {
        $this->is_alpine_component = is_array($x_data) || $x_data;
        
        $this->attributes->registerMutator('x-data', function() use ($x_data) {
            if (!$this->isAlpineComponent()) {
                return null;
            }
            
            $data = [];
            
            collect($this->json_serializable_elements)
                ->reject(function(Element $element) {
                    return empty($element->getInputName());
                })
                ->each(function(Element $element) use (&$data) {
                    Arr::set($data, $element->getInputName(), $element->getJsonValue());
                });
            
            return json_encode(array_merge($data, $x_data));
        });
        
        return $this;
    }
    
    /**
     * Determine whether the form is configured as an Alpine.js component
     * 
     * @see https://github.com/alpinejs/alpine
     * 
     * @return bool
     */
    public function isAlpineComponent() : bool 
    {
        return $this->is_alpine_component;
    }
    
    /**
     * Determine whether the form has any bound data
     *
     * @return bool
     */
    public function hasBoundData() : bool
    {
        return null !== $this->bound_data
            or ($this->session_store && $this->session_store->hasOldInput());
    }
    
    /**
     * Get the bound value for an Element
     *
     * @param $name
     * @param null $default
     * @return mixed|null
     */
    public function getBoundValue($name, $default = null)
    {
        if (empty($name)) {
            return value($default);
        }
        
        // If old input is set, use that
        if ($this->session_store && ($old = $this->session_store->getOldInput()) && Arr::has($old, $name)) {
            return Arr::get($old, $name) ?? '';
        }
        
        // If form has bound data, use that
        $bound_data = $this->bound_data instanceof BindsToForm
            ? $this->bound_data->getAireFormData()
            : $this->bound_data;
        
        if ($bound_data) {
            $not_bound = new stdClass();
            
            $bound_value = is_object($bound_data)
                ? object_get($bound_data, $name, $not_bound)
                : Arr::get($bound_data, $name, $not_bound);
            
            if ($bound_value !== $not_bound) {
                return $bound_value;
            }
        }
        
        return value($default);
    }
    
    /**
     * Get any validation errors associated with an Element
     *
     * @param string $name
     * @return array
     */
    public function getErrors(string $name) : array
    {
        if (!$errors = $this->session_store->get('errors')) {
            return [];
        }
        
        if (!$errors instanceof ViewErrorBag) {
            return [];
        }
        
        if (!$errors->has($name)) {
            return [];
        }
        
        return $errors->get($name);
    }
    
    /**
     * Open the form
     *
     * This will start output buffering until the form is closed
     *
     * @return \Galahad\Aire\Elements\Form
     */
    public function open() : self
    {
        ob_start();
        $this->opened = true;
        
        return $this;
    }
    
    /**
     * Close the form
     *
     * This will end output buffering and set all the output to the 'fields'
     * property in the view data
     *
     * @return \Galahad\Aire\Elements\Form
     */
    public function close() : self
    {
        if (!$this->isOpened()) {
            throw new BadMethodCallException('Trying to close a form that hasn\'t been opened.');
        }
        
        $this->view_data['fields'] = new HtmlString(trim(ob_get_clean()));
        $this->opened = false;
        
        if (is_callable($this->on_close)) {
            call_user_func($this->on_close, $this);
        }
        
        return $this;
    }
    
    public function isOpened() : bool
    {
        return true === $this->opened;
    }
    
    public function openButton() : Button
    {
        $this->pending_button = new Button($this->aire, $this);
        
        return $this->pending_button->open();
    }
    
    public function closeButton() : Button
    {
        if (!$this->pending_button) {
            throw new BadMethodCallException('Trying to close a button that hasn\'t been opened.');
        }
        
        $button = $this->pending_button->close();
        
        $this->pending_button = null;
        
        return $button;
    }
    
    /**
     * Set the form's action to a named route
     *
     * @param string $route_name
     * @param mixed $parameters
     * @param bool $absolute
     * @return \Galahad\Aire\Elements\Form
     */
    public function route(string $route_name, $parameters = [], bool $absolute = true) : self
    {
        $action = $this->url->route($route_name, $parameters, $absolute);
        $this->action($action);
        
        $this->inferMethodFromRoute($route_name);
        
        return $this;
    }
    
    public function get() : self
    {
        $this->attributes->set('method', 'GET');
        unset($this->view_data['_method']);
        
        return $this;
    }
    
    public function post() : self
    {
        $this->attributes->set('method', 'POST');
        unset($this->view_data['_method']);
        
        return $this;
    }
    
    public function put() : self
    {
        $this->attributes->set('method', 'POST');
        $this->view_data['_method'] = 'PUT';
        
        return $this;
    }
    
    public function patch() : self
    {
        $this->attributes->set('method', 'POST');
        $this->view_data['_method'] = 'PATCH';
        
        return $this;
    }
    
    public function delete() : self
    {
        $this->attributes->set('method', 'POST');
        $this->view_data['_method'] = 'DELETE';
        
        return $this;
    }
    
    public function method($method = null)
    {
        if (method_exists($this, strtolower($method))) {
            return $this->$method();
        }
        
        return parent::method($method);
    }
    
    public function urlEncoded() : self
    {
        return $this->encType('application/x-www-form-urlencoded');
    }
    
    public function multipart() : self
    {
        return $this->encType('multipart/form-data');
    }
    
    /**
     * Enable client-side validation
     *
     * @param array|string|null $rule_source
     * @param array $custom_messages
     * @return $this
     */
    public function validate($rule_source = null, array $custom_messages = null) : self
    {
        $this->validate = true;
        
        // If we were passed rules, call rules() method
        if (is_array($rule_source)) {
            return $this->rules($rule_source);
        }
        
        // If we were passed a FormRequest class name, call formRequest() method
        if (is_string($rule_source) && is_subclass_of($rule_source, FormRequest::class)) {
            return $this->formRequest($rule_source);
        }
        
        if ($custom_messages) {
            $this->messages($custom_messages);
        }
        
        return $this;
    }
    
    /**
     * Disable client-side validation
     *
     * @return $this
     */
    public function withoutValidation() : self
    {
        $this->validate = false;
        
        return $this;
    }
    
    public function rules(array $rules = []) : self
    {
        $this->validation_rules = $rules;
        
        return $this;
    }
    
    public function messages(array $messages = [], bool $overwrite = false) : self
    {
        if ($overwrite) {
            $this->validation_messages = [];
        }
        
        $this->validation_messages = array_merge($this->validation_messages, $messages);
        
        return $this;
    }
    
    public function formRequest(string $class_name) : self
    {
        $this->form_request = $class_name;
        $request = new $class_name();
        
        if (is_callable([$request, 'rules'])) {
            $this->rules($request->rules());
        }
        
        if (is_callable([$request, 'messages'])) {
            $this->messages($request->messages());
        }
        
        return $this;
    }
    
    public function onClose(callable $callback): self
    {
        $this->on_close = $callback;
        
        return $this;
    }
    
    public function render() : string
    {
        if ($this->isOpened()) {
            return '';
        }
        
        return parent::render();
    }
    
    protected function viewData() : array
    {
        return array_merge(parent::viewData(), $this->validationData());
    }
    
    protected function initGroup() : ?Group
    {
        return null; // Ignore for Form
    }
    
    protected function inferMethodFromRoute($route_name) : void
    {
        if ($this->attributes['method'] !== $this->default_attributes['method']) {
            return;
        }
        
        if (!$this->router) {
            return;
        }
        
        if (!$route = $this->router->getRoutes()->getByName($route_name)) {
            return;
        }
        
        $methods = array_filter($route->methods(), function($method) {
            return 'HEAD' !== $method;
        });
        
        if (!count($methods)) {
            return;
        }
        
        $method = strtolower($methods[0]);
        
        if (in_array($method, ['get', 'post', 'put', 'patch', 'delete'])) {
            $this->$method();
        }
    }
    
    protected function initValidation() : void
    {
        $this->validate = $this->aire->config('validate_by_default', true);
        
        $this->attributes->registerMutator('data-aire-id', function() {
            return $this->validate
                ? $this->element_id
                : null;
        });
    }
    
    protected function validationData() : array
    {
        // TODO: FormRequest
        
        $validation = ($this->validate && (count($this->validation_rules) || null !== $this->form_request))
            ? new ClientValidation($this->aire, $this->element_id, $this->validation_rules, $this->validation_messages, $this->form_request, $this->dev_mode)
            : '';
        
        return compact('validation');
    }
}