Elementos compuestos en formularios
En nuestro post anterior sobre formularios revisamos como usar el paquete
comphppuebla/easyforms En este post desarrollaremos un ejemplo para ilustrar
como podemos combinar un elemento del tipo text
y uno de tipo select
para
crear un nuevo elemento de tipo moneda. Para esto realizaremos 3 tareas:
- Creación de un elemento moneda
- Creación de un filtro para validar elementos del tipo moneda
- Plantilla para mostrar nuestro elemento moneda
Creación de elemento moneda
"If I had a dime for every time I've seen someone use FLOAT to store currency, I'd have $999.997634" -- Bill Karwin
Supongamos que tenemos nuevamente nuestra aplicación de catálogo de productos y que
estamos usando el patrón de diseño de moneda para los precios de los productos
en lugar de usar valores de tipo float
, porque ahora tenemos productos que podemos
comprar y vender en dólares y en pesos.
Nuestro objetivo es crear un formulario que nos permita editar la información de los precios de compra y venta de nuestros productos como se muestra en la siguiente imagen:
Nuestro primer paso es crear un elemento que extienda de EasyForms\Elements\Element
compuesto a su vez por otros dos elementos amount
y currency
del tipo
EasyForms\Elements\Text
y EasyForms\Elements\Select
respectivamente.
use EasyForms\Elements\Element;
use EasyForms\Elements\Select;
use EasyForms\Elements\Text;
class Money extends Element
{
protected $amount;
protected $currency;
public function __construct($name)
{
parent::__construct($name);
$this->amount = new Text("{$name}[amount]");
$this->currency = new Select("{$name}[currency]");
}
}
Los valores de nuestro elemento serán recuperados en forma de un arreglo con las
llaves amount
y currency
.
Al ser un elemento compuesto, debemos modificar la forma en que se manipula su
valor, por lo que tenemos que sobrecargar los métodos setValue
y
value
.
setValue
debe tomar el arreglo que viene de alguna de las superglobales$_GET
o$_POST
y pasar el valor correspondiente llamando al métodosetValue
de los elementosamount
ycurrency
respectivamente.value
, por el contrario, debe recuperar el valor de los elementosamount
ycurrency
llamando al métodovalue
en cada objeto, a fin de devolver el arreglo original que recibió de alguna de las variables superglobales.
// ...
class Money extends Element
{
// ...
public function setValue($value)
{
$this->amount->setValue($value['amount']);
$this->currency->setValue($value['currency']);
}
public function value()
{
return [
'amount' => $this->amount->value(),
'currency' => $this->currency->value(),
];
}
}
Es necesario pasar al elemento select
los tipos de moneda válidos que el
usuario puede elegir. Supongamos que estos valores los recuperamos del catálogo de
productos.
// ...
class Catalog
{
// ...
public function validCurrencies()
{
return ['MXN', 'USD'];
}
}
Filtro para validar un elemento moneda
Vamos a escribir ahora un filtro para validar cualquier elemento de tipo moneda.
Creamos entonces una clase MoneyFilter
que herede de Zend\InputFilter\InputFilter
.
Para el elemento amount
validaremos que se trate de un número entero.
use Zend\InputFilter\Input;
use Zend\InputFilter\InputFilter;
use Zend\Validator\Digits;
use Zend\Validator\NotEmpty;
// ...
class MoneyFilter extends InputFilter
{
// ...
protected function buildAmountInput()
{
$amount = new Input('amount');
$amount
->getValidatorChain()
->attach(new NotEmpty(['type' => NotEmpty::INTEGER]))
->attach(new Digits())
;
return $amount;
}
}
Para el elemento currency
debemos agregar un validador InArray
que verifique
que el valor proporcionado es uno de los valores permitidos por el catálogo de
productos.
use Zend\Validator\InArray;
// ...
class MoneyFilter extends InputFilter
{
// ...
public function buildCurrencyInput(array $validCurrencies)
{
$currency = new Input('currency');
$currency->setContinueIfEmpty(true);
$currency
->getValidatorChain()
->attach(new InArray([
'haystack' => $validCurrencies,
]))
;
$this->add($currency);
}
}
El objetivo del patrón de moneda es no guardar valores flotantes para evitar
problemas de redondeo, es por eso que para validar amount
hemos agregado un
validador del tipo Digits
.
Para que la validación funcione debemos multiplicar el valor introducido por el usuario por 100, si el resultado es un valor entero (contiene solo dígitos), significa que es un valor de moneda válido, ya que contiene solo dos dígitos después del punto decimal, además de que ese valor entero es el que debemos guardar en la base de datos.
// ...
class MoneyFilter extends InputFilter
{
// ...
public function setData($data)
{
$data['original_amount'] = $data['amount'];
$data['amount'] = $data['amount'] * 100;
parent::setData($data);
}
}
En el snippet anterior guardamos el valor original porque debemos mostrarlo en el formulario en el caso de que la validación falle.
Además de guardar el valor original, debemos modificar los métodos getValues
y getMessages
.
getValues
debe recuperar el valor original proporcionado por el usuario, el cual se almacena en$this->data['original_amount']
getMessages
debe agrupar los mensajes de los dos elementos que se están validando, ya que para el usuario final se trata de un único elemento y sus mensajes de error deben mostrarse juntos.
// ...
class MoneyFilter extends InputFilter
{
// ...
public function getValues()
{
$values = parent::getValues();
$values['amount'] = $this->data['original_amount'];
return $values;
}
public function getMessages()
{
$messages = parent::getMessages();
$moneyMessages = [];
if (isset($messages['amount'])) {
$moneyMessages = $messages['amount'];
unset($messages['amount']);
}
if (isset($messages['currency'])) {
$moneyMessages = array_merge($moneyMessages, $messages['currency']);
unset($messages['currency']);
}
$messages[$this->name] = $moneyMessages;
return $messages;
}
}
Filtro para el formulario
Ya que tenemos el nuevo elemento y el filtro que valida elementos de ese tipo podemos crear nuestro formulario y filtro para cambiar los precios de un producto.
Empecemos con el formulario:
use EasyForms\Form;
class ProductPricingForm extends Form
{
public function __construct()
{
$this
->add(new Money('cost_price'))
->add(new Money('sale_price'))
;
}
}
El filtro sería el siguiente:
use Zend\InputFilter\InputFilter;
class ProductPricingFilter extends InputFilter
{
public function __construct()
{
$this
->add(new MoneyFilter('cost_price'), 'cost_price')
->add(new MoneyFilter('sale_price'), 'sale_price')
;
}
}
Tanto al filtro como al formulario necesitamos pasarles los valores válidos
para currency
. Usaremos un objeto de configuración como en nuestro ejemplo
del post anterior.
class ProductPricingConfiguration
{
protected $catalog;
public function __construct(Catalog $catalog)
{
$this->catalog = $catalog;
}
public function getCurrencyChoices()
{
return array_combine(
$this->catalog->validCurrencies(),
$this->catalog->validCurrencies()
);
}
public function getCurrenciesHaystack()
{
return $this->catalog->validCurrencies();
}
}
Agregamos un método configure
tanto al filtro como al formulario.
class ProductPricingFilter extends InputFilter
{
// ...
public function configure(ProductPricingConfiguration $configuration)
{
$this
->get('cost_price')
->buildCurrencyInput($configuration->getCurrenciesHaystack())
;
$this
->get('sale_price')
->buildCurrencyInput($configuration->getCurrenciesHaystack())
;
}
}
class ProductPricingForm extends Form
{
// ...
public function configure(ProductPricingConfiguration $configuration)
{
$this
->get('cost_price')
->setCurrencyChoices($configuration->getCurrencyChoices())
;
$this
->get('sale_price')
->setCurrencyChoices($configuration->getCurrencyChoices())
;
}
}
El controlador
En nuestro controlador tenemos que hacer dos cosas:
- Si la solicitud fue hecha a través del método
GET
recuperamos la información del producto desde nuestra base de datos y llenamos el formulario con esos datos. - Si la solicitud llega a través de
POST
debemos validar la información que nos mandó el usuario si la validación pasa, guardamos los cambios, en caso contrario mostramos los errores en el formulario.
Supongamos que tenemos una entidad producto como la siguiente:
use Money\Money;
class Product
{
protected $productId;
protected $name;
protected $description;
protected $costPrice;
protected $salePrice;
public function __construct(
$productId,
$name,
Money $costPrice,
Money$salePrice,
$description = null
)
{
$this->productId = $productId;
$this->costPrice = $costPrice;
$this->salePrice = $salePrice;
$this->name = $name;
$this->description = $description;
}
public function changePrices(Money $costPrice, Money $salePrice)
{
$this->costPrice = $costPrice;
$this->salePrice = $salePrice;
}
public function information()
{
$information = new ProductInformation();
$information->productId = $this->productId;
$information->name = $this->name;
$information->description = $this->description;
$information->costPrice = $this->costPrice;
$information->salePrice = $this->salePrice;
return $information;
}
}
Nuestro controlador sería algo similar al siguiente, el método editProductPrices
corresponde a una solicitud GET
mientras que updateProductPrices
corresponde
a POST
:
class ChangeProductPrices
{
protected $view
protected $form;
protected $validator;
protected $catalog;
public function __construct(
Twig_Environment $view,
ProductPricingForm $form,
InputFilterValidator $validator,
Catalog $catalog
)
{
$this->view = $view;
$this->form = $form;
$this->validator = $validator;
$this->catalog = $catalog;
}
public function editProductPrices($productId)
{
$product = $this->catalog->productOf($productId);
$this->form->populateFrom($product->information());
return $this->view->render('product/edit-product.html.twig', [
'form' => $this->form->buildView(),
]);
}
public function updateProductPrices(Request $request)
{
$this->form->submit($request->post());
if ($this->validator->validate($this->form)) {
$pricing = $this->form->values()
$costPrice = new Money(
(int) round($pricing['cost_price']['amount'] * 100),
new Currency($pricing['cost_price']['currency'])
);
$salePrice = new Money(
(int) round($pricing['sale_price']['amount'] * 100),
new Currency($pricing['sale_price']['currency'])
);
$product = $this->catalog->productOf($pricing['productId']);
$product->changePrices($costPrice, $salePrice);
$this->catalog->update($product);
$this->redirect('products_list');
}
return $this->view->render('product/edit-product.html.twig', [
'form' => $request->form(),
]);
}
}
Plantilla para el formulario
Lo último que nos falta por resolver es como mostraremos nuestro elemento moneda con Twig. El objetivo es que para la plantilla sea lo más transparente posible.
{{ form_start(form) }}
{{ element_row(form.cost_price, {'label': 'Cost price', 'attr': {'id': 'cost_price'}}) }}
{{ element_row(form.sale_price, {'label': 'Sale price', 'attr': {'id': 'sale_price'}}) }}
{{ form_rest(form) }}
<button type="submit" class="btn btn-default">
<span class="glyphicon glyphicon-usd"></span> Update pricing
</button>
{{ form_end() }}
Para lograrlo debemos sobrecargar el método buildView
de nuestro elemento Money
.
Necesitamos también un MoneyView
que extienda de ElementView
que a su vez contenga
los objetos View
tanto del elemento text
como del elemento select
.
use EasyForms\View\ElementView;
class MoneyView extends ElementView
{
/** @var ElementView */
public $amount;
/** @var SelectView */
public $currency;
}
class Money extends Element
{
// ...
public function buildView(ElementView $view = null)
{
$view = new MoneyView();
$view = parent::buildView($view);
$view->amount = $this->amount->buildView();
$view->currency = $this->currency->buildView();
$view->block = 'money';
return $view;
}
}
Aprovecharemos que podemos definir bloques directamente en la plantilla
del formulario para definir un bloque especial para los elementos del
tipo Money
.
{% extends 'layouts/base.html.twig' %}
{% block title %}/ Update product prices{% endblock %}
{# Use this template to add an inline block #}
{% form_theme [_self] %}
{# Money block #}
{%- block money -%}
<div class="form-inline">
<div class="form-group">
<div class="input-group">
<div class="input-group-addon">$</div>
{# Render the money amount as a text element #}
{%- set options = options|merge({'block': 'input'}) -%}
{%- set attr = attr|merge(element.amount.attributes) -%}
{{- element(element.amount, attr, options) -}}
</div>
{# Render the money currency as a select element #}
{%- set options = options|merge({'block': 'select'}) -%}
{%- set attr = attr|merge(element.currency.attributes) -%}
{{- element(element.currency, attr, options) -}}
</div>
</div>
{%- endblock money -%}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">Update product pricing</div>
<div class="panel-body">
{# The form goes here... #}
</div>
</div>
</div>
</div>
{% endblock %}
Fin
Puedes revisar un ejemplo similar en este repositorio, en el código relacionado
con la ruta /composite-element
, espero que te sea útil. Agradeceré mucho tus
comentarios, dudas, quejas, sugerencias o reclamaciones.