Třída pro vytvářecí a editační formuláře pro YetORM

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
Zax
Člen | 370
+
0
-

Zdravím,

předem se chci omluvit za možné překlepy a chybky, je 6 ráno a na této třídě jsem pracoval přes noc, tak snad mi neutrhnete hlavu :-)

Mám rád Nette\Database, proto používám YetORM. Také se mi líbí Nette\Forms. No a přemýšlel jsem, jak tyto dvě věci zkombinovat a usnadnit si nejčastější problém – tvorbu vytvářecích a editačních formulářů.

Určitě existují hotová řešení pro LeanMapper, Doctrine atd., ale nevím o ničem pro YetORM. Proto jsem se rozhodl napsat malou třídu, která za mě vyřeší rutiny spojené s přiřazováním hodnot z formuláře do YetORM entity a naopak. Tuto třídu poskytuji zdarma pro svobodné užití, s nadějí, že to někomu pomůže. Třída by teoreticky měla jít jednoduše přepsat pro spolupráci s entitami z jiných knihoven – kdyby to někdo chtěl zkoušet, mělo by stačit přepsat metody isEntityEmpty, getEntityValue a setEntityValue.

Popis třídy:
Třída EntityForm chce v konstruktoru YetORM entitu. Druhý parametr je volitelný a chce instanci Nette\Forms\Form nebo potomka, můžete tedy použít vlastní BaseForm.

