www.lowcodeapp.de - Beschleunigung der digitalen Transformation mit Open Source Low-Code Development.

Oro BAP Tutorial OrderManager: Teil 4 – DialogWidgets

Mensch bedient Tastatur zum Entwickeln

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.

ZURÜCK

Kontaktieren Sie uns