Aufbauend zu unserem letzten Tutorial, wo es darum ging, eine Detail- und Listenansicht von Bestellungen in eine Oro-Businessanwendung zu integrieren, wollen wir in diesem Tutorial auf das manuelle Erstellen von Bestellungen eingehen. Ziel soll es sein, neue Bestellungen anzulegen, zu bearbeiten und löschen zu können. Mit anderen Worten, eine vollständige CRUD-Funktionalität bereitzustellen. Wir beschränken uns zunächst auf die Bestellung selbst, ohne Positionen. Diese möchten wir gern in einem der nächsten Teile dieses Tutorials behandeln und dabei näher auf nützliche Features wie DialogWidgets eingehen.
AusgangspunktWir setzen wieder eine installierte und lauffähige Oro-Platform voraus, dabei ist es gleich, ob Plattform oder CRM gewählt wurde, das Bundle lässt sich in beiden Systemen nutzen. Der vollständige Code kann hier herunter geladen werden und muss dann ins src-Verzeichnis der Oro-Platform kopiert werden. Eine genaue Installationsanleitung ist in der README.md Datei im Bundle-Ordner beschrieben.
Neue Bestellung anlegen
Um eine neue Bestellung zu erstellen, benötigen wir als erstes einen EntityManager, der sich um die Verwaltung der Entity „Bestellung“ kümmert:
Acme/OrderBundle/Entity/Manager/OrderManager.php:
namespace Acme\OrderBundle\Entity\Manager; use Doctrine\ORM\EntityManager; class OrderManager { /** * @var EntityManager */ protected $em; /** * @var string */ protected $className; /** * @param $className * @param EntityManager $em */ function __construct($className, EntityManager $em) { $this->em = $em; $this->className = $className; } /** * @return mixed */ public function createEntity() { return new $this->className; } }
Den OrderManager legen wir uns gleich als service in der Datei Acme/OrderBundle/Resources/config/services.yml an, um später einfach darauf zugreifen zu können:
Acme/OrderBundle/Resources/config/services.yml:
parameters: acme_orders.order.entity.class: Acme\OrderBundle\Entity\Order acme_orders.order.manager.class: Acme\OrderBundle\Entity\Manager\OrderManager services: acme_orders.entity.manager.ordermanager: class: %acme_orders.order.manager.class% arguments: [ "%acme_orders.order.entity.class%", @doctrine.orm.entity_manager ]
Wenn das Bundle nicht über den Befehl php app/console generate:bundle angelegt wurde, muss die Datei services.yml noch bekannt gemacht werden, da diese nicht per Konvention geladen wird.Dies geschieht in der Datei
Acme/OrderBundle/DependencyInjection/AcmeOrderExtension.php:
class AcmeOrderExtension extends Extension { /** * {@inheritDoc} */ public function load(array $configs, ContainerBuilder $container) { $configuration = new Configuration(); $this->processConfiguration($configuration, $configs); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services.yml'); } }
FormType - Mapping zwischen Entity und Formular
Für ein Formular benötigen wir zwei Klassen: eine Type- und eine Handler-Klasse. In der Type-Klasse werden die einzelnen Bestandteile des Formulars und deren Darstellungstyp festgelegt. Es können sehr viele Optionen der einzelnen Felder konfiguriert werden, auf die wir auf Grund der Komplexität nicht näher eingehen wollen. Weiterführend dazu ist aber die Dokumentation von Symfony über Formulare zu empfehlen. Ansprechen werden wir an dieser Stelle jedoch Formularelemente, die speziell die Oro-Platform mitbringt.
Die Order aus dem 2. Teil des Tutorials hat einige Felder hinzubekommen, für die wir zuerst eine Type-Klasse anlegen, um die Entity auf ein Formular zu mappen.
Acme/OrderBundle/Form/Type/OrderType.php:
namespace Acme\OrderBundle\Form\Type; use Symfony\Component\OptionsResolver\OptionsResolverInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; class OrderType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add( 'status', 'choice', //eine DropDown-Auswahl wobei der ArrayKey den eigentliche Wert und der Value das Label darstellt array( 'label' => 'acme.orders.order.status.label', 'required' => true, 'choices' => [ 0 => 'eingegangen', 1 => 'in Bearbeitung', 2 => 'ausgeliefert' ] ) ) ->add( 'customer', 'entity', //eine DropDown-Auswahl mit der man eine bestimmte Entity auswählt [ 'label' => 'acme.orders.order.customer.label', 'class' => 'AcmeOrderBundle:Customer', 'required' => true ] ) ->add( 'orderDate', 'oro_datetime', //die Datumsauswahl von Oro wird über jQuerys DateTimePicker realisiert [ 'label' => 'acme.orders.order.date.label', 'data' => new \DateTime('now', new \DateTimeZone('UTC')) // data ist der initiale Wert des Feldes ] ) ->add( 'locale', 'oro_locale', //eine Auswahl von Sprachen [ 'label' => 'acme.orders.order.locale.label', 'data' => 'de_DE' ] ) ->add( 'currency', 'oro_currency', //eine Auswahl von Währungen [ 'label' => 'acme.orders.order.currency.label', 'data' => 'EUR' ] ) ->add( 'totalAmount', 'oro_money', //Betrag wird mit den konfigurierten Locales angezeigt [ 'label' => 'acme.orders.order.total_amount.label', 'divisor' => 100 //um z. B. Beträge in Cent zu speichern kann man hier den Faktor angeben ] ) ->add( 'comment', 'textarea', [ 'label' => 'acme.orders.order.comment.label', ] ) ->add( 'manager', 'oro_user_select', //eine Benutzerauswahl mit Suchfunktion und Template für die Ergebnisse [ 'label' => 'acme.orders.order.account_manager.label', ] ) ; }
Dem FormBuilderInterface werden mittels der Methode add die einzelnen Felder der Entity übergeben. Der erste Parameter ist dabei der Name der Getter- bzw. Setter-Methode der Entity ohne das vorangestellte „get“ bzw. „set“. Der 2. Parameter stellt den Typ des Formularelements dar. Dies können vorhandene, einfache Elemente wie Text oder Datum sein, aber auch komplett eigenständige Formulare, die aus mehreren Feldern bestehen.
Somit hat man die Möglichkeit, Formulare zu verschachteln und eigene komplexe Strukturen, wie z. B. eine Adresseingabe, als eigenen Formulartyp zu definieren. Diesen kann man dann in verschiedenen Formularen wiederverwenden.
Als 3. Parameter kann man zusätzliche Optionen übergeben, die dann in der jeweiligen Klasse das Verhalten beeinflussen. „Label“ ist eine Option, die für alle Formularelemente gilt. Spezielle Typen wie „entity“ erwarten hier die Option „class“, in der der Klassenname stehen muss.
Die Entwickler der Oro-Platform haben vor allem stark an der Usability dieser Felder gearbeitet und bieten z. B. mit dem Typ „oro_user_select“ eine DropDown-Auswahl für User an, die eine Suchfunktion beinhaltet und die Möglichkeit bietet, die Suchergebnisse über ein vorher definiertes Template darzustellen. Durch Verwendung von Ajax kommt das Auswahlelement auch mit größeren Datenmengen zurecht.
Wie man diese Technik für eigene Entities nutzen kann, werden wir in einem der folgenden Tutorials genauer erklären.
Da aus dem Formular direkt eine Entity generiert werden soll, definieren wir in den Standardoption den Parameter „data_class“ und setzen die Klasse „Order“ mit der Angabe des vollen Namespaces:
Acme/OrderBundle/DependencyInjection/AcmeOrderExtension.php:
/** * {@inheritdoc} */ public function setDefaultOptions(OptionsResolverInterface $resolver) { $resolver->setDefaults( array( 'data_class' => 'Acme\OrderBundle\Entity\Order', 'intention' => 'order', 'extra_fields_message' => 'This form should not contain extra fields: "{{ extra_fields }}"', 'cascade_validation' => true ) ); }
„intention“ ist optional und wird verwendet um ein sicheres Token als Schutz vor CSRF-Angriffen zu generieren. Weitere Informationen dazu finden sich unter dem Abschnitt CSRF Protection in der Symfony Dokumentation.
Mit der Option „cascade_validation“ legt man fest, ob die Validierung der Formularelemente auch in verschachtelten Formularen stattfinden soll.
Acme/OrderBundle/Form/Type/OrderType.php:
/** * {@inheritdoc} */ public function getName() { return 'acme_order'; } }
Die Methode „getName“ legt einen eindeutigen Namen fest, unter dem man das Formular später verwenden kann.
Template
Um nun das Formular dem Benutzer anzuzeigen, muss man die Elemente in ein Template einfügen. Das Erzeugen einer neuen Bestellung und das Bearbeiten selbiger, benötigt die gleichen Eingabeelemente. Deshalb geben wir uns mit einem Template für update- und create-Prozesse zufrieden.
Acme/OrderBundle/Resources/views/Order/update.html.twig:
{% extends 'OroUIBundle:actions:update.html.twig' %} {% form_theme form with ['OroFormBundle:Form:fields.html.twig'] %} {% oro_title_set({params : {"%order.no%": form.vars.value.orderNo|default('N/A')} }) %} {% set formAction = form.vars.value.id ? path('acme_orders_order_update', { 'id': form.vars.value.id }) : path('acme_orders_order_create') %} {% block head_script %} {{ parent() }} {% block stylesheets %} {{ form_stylesheet(form) }} {% endblock %} {% endblock %} {% block navButtons %} {{ UI.cancelButton(path('acme_orders_order_index')) }} {% set html = UI.saveAndCloseButton() %} {% if form.vars.value.id or resource_granted('orocrm_account_update') %} {% set html = html ~ UI.saveAndStayButton() %} {% endif %} {{ UI.dropdownSaveButton({'html': html}) }} {% endblock %} {% block pageHeader %} {% if form.vars.value.id %} {% set breadcrumbs = { 'entity': form.vars.value, 'indexPath': path('acme_orders_order_index'), 'indexLabel': 'acme.orders.order.entity_plural_label'|trans, 'entityTitle': form.vars.value.orderNo|default('N/A') } %} {{ parent() }} {% else %} {% set title = 'oro.ui.create_entity'|trans({'%entityName%': 'acme.orders.order.entity_label'|trans}) %} {% include 'OroUIBundle::page_title_block.html.twig' with { title: title } %} {% endif %} {% endblock pageHeader %} {% block stats %}
- {{ 'acme.orders.order.order_date.label'|trans }}: {{ form.vars.value.orderDate ? form.vars.value.orderDate|oro_format_datetime : 'N/A' }}
{% endblock stats %} {% block content_data %} {% set id = 'order' %} {% set dataBlocks = [{ 'title': 'General'|trans, 'class': 'active', 'subblocks': [ { 'title': 'Order Information'|trans, 'data': [ form_row(form.status), form_row(form.orderDate), form_row(form.manager), form_row(form.totalAmount), form_row(form.comment), ] }, { 'title': 'Basic Information'|trans, 'data': [ form_row(form.customer), form_row(form.locale), form_row(form.currency), ] } ] }] %} {% set data = { 'formErrors': form_errors(form)? form_errors(form) : null, 'dataBlocks': dataBlocks, } %} {{ parent() }} {% endblock content_data %}
Die Oro-Platform unterteilt hier das Template in 4 Bereiche:
- Header-Bereich und Konfiguration:
Dieser Block ist nicht sichtbar und dient der Konfiguration und Anpassung des Designs. Es werden Parameter wie der Titel der Seite und die Form-Action festgelegt. Da wir das Template für das Erzeugen und Bearbeiten nutzen, müssen wir hier unterscheiden, ob es sich um eine neue oder bestehende Entity handelt. Im Block „head_script“ können weitere Stylesheets oder JavaScripts geladen werden. - Interaktionselemente:
Der Block „navButtons“ ist für die primären Aktionen reserviert, die auf der entsprechenden Seite erfolgen sollen. In unserem Fall beschränkt sich das vorerst auf das Ausgeben zweier Buttons: „Cancel“ und „Save“. Oro unterscheidet hier nochmal zwischen „Save and Close“, was nach dem Speichern in die View-Ansicht wechselt und „Save“, was den Datensatz nur speichert und in der aktuellen Ansicht verbleibt. - Kopfleiste:
Die Überschrift der Seite und auch gleichzeitig Breadcrumb wird im Block „pageHeader“ definiert. Wer möchte, kann unter „stats“ noch statistische Daten wie ein Erzeugungs- und Aktualisierungsdatum angeben. - Content-Bereich:
Hier kommt der eigentliche Inhalt der Seite, dabei achtet Oro-Platform streng darauf, HTML-Code in entsprechende Macros oder Twig-Funktionen auszulagern, um eine maximale Trennung von Code und Design zu erreichen. Charakteristisch für alle Seiten, ist die Darstellung in DataBlocks. Das sind einfach strukturierte Objekte, die zum Beispiel die Formularelemente enthalten. Die DataBlocks werden dann gerendert und mit Funktionen wie der Scrollbox versehen, um schnell an definierte Stellen der Seite zu springen.
Bestellung erzeugen
Als nächstes müssen wir den Controller um eine createAction erweitern.
Acme/OrderBundle/Controller/OrderController.php:
/** * @Route("/create", name="acme_orders_order_create") * @Template("AcmeOrderBundle:Order:update.html.twig") */ public function createAction() { return $this->update(); } /** * @param Order $entity * @return array */ protected function update(Order $entity = null) { if (!$entity) { $entity = $this->getManager()->createEntity(); } if ($this->get('acme_orders.form.handler.order')->process($entity)) { $this->get('session')->getFlashBag()->add( 'success', $this->get('translator')->trans('acme.orders.order.saved.message') ); return $this->get('oro_ui.router')->redirectAfterSave( ['route' => 'acme_orders_order_update', 'parameters' => ['id' => $entity->getId()]], ['route' => 'acme_orders_order_view', 'parameters' => ['id' => $entity->getId()]], $entity ); } return array( 'entity'=> $entity, 'form' => $this->get('acme_orders.form.order')->createView() ); }
Das Template muss explizit mit der Datei „update.html.twig“ angegeben werden. Für die weitere Verarbeitung sorgt die gemeinsam genutzte update-Methode. Diese kümmert sich um die Erzeugung einer neuen Entity und die Bereitstellung des Formulars an das Template. Der Handler sorgt dann für die korrekte Validierung der Daten und füllt das Formular mit den entsprechenden Werten der Entity.
Sobald das Formular abgeschickt wird, prüft der Handler die eingegebenen Daten und persistiert diese, in unserem Fall, in einer MySQL Datenbank. Nach erfolgreichem Speichern entscheidet der Oro-Router aus dem UIBundle, welche Seite angesprungen wird. Dabei entspricht der erste Parameter der Methode „redirectAfterSave“ einem Klick auf den „Save“-Button und der zweite Parameter einem Klick auf den „Save And Close“-Button.
Bestellung bearbeiten und löschen
Um neu erstellte Bestellungen auch wieder bearbeiten zu können, benötigen wir in unserem Datagrid (Auflistung aller Bestellungen) und bei einer einzelnen Bestellung (in der Detailansicht) einen Button, mit der Möglichkeit den Datensatz wieder zu bearbeiten.
Dazu ändern wir unsere Konfigurationsdatei für das Datagrid wie folgt ab:
Acme/OrderBundle/Resources/config/datagrid.yml:
properties: id: ~ view_link: type: url route: acme_orders_order_view params: [ id ] update_link: type: url route: acme_orders_order_update params: [ id ] actions: view: type: navigate label: Bestellung anzeigen link: view_link icon: info-sign rowAction: true edit: type: navigate label: Bestellung bearbeiten link: update_link icon: edit
Und erhalten dann folgenden Button zum Bearbeiten einer Bestellung:
Die Route verweist im Controller auf die updateAction, welche analog zur createAction, unsere update-Methode aufruft. Mit dem Unterschied, dass wir hier als Parameter die ID des Datensatzes übergeben, woraus Symfony uns automatisch die richtige Entity ermittelt.
Acme/OrderBundle/Controller/OrderController.php:
/** * Update Order form * * @Route("/update/{id}", name="acme_orders_order_update", requirements={"id"="\d+"}) * @Template */ public function updateAction(Order $order) { return $this->update($order); }
Damit Bestellungen auch bequem aus der Einzelansicht bearbeitet und gelöscht werden können, fügen wir im Template noch zwei Buttons mit den entsprechenden Aktionen ein:
Acme/OrderBundle/Controller/OrderController.php:
/** * Update Order form * * @Route("/update/{id}", name="acme_orders_order_update", requirements={"id"="\d+"}) * @Template */ public function updateAction(Order $order) { return $this->update($order); }
Damit Bestellungen auch bequem aus der Einzelansicht bearbeitet und gelöscht werden können, fügen wir im Template noch zwei Buttons mit den entsprechenden Aktionen ein:
Acme/OrderBundle/Resources/views/Order/view.html.twig:
{% block navButtons %} {% if resource_granted('EDIT', entity) %} {{ UI.editButton({ 'path': path('acme_orders_order_update', {'id': entity.id}), 'entity_label': 'acme.orders.order.entity_label'|trans }) }} {% endif %} {% if resource_granted('DELETE', entity) %} {{ UI.deleteButton({ 'dataUrl': path('acme_orders_order_delete', {'id': entity.id}), 'dataRedirect': path('acme_orders_order_index'), 'aCss': 'no-hash remove-button', 'dataId': entity.id, 'id': 'btn-remove-account', 'entity_label': 'acme.orders.order.entity_label'|trans }) }} {% endif %} {% endblock navButtons %}
Hierbei generiert das Twig-Macro „UI.editButton“ einen Button mit einem einfachen Link wo hingegen „UI.deleteButton“ einen Button erzeugt, der die Löschanfrage per Ajax-Call an den Server sendet. Das Handling von Erfolgs- oder Misserfolgsmeldungen sowie eine Sicherheitsabfrage übernimmt die Oro-Platform, sodass man sich nur noch um das Antworten per JSON mit dem korrekten HTTP-Code kümmern muss.
Wir haben der Einfachheit halber auf eine Auslagerung der REST-Funktionen in einen eigenen Controller verzichtet und die Löschfunktionalität im OrderController implementiert:
Acme/OrderBundle/Resources/config/datagrid.yml:
/** * @Route("/delete/{id}", name="acme_orders_order_delete", requirements={"id"="\d+"}) */ public function deleteAction(Order $order) { try { $this->getManager()->deleteEntity($order); } catch (EntityNotFoundException $notFoundEx) { return new JsonResponse(null, Codes::HTTP_NOT_FOUND); } catch (\Exception $e) { return new JsonResponse(['reason' => $e->getMessage()], Codes::HTTP_BAD_REQUEST); } return new JsonResponse(null, Codes::HTTP_NO_CONTENT); }
Um den Controller schlank zu halten, übernimmt das Löschen unser OrderManager. Für spätere Validierungen oder Nacharbeiten wäre das auch der richtige Einstiegspunkt.
Damit haben wir die grundlegenden CRUD-Funktionen angelegt und können unsere Bestellung komfortabel verwalten. Das Wichtigste, die Bestellpositionen, fehlt uns noch, um eine vollständige Bestellung anzulegen. Darum soll sich das nächste Tutorial dieser Reihe drehen und Einblicke geben, wie man mit DialogWidgets ein ansprechendes UserInterface für die Positionsverwaltung erstellen kann.
Download
Projektdatei hier downloaden