Poté se s objektem třídy EntityForm pracuje stejně jako s normálním formulářem pomocí add* metod, jediná odlišnost je, že pokud se na začátku názvu uvede hash (#), pak se input namapuje na vlastnost entity se stejným názvem. Pokud jste třídě předali BaseForm, který obsahuje vlastní add* metody (např. addDateTime), pak je samozřejmě můžete také použít.

A nakonec, třída EntityForm disponuje vlastním onSuccess eventem, který kromě instance Form také vrací entitu naplněnou daty z formuláře.

Zpracovaný formulář pro zobrazení v šabloně získáte pomocí metody process().

Ukázka použití (namespace, výjimky, inject modelu atd nechám na vás)

Jednoduchá YetORM entita:

/**
 * @property-read int $id
 * @property string $email
 * @property string $name
 */
class User extends YetORM\Entity {
}

presenter:

/** @var Model\Entity\User */
protected $userEntity = NULL;

// Pro editační formulář entitu načteme z databáze
public function actionEditUser($id) {
    $this->userEntity = $this->userService->getById($id);
}

// Pro vytvářecí formulář vytvoříme novou prázdnou entitu
public function actionCreateUser() {
    $this->userEntity = new Model\Entity\User;
}

// EntityForm má vlastní onSuccess, který kromě Form vrací i naplněnou entitu
public function userFormSubmitted(Form $form, Model\Entity\User $user) {
    $this->userService->persist($user);
}

public function createComponentUserForm() {

    // Vytvoříme EntityForm, předáme mu entitu a form
    // Druhý parametr je nepovinný, pokud se neuvede, vytvoří se instance Nette\Forms\Form
    $entityForm = new Zax\Forms\EntityForm($this->userEntity, new Form);

    // Následně s ním pracujeme jako s klasickým formulářem, rozdíl je akorát v hashi v názvu
    $entityForm->addText('#name', 'Jméno:')
        ->setRequired();
    $entityForm->addText('#email', 'E-mail:')
        ->setRequired()
        ->addRule(Form::EMAIL);

    // Můžeme zjistit, zda je formulář vytvářecí, nebo editační
    $entityForm->addSubmit('userSubmit', ($entityForm->isCreateForm() ? 'Přidat' : 'Upravit') . ' uživatele');

    // onSuccess event
    $entityForm->onSuccess[] = array($this, 'userFormSubmitted');

    // Metoda process vrátí zpracovaný formulář
    return $entityForm->process();
}

Následně vytvoříme dvě šablony (editUser, createUser), do nich napíšeme {control userForm} a máme hotový jak vytvářecí, tak editační formulář!

No a samozřejmě zde je samotný kód třídy EntityForm:

<?php

/** @licence MIT */

namespace Zax\Forms;
use Nette,
    Nette\Forms\Form,
    Nette\ComponentModel\IComponent,
    Nette\Forms\IControl,
    YetORM\Entity;

class EntityForm extends Nette\Object {

    /** @var array */
    public $onSuccess;

    /** @var \YetORM\Entity */
    protected $entity;

    /** @var \Nette\Forms\Form */
    protected $form;

    /** @var array */
    protected $mappedProperties = array();

    public function __construct(Entity $entity, Form $form = NULL) {
        $this->entity = $entity;
        $this->form = ($form === NULL ? new Form : $form);
    }

    /** @return bool */
    protected function isEntityEmpty() {
        try {
            return !isset($this->entity->id);
        } catch (\Exception $e) {
            return true;
        }
    }

    /** @return mixed */
    protected function getEntityValue($name) {
        return $this->entity->$name;
    }

    /**
     * @param string
     * @param mixed
     * @return $this
     */
    protected function setEntityValue($name, $value) {
        $this->entity->$name = $value;
        return $this;
    }

    /** public alias
     *
     * @return bool
     */
    public function isCreateForm() {
        return $this->isEntityEmpty();
    }

    /**
     * @param string
     * @return bool
     */
    protected function shouldBeMapped($name) {
        return is_string($name) && (substr($name, 0, 1) === '#');
    }

    /**
     * @param string
     * @return string
     */
    protected function cleanName($name) {
        return str_replace('#', '', $name);
    }

    /** Fills form with values from entity, adds 'id' field if needed */
    protected function mapEntity() {
        if(!$this->isEntityEmpty()) {
            // set values in form
            foreach($this->mappedProperties as $name) {
                $this->form[$name]->setDefaultValue($this->getEntityValue($name));
            }

            // entity id
            $this->form->addHidden('id')
                ->setDefaultValue($this->getEntityValue('id'));
        }
    }

    /** Maps property and returns clean name
     *
     * @param string
     * @return string
     */
    protected function mapProperty($name) {
        return $this->mappedProperties[] = $this->cleanName($name);
    }

    /** Fills entity with form values and returns it
     *
     * @param array
     * @return \YetORM\Entity
     */
    public function fillEntity($values) {
        $entity = $this->entity;

        foreach($this->mappedProperties as $property) {
            if(isset($values[$property])) {
                $this->setEntityValue($property, $values[$property]);
            }
        }

        return $entity;
    }

    /** return \Nette\Forms\Form */
    public function getForm() {
        return $this->form;
    }

    /** return \Nette\Forms\Form */
    public function process() {
        $this->mapEntity();
        $entityForm = $this;
        foreach($this->onSuccess as $onSuccess) {
            $this->form->onSuccess[] = function(Form $form) use ($entityForm, $onSuccess) {
                call_user_func_array($onSuccess, array($form, $entityForm->fillEntity($form->getValues())));
            };
        }
        return $this->form;
    }

    /** If component is Forms\IControl, then it can be mapped
     *
     * @param \Nette\ComponentModel\IComponent
     * @param string
     * @param string|NULL
     * @return \Nette\Forms\Container
     */
    public function addComponent(IComponent $component, $name, $insertBefore = NULL) {
        if($this->shouldBeMapped($name) && is_a($component, 'Nette\Forms\IControl')) {
            $name = $this->mapProperty($name);
        }
        return $this->form->addComponent($component, $name, $insertBefore);
    }

    /** If add$name() is called, then it's probably a control that can be mapped, else just call form->$name()
     *
     * @param $name
     * @param $args
     * @return mixed
     */
    public function __call($name, $args) {
        if(substr($name, 0, 3) === 'add' && count($args) > 0) {
            $inputName = $args[0];
            if($this->shouldBeMapped($inputName)) {
                $args[0] = $this->mapProperty($inputName);
            }
        }

        return call_user_func_array(array($this->form, $name), $args);
    }

}

Budu rád za jakýkoliv feedback!

Editoval Zax (10. 4. 2014 0:03)

Jan Suchánek
Člen | 404
+
0
-

@Zax: Paráda, YetORM se mi líbí taky a tak vyzkouším! Jdou i kontejnery třeba s vice jazykovejma mutacema?

Zax
Člen | 370
+
0
-

No, přiznám se, že si nejsem jistý, já zas tak hluboko do Nette ještě nepronikl, ale myslím, že by mělo jít vše, co s klasickým Nette formulářem (snad asi kromě magie offsetSet, offsetGet atd.).

Metodu __call jsem se snažil napsat tak, aby se neexistující metoda zavolala na tom formuláři, takže teoreticky můžeš klidně napsat $entityForm->setTranslator($translator) a ovlivní ti to přímo samotný formulář.

Je třeba ale dávat pozor, tu metodu __call jsem psal s myšlenkou, že to beztak budu používat jenom já a mně to stačí na add(Input) metody. Všechny metody, které začínají na add* a jako první parametr přijímají něco jiného než string, spadnou. Prostě mi chybí is_string kontrola, v 6 ráno jsem na to nějak neviděl nebo co :-) . Nevím, kterých všech metod se to týká, pošéfoval jsem akorát addComponent.

Kdyby __call magie nestačila, stačí místo return $entityForm->process(); si hotový (naplněný) formulář prostě vzít do proměnné $processedForm = $entityForm->process();. Kdybys chtěl s formulářem manipulovat během tvorby, můžeš si ho kdykoliv vzít metodou getForm.

