Im vierten Teil unseres Tutorials soll die Bestellverwaltung um die Möglichkeit Positionen zu bearbeiten erweitert werden. Um dies komfortabel bedienen zu können, wollen wir Positionen über ein In-Page Pop-Up (in Oro „DialogWidget“ genannt) realisieren. Dabei soll gezeigt werden, welche Funktionen und Methoden die Oro-Platform bereitstellt, um einfach und ohne fundierte JavaScript-Kenntnisse Dialog-Fenster zu erzeugen.
Ausgangspunkt
Wir 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 heruntergeladen 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.
Bestellpositionen anlegen
Zunächst benötigen wir wieder eine Entity, die unsere Bestellpositionen abbildet und alle notwendigen Daten und deren Repräsentation in der Datenbank enthält. Unsere Position soll eine Anzahl, einen Name, eine Beschreibung, einen Preis und die Steuer enthalten. Den Bezug zur Bestellung definieren wir über eine ManyToOne-Beziehung.
Acme/OrderBundle/Entity/OrderPosition.php:
/** * @ORM\Entity * @ORM\Table(name="acme_orderposition") * @Config(routeName="acme_orders_orderposition") */ class OrderPosition { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue(strategy="AUTO") */ protected $id; /** * @ORM\Column(type="integer") */ protected $quantity; /** * @ORM\Column(type="text", length=100) */ protected $name; /** * @ORM\Column(type="text", length=255, nullable=true) */ protected $description; /** * @ORM\Column(type="integer") */ protected $price; /** * @ORM\Column(type="integer") */ protected $tax; /** * @ORM\ManyToOne(targetEntity="\Acme\OrderBundle\Entity\Order", inversedBy="positions") * @ORM\JoinColumn(name="order_id", referencedColumnName="id") */ protected $order; [Getter und Setter] ...
Datagrid der Bestellpositionen
Wir wollen natürlich eine Übersicht der Bestellposition angezeigt bekommen und legen uns dafür ein neues Datagrid an.
Acme/OrderBundle/Resources/config/datagrid.yml:
[...] acme-orders-order-position-grid: source: type: orm query: select: - op - op.tax / 100 as tax from: - { table: Acme\OrderBundle\Entity\OrderPosition, alias: op } where: and: - :orderId = op.order columns: quantity: label: acme.orders.order_position.quantity.label frontend_type: number name: label: acme.orders.order_position.name.label description: label: acme.orders.order_position.description.label price: label: acme.orders.order_position.price_net.label frontend_type: currency tax: label: acme.orders.order_position.tax.label frontend_type: percent priceGross: label: acme.orders.order_position.price.label frontend_type: currency totalGross: label: acme.orders.order_position.final_price.label frontend_type: currency sorters: columns: name: data_name: op.name quantity: data_name: op.quantity price: data_name: op.price default: id: ASC
Die Where-Bedingung schränkt die Ausgabe der Positionen auf die aktuelle Bestellung ein. Damit :orderId mit der korrekten Id gefüllt wird, nutzen wir einen Event Listener. Dieser sorgt dafür, dass Parameter die per Macro im Template an das Datagrid übergeben werden, in der Query mit dem entsprechenden Wert ankommen.
Acme/OrderBundle/Resources/config/services.yml: acme_orders.event_listener.order_position_grid_listener: class: %oro_datagrid.event_listener.base_orm_relation.class% arguments: - 'orderId' - false tags: - { name: kernel.event_listener, event: oro_datagrid.datagrid.build.after.acme-orders-order-position-grid, method: onBuildAfter }
Im Template können wir dann ganz einfach ein Makro nutzen, welches das Datagrid rendert.
Acme/OrderBundle/Resources/views/Order/view.html.twig:
{% set dataBlocks = dataBlocks|merge([{ 'title': 'Order Positions'|trans, 'subblocks': [ { 'data': [ dataGrid.renderGrid('acme-orders-order-position-grid', { 'orderId' : entity.id }) ] } ] }]) %}
Bestellpositionen hinzufügen
Um das ganze jetzt editierbar zu machen, benötigen wir wieder ein Formular für die Eingabe der Positionen. Die Komponenten dafür bestehen analog zur Bestellung aus Tutorial 3 aus Type-Klasse, Handler-Klasse und Template.
Acme/OrderBundle/Form/Type/OrderPositionType.php:
class OrderPositionType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder ->add( 'quantity', 'integer', array( 'label' => 'acme.orders.order_position.quantity.label', 'attr' => array('style' => 'width:100%'), 'required' => true, 'empty_data' => null ) ) ->add( 'name', 'text', [ 'label' => 'acme.orders.order_position.name.label', 'required' => true, 'empty_data' => '' ] ) ->add( 'description', 'textarea', [ 'label' => 'acme.orders.order_position.description.label', 'required' => false, 'max_length' => 255, 'empty_data' => '' ] ) ->add( 'price', 'oro_money', [ 'label' => 'acme.orders.order_position.price.label', 'attr' => array('style' => 'width:100%'), 'required' => true, 'divisor' => 100, 'empty_data' => null ] ) ->add( 'tax', 'choice', [ 'choices' => [0 => '0%', 7 => '7%', 19 => '19%'], 'label' => 'acme.orders.order_position.tax.label', 'required' => true, 'empty_data' => 0 ] ); } /** * {@inheritdoc} */ public function setDefaultOptions(OptionsResolverInterface $resolver) { $resolver->setDefaults( array( 'data_class' => 'Acme\OrderBundle\Entity\OrderPosition', 'intention' => 'order_position', 'extra_fields_message' => 'This form should not contain extra fields: "{{ extra_fields }}"', 'cascade_validation' => true ) ); } /** * {@inheritdoc} */ public function getName() { return 'acme_order_position'; } }
Acme/OrderBundle/Form/Handler/OrderHandler.php:
class OrderHandler { /** * @var FormInterface */ protected $form; /** * @var Request */ protected $request; /** * @var ObjectManager */ protected $manager; /** * @var OrderManager */ protected $orderManager; /** * * @param FormInterface $form * @param Request $request * @param ObjectManager $manager * @param OrderManager $orderManager */ public function __construct(FormInterface $form, Request $request, ObjectManager $manager, OrderManager $orderManager) { $this->form = $form; $this->request = $request; $this->manager = $manager; $this->orderManager = $orderManager; } /** * Process form * * @param Order $entity * @return bool True on successful processing, false otherwise */ public function process(Order $entity) { $this->form->setData($entity); if (in_array($this->request->getMethod(), array('POST', 'PUT'))) { $this->form->submit($this->request); if ($this->form->isValid()) { $entity->setOrderNo($this->orderManager->generateOrderNo()); $this->onSuccess($entity); return true; } } return false; } /** * "Success" form handler * * @param Order $entity */ protected function onSuccess(Order $entity) { $this->manager->persist($entity); $this->manager->flush(); } }
Widget-Button
Unterhalb der Positionsauflistung auf der Bearbeiten-Seite der Bestellung definieren wir einen neuen Button, über den neue Positionen hinzugefügt werden können:
{% set positionDatagrid = 'acme-orders-order-position-grid' %} {% if entity.id %} {% set newPositionBtn %} {% set title = 'acme.orders.order_position.add.label'|trans %} <div class="row"> <div class="pull-right"> {{ UI.clientButton({ 'dataUrl': path('acme_orders_order_position_add', {'id': entity.id }), 'aCss': 'no-hash', 'iCss': 'icon-plus', 'dataId': entity.id, 'title' : title, 'label' : title, 'widget' : { 'type' : 'dialog', 'multiple' : false, 'reload-grid-name' : positionDatagrid, 'options' : { 'alias': 'cart-order-type-dialog', 'stateEnabled': false, 'incrementalPosition': true, 'loadingMaskEnabled': true, 'dialogOptions' : { 'title' : title, 'allowMaximize': true, 'allowMinimize': false, 'width': 870, 'height': 'auto', 'modal': true, 'autoResize': true } } } }) }} </div> </div> {% endset %} {% else %} {% set newPositionBtn = '' %} {% endif %}
Die meisten Optionen sind selbsterklärend, deshalb soll hier nur auf die wichtigsten eingegangen werden:
„dataUrl“ | Die Url wird beim klick auf den Button geladen und damit das Widget geöffnet. |
„reload-grid-name“ | Läd das angegebene Datagrid neu, nachdem das Widget erfolgreich geschlossen wurde. |
„dialogOptions“ | Hier kann direkt Einfluss auf das Aussehen und einige Funktionen des zu erzeugende Widgets genommen werden. |
„stateEnabled“ | Soll der Status des Widgets (Position, Minimiert, Maximiert) in der Datenbank gespeichert werden, um das Widget später wiederherstellen zu können. |
Widget-Template
Da das DialogWidget per Ajax geladen wird, kommen wir ohne viel Boilerplate-Code aus und benötigen nur die Elemente im Template zu definieren, die wir für das Hinzufügen von Positionen benötigen.
Grundsätzlich unterscheiden wir im Template zwei Zustände, die über die Variable „saved“ abgebildet werden. Wenn das Formular abgeschickt, validiert und die Bestellposition in der Datenbank gespeichert wurde, wird per JavaScript eine Flash-Message über den Erfolg ausgegeben und das Widget wird geschlossen.
Das Auslösen eines Triggers ist an der Stelle optional und kann dafür genutzt werden, um nach erfolgreichen Speichern der Entity ein globales Event auszulösen. Man kann sich an anderer Stelle per JavaScript an dieses Event binden.
Zum Beispiel:
<script type="text/javascript"> require(['underscore', 'oroui/js/mediator'], function(_, mediator) { mediator.on('widget_success:mein-widget-alias', function() { //do something... }); } ); </script>
Wenn die Variable „saved“ nicht auf true gesetzt ist soll das Formular für die Bestellposition angezeigt werden. Dafür wird der else-Zweig eingeschlagen und die Formularelemente werden gerendert. Evtl. Fehler bei der Validierung der Eingaben werden durch {{ form_errors(form) }} direkt angezeigt.
Hier ist die div-Klasse wichtig für das Verhalten des Widgets. Elemente, die innerhalb von <div class="widget-actions form-actions"> stehen werden automatisch an den unteren Rand des DialogWidget positioniert und als Aktions-Buttons definiert.
Das Absenden des Formulars wird von ORO per Ajax erledigt, das Widget kümmert sich um das auswerten und anzeigen der Rückgabe.
Acme/OrderBundle/Resources/views/OrderPosition/widget/addOrderPosition.html.twig:
<div class="widget-content orderposition-form"> {% if saved is defined and saved %} <script type="text/javascript"> require(['underscore', 'orotranslation/js/translator', 'oroui/js/widget-manager', 'oroui/js/messenger', 'oroui/js/mediator'], function(_, __, widgetManager, messenger, mediator) { widgetManager.getWidgetInstance({{ app.request.get('_wid')|json_encode|raw }}, function(widget) { messenger.notificationFlashMessage('success', __('acme.orders.order_position_added.message')); mediator.trigger('widget_success:' + widget.getAlias()); mediator.trigger('widget_success:' + widget.getWid()); widget.remove(); }); }); </script> {% else %} {% if not form.vars.valid and form.vars.errors|length %} <div class="alert alert-error"> <div class="message"> {{ form_errors(form) }} </div> </div> {% endif %} <div class="form-container"> <form id="{{ form.vars.name }}" action="{{ path('acme_orders_order_position_add', {'id': entity.order.id }) }}" method="post"> <fieldset class="form form-horizontal"> <div class="span6"> {{ form_row(form.name) }} {{ form_row(form.description) }} </div> <div class="span4"> {{ form_row(form.quantity) }} {{ form_row(form.price) }} {{ form_row(form.tax) }} </div> <div class="span6"> {{ form_rest(form) }} </div> <div class="widget-actions form-actions"> <button class="btn" type="reset">{{ 'Cancel'|trans }}</button> <button class="btn btn-primary" type="submit">{{ 'Add'|trans }}</button> </div> </fieldset> </form> {{ oro_form_js_validation(form) }} </div> {% endif %} </div>
Besonders wichtig ist hier das äußerste div-Element und dessen Klasse, welches den Content kapselt. Beim Laden des DialogWidgets wird explizit nur der Inhalt angezeigt, der im div-Element mit der Klasse „widget-content“ steht, alles andere wird ignoriert.
Mit der Möglichkeit Widgets einfach über die Konfiguration eines entsprechenden Buttons zu laden ist es sehr einfach diese auch an mehreren Stellen auf der Website unterzubringen. Dabei unterscheidet sich das Vorgehen nicht sehr vom Laden einer normalen Website. Der Aufgerufene Controller lädt das Template aus dem gleichen Pfad, wie eine „normal“ aufgerufene Seite mit dem Unterschied, dass die Widget-Templates im Ordner „widget“ gesucht werden.
Aus einem Templatepfad für eine Standardroute „Acme/OrderBundle/Resources/views/Order/update.html.twig“ wird für ein Widget automatisch „Acme/OrderBundle/Resources/views/Order/widget/update.html.twig“ benutzt um das Template zu rendern.
Inline-Widget
Mit der gleichen Technik lassen sich auch Inline-Widgets erstellen. Das sind Code-Blöcke, die man auf der Seite einbinden kann um durch asynchrones Laden lange Ladezeiten zu vermieden.
OroCRM/Bundle/AccountBundle/Resources/views/Account/view.html.twig:
{{ oro_widget_render({ 'widgetType': 'block', 'title': 'orocrm.account.widgets.account_information'|trans, 'url': path('orocrm_account_widget_info', {id: entity.id}) }) }}
Schlusswort
Widgets sind also sehr flexible Elemente, die ohne viel Programmieraufwand erzeugt werden können und sich von der Handhabung nicht viel vom „normalen“ Formular-Workflow unterscheiden. Sie stellen aber ein effektives Werkzeug dar, dass eine schnelle Interaktion mit dem Benutzer erlaubt.