Snad to bude k užitku.

Jan Suchánek
Člen | 404
+
0
-

@Zax: No ja spiš myslel něco takového:

		$localeContainer = $form->addContainer("locale");
		foreach($this->model->getPairs("lang") as $code){
			$locale = $localeContainer->addContainer($code);
			$locale->addText("name", 'Název ['.$code.']');
			$locale->addText("name_long", 'Delší název ['.$code.']');
			$locale->addTextarea("description", 'Popis ['.$code.']');
		}

		$locale->setDefaults($values->locale);

Editoval jenicek (8. 4. 2014 13:20)

Zax
Člen | 370
+
0
-

Hmmm tak to bohužel ne. Přiznám se, že addContainer jsem zatím nikdy nepoužil.

Určitě je to dobrý nápad na rozšíření, takže děkuju za skvělou myšlenku, ale nevím, kdy budu schopen to implementovat. Musím ty formuláře nejdřív nastudovat trochu důkladněji.

Zax
Člen | 370
+
0
-

Koukám, že zatím moc velký zájem není, ale to nevadí.

Jenom quick dev update co se bude dít dále:

Plánuji úplně se zbavit závislosti na YetORM. Tohle chce nejvíc práce, budu muset celou třídu prošpikovat nastavitelnými callbacky, aby formulář věděl, jak má přistupovat k vlastnostem entit. Druhů vlastností je samozřejmě celá řada, může jít o normální int/string vlastnost, může jít o cizí klíč, může jít o „belongsToMany“, „hasMany“ atd. a co programátor, to jiný způsob práce s modelem.

Jde mi o to, aby bylo možné si někde (třeba v config.neon) nadefinovat, jak vypadá standardní entita, a formulář si potom dokázal automaticky poradit s jakoukoliv vlastností.

S tím je spojená další věc, kterou plánuji, a to jsou právě ty cizí klíče a spojovací tabulky. Netuším ještě, jak udělám M:N vztahy, ale pro cizí klíč mám pracovně zavedený doublehash (##) a v parametrech je třeba přidat callback, který podle IDčka vytvoří příslušnou entitu, aby ji mohl přidat jako vlastnost.

V praxi to pak bude vypadat zhruba takhle:

public function createComponentNecoZabezpecenehoForm() {
	// Nějak získáme entitu - buď vytvoříme pro vytvářecí form
	// Nebo nějakou už naplněnou pro editační
	//$entity = new Entity;
	//$entity = $this->entity;
	$entityForm = new EntityForm(new Form, $entity);

	$resourceService = $this->resourceService;
	$privilegeService = $this->privilegeService;

	// ...

	// "Secured" zaškrtávátko + toggle
	$entityForm->addCheckbox('#secured', 'Zabezpečit')
		->addConditionOn(Form::EQUAL, 1)
		->toggle('securityOptions');

	$entityForm->addGroup('Možnosti zabezpečení')
		->setOption('container', 'fieldset id=securityOptions');

	// Resource je vyžadován jen pokud je secured zaškrtnuté
	$resources = array_merge(
		array('' => '(Vyberte zdroj)'),
		$resourceService->getPairs('id', 'title') // Vytáhnu seznam resource
	);
	$entityForm->addSelect('##resource', function($id) use ($resourceService) {
		return $resourceService->getById($id);
	}, 'Zdroj:', $resources)
		->addConditionOn($entityForm->getInput('secured'), Form::EQUAL, 1)
			->setRequied();

	// Privilege není vyžadováno - NULL = cokoliv
	$privileges = array_merge(
		array('' => 'Cokoliv'),
		$privilegeService->getPairs('id', 'title')
	);
	$entityForm->addSelect('##privilege', function($id) use ($privilegeService) {
		return $privilegeService->getById($id);
	}, 'Akce:', $privileges);

	// addSubmit, onSuccess atd atd
	return $entityForm->process();
}

Pak v submitu dostaneme entitu, která už rovnou dostane do vlastností resource a privilege získané entity právě díky těm callbackům.

Bylo by o něco takového zájem, nebo si to mám tvořit jen sám pro sebe? :D

Editoval Zax (16. 4. 2014 22:48)

Jan Suchánek
Člen | 404
+
0
-

@Zax: Nebylo by lepší createComponentNecoZabezpecenehoForm() delegovat na komponenty? Nebo je to zbytečný? Já že když obsahuje presenter víc takovýchto obr konstrukcí může být pěkně velkej.

Dále nechápu uplně (Vyberte zdroj), není na to určen:

$form["resource"]->setPrompt('Vyberte zdroj');

Místo těch ## mi přijdou lepší ty form kontejnery, dá se to jasně identifikovat.

$resource = $form->addContainer("resource");
$resource->addSelect("id" …);

Editoval jenicek (2. 6. 2014 22:33)