Important

You are browsing the documentation for version 4.1 of OroCommerce, OroCRM and OroPlatform, which is no longer maintained. Read version 5.1 (the latest LTS version) of the Oro documentation to get up-to-date information.

See our Release Process documentation for more information on the currently supported and upcoming releases.

Customize CRUD Pages

OroCommerce equips users and developers with powerful UIs that they can use to manage both simple and complex data entities, including all entity attributes (fields) and relations. As a developer, you can easily enable standard CRUD pages for a new entity, and with the same ease, you can add more fields to any of the entities that you have created before. Just add new entity properties, create a migration script and modify the templates if necessary.

But what if you need to add a few more fields to one of the OroCommerce built-in entities, or to an entity that has been created by somebody else’s extension? Where would you start?

Editing the OroCommerce source code or the code for third party extensions is never a good idea. In this article, we will show you how to customize the CRUD pages of the existing entities with the custom code in your own bundle.

Note

CRUD stands for Create, Read, Update and Delete operations. They are commonly accompanied by some sort of listing or navigation that allows to retrieve, sort and filter multiple records at once. In the context of OroCommerce the data management UIs for the above operations are represented by the following pages:

  • List – represented by the data grids (for example, select Products → Products in the main navigation menu). For more information on customization of the data grids, see the Customizing Data Grids in OroCommerce section.

  • Create – an entity creation screen (for example, go to Products → Products and click on the Create Product button above the product data grid). In most cases, the entity creation screen and entity editing screen look and work exactly the same, though there may be exceptions to this rule.

  • Read – an entity view page (for example, go to Products → Products and click on any product in the grid).

  • Update – an entity editing page (for example, go to Products → Products and select Edit in the action column, or click on the Edit button on the product view page).

  • Delete – there is no special screen for entity deletion other than the confirmation popup window (go to Products → Products select Delete in the action column, or click on the Delete button on the product view or edit page).

For the purpose of the today’s exercise, we will be adding a new text field to the product edit and view screens from our custom bundle.

Prerequisites

Before we start writing code, you should create a new bundle in your application. If you are not familiar with bundle creation process yet, please check the article about how to create a new bundle in OroPlatform, OroCRM, or OroCommerce. If you have already created a bundle for your app customizations, you are good to go and may reuse it in other tutorials as well.

Custom Data Entity

As the first step we will create a new entity to store our custom data. It is still possible to create new product entity fields from your custom bundle, but we will show how you can add some data that is stored elsewhere (it may as well be calculated on the fly or submitted to an external web-service for storage).

Note

Please check the How to Create Entities article to learn more.

Let’s also make our entity compliant with the ProductHolderInterface, so it will be possible to reuse it in other places (e.g. reports). Other than the reference to the product, our entity will have only one text field to store our data. You can add multiple fields and use other data types according to your requirements.

This is how our custom entity will look like:

src/Oro/Bundle/BlogPostExampleBundle/Entity/ProductOptions.php
 1<?php
 2
 3namespace Oro\Bundle\BlogPostExampleBundle\Entity;
 4
 5use Doctrine\ORM\Mapping as ORM;
 6use Oro\Bundle\ProductBundle\Entity\Product;
 7use Oro\Bundle\ProductBundle\Model\ProductHolderInterface;
 8
 9/**
10 * @ORM\Table(name="oro_bpe_prod_opts")
11 * @ORM\Entity
12 */
13class ProductOptions implements ProductHolderInterface
14{
15    /**
16     * @var integer
17     *
18     * @ORM\Id
19     * @ORM\Column(type="integer")
20     * @ORM\GeneratedValue(strategy="AUTO")
21     */
22    protected $id;
23
24    /**
25     * @var Product
26     *
27     * @ORM\ManyToOne(targetEntity="Oro\Bundle\ProductBundle\Entity\Product")
28     * @ORM\JoinColumn(name="product_id", referencedColumnName="id", nullable=false, onDelete="CASCADE")
29     */
30    protected $product;
31
32    /**
33     * @var string
34     *
35     * @ORM\Column(name="value", type="text")
36     */
37    protected $value;
38
39    // ..... Getters & Setters implementations .....
40}

Installation And Migrations

It might be not necessary for this exercise, but if you plan to distribute your custom bundle, or if you want to deploy it later to another application or machine, you have to create the installation and migration scripts. The installation script should create the required database structures during application installation, and the migration scripts will be used to update your module in the application to a specific version.

Note

More information about migrations is available in the OroMigrationBundle documentation.

We are going to have only one version of our custom bundle in this blog post, so the installation and migration code will look very similar.

Installation:

src/Oro/Bundle/BlogPostExampleBundle/Migrations/Schema/OroBlogPostExampleBundleInstaller.php
 1<?php
 2
 3namespace Oro\Bundle\BlogPostExampleBundle\Migrations\Schema;
 4
 5use Doctrine\DBAL\Schema\Schema;
 6use Oro\Bundle\MigrationBundle\Migration\Installation;
 7use Oro\Bundle\MigrationBundle\Migration\QueryBag;
 8
 9class OroBlogPostExampleBundleInstaller implements Installation
10{
11    /**
12     * {@inheritdoc}
13     */
14    public function getMigrationVersion()
15    {
16        return 'v1_0';
17    }
18
19    /**
20     * {@inheritdoc}
21     */
22    public function up(Schema $schema, QueryBag $queries)
23    {
24        /** Tables generation **/
25        $this->createOroBpeProdOptsTable($schema);
26
27        /** Foreign keys generation **/
28        $this->addOroBpeProdOptsForeignKeys($schema);
29    }
30
31    /**
32     * Create oro_bpe_prod_opts table
33     *
34     * @param Schema $schema
35     */
36    protected function createOroBpeProdOptsTable(Schema $schema)
37    {
38        $table = $schema->createTable('oro_bpe_prod_opts');
39        $table->addColumn('id', 'integer', ['autoincrement' => true]);
40        $table->addColumn('product_id', 'integer', []);
41        $table->addColumn('value', 'text', []);
42        $table->setPrimaryKey(['id']);
43        $table->addIndex(['product_id']);
44    }
45
46    /**
47     * Add oro_bpe_prod_opts foreign keys.
48     *
49     * @param Schema $schema
50     */
51    protected function addOroBpeProdOptsForeignKeys(Schema $schema)
52    {
53        $table = $schema->getTable('oro_bpe_prod_opts');
54        $table->addForeignKeyConstraint(
55            $schema->getTable('oro_product'),
56            ['product_id'],
57            ['id'],
58            ['onDelete' => 'CASCADE', 'onUpdate' => null]
59        );
60    }
61}

Migration:

src/Oro/Bundle/BlogPostExampleBundle/Migrations/Schema/v1_0/OroBlogPostExampleBundle.php
 1<?php
 2
 3namespace Oro\Bundle\BlogPostExampleBundle\Migrations\Schema\v1_0;
 4
 5use Doctrine\DBAL\Schema\Schema;
 6use Oro\Bundle\MigrationBundle\Migration\Migration;
 7use Oro\Bundle\MigrationBundle\Migration\QueryBag;
 8
 9class OroBlogPostExampleBundle implements Migration
10{
11    /**
12     * {@inheritdoc}
13     */
14    public function up(Schema $schema, QueryBag $queries)
15    {
16        /** Tables generation **/
17        $this->createOroBpeProdOptsTable($schema);
18
19        /** Foreign keys generation **/
20        $this->addOroBpeProdOptsForeignKeys($schema);
21    }
22
23    /**
24     * Create oro_bpe_prod_opts table
25     *
26     * @param Schema $schema
27     */
28    protected function createOroBpeProdOptsTable(Schema $schema)
29    {
30        $table = $schema->createTable('oro_bpe_prod_opts');
31        $table->addColumn('id', 'integer', ['autoincrement' => true]);
32        $table->addColumn('product_id', 'integer', []);
33        $table->addColumn('value', 'text', []);
34        $table->setPrimaryKey(['id']);
35        $table->addIndex(['product_id']);
36    }
37
38    /**
39     * Add oro_bpe_prod_opts foreign keys.
40     *
41     * @param Schema $schema
42     */
43    protected function addOroBpeProdOptsForeignKeys(Schema $schema)
44    {
45        $table = $schema->getTable('oro_bpe_prod_opts');
46        $table->addForeignKeyConstraint(
47            $schema->getTable('oro_product'),
48            ['product_id'],
49            ['id'],
50            ['onDelete' => 'CASCADE', 'onUpdate' => null]
51        );
52    }
53}

Form Types

In order to customize the new product field, we need to implement a corresponding form type that will be used in the main form on the product create and edit pages:

src/Oro/Bundle/BlogPostExampleBundle/Form/Type/ProductOptionsType.php
 1<?php
 2
 3namespace Oro\Bundle\BlogPostExampleBundle\Form\Type;
 4
 5use Oro\Bundle\BlogPostExampleBundle\Entity\ProductOptions;
 6use Symfony\Component\Form\AbstractType;
 7use Symfony\Component\Form\FormBuilderInterface;
 8use Symfony\Component\OptionsResolver\OptionsResolver;
 9
10class ProductOptionsType extends AbstractType
11{
12    const BLOCK_PREFIX = 'oro_blogpostexample_product_options';
13
14
15    /**
16     * {@inheritdoc}
17     */
18    public function buildForm(FormBuilderInterface $builder, array $options)
19    {
20        $builder->add('value');
21    }
22
23    /**
24     * {@inheritdoc}
25     */
26    public function configureOptions(OptionsResolver $resolver)
27    {
28        $resolver->setDefaults(
29            [
30                'data_class' => ProductOptions::class
31            ]
32        );
33    }
34
35    /**
36     * {@inheritdoc}
37     */
38    public function getBlockPrefix()
39    {
40        return self::BLOCK_PREFIX;
41    }
42}

The setDataClass method is used here to provide more flexibility while allowing for the re-use of this form type. Using it like this is optional.

Once you have your new form type, it should be registered in the service container to be recognizable by the Symfony’s form factory:

src/Oro/Bundle/BlogPostExampleBundle/Resources/config/form_types.yml
1services:
2    oro_blogpostexample.form.type.product_options:
3        class: Oro\Bundle\BlogPostExampleBundle\Form\Type\ProductOptionsType
4        tags:
5            - { name: form.type }

Form Type Extension

Any integrations between different form types within OroCommerce can use form type extension to tie in the form types together. In our case, we need to list the following form events:

  • FormEvents::POST_SET_DATA – it will be used to assign values to the form from our custom entity object;

  • FormEvents::POST_SUBMIT – it will be used to convert, validate and persist our custom values.

src/Oro/Bundle/BlogPostExampleBundle/Form/Extension/ProductOptionsFormTypeExtension.php
  1<?php
  2
  3namespace Oro\Bundle\BlogPostExampleBundle\Form\Extension;
  4
  5use Doctrine\Common\Persistence\ManagerRegistry;
  6use Doctrine\Common\Persistence\ObjectManager;
  7use Doctrine\Common\Persistence\ObjectRepository;
  8use Oro\Bundle\BlogPostExampleBundle\Entity\ProductOptions;
  9use Oro\Bundle\BlogPostExampleBundle\Form\Type\ProductOptionsType;
 10use Oro\Bundle\ProductBundle\Entity\Product;
 11use Oro\Bundle\ProductBundle\Form\Type\ProductType;
 12use Symfony\Component\Form\AbstractTypeExtension;
 13use Symfony\Component\Form\FormBuilderInterface;
 14use Symfony\Component\Form\FormEvent;
 15use Symfony\Component\Form\FormEvents;
 16
 17class ProductOptionsFormTypeExtension extends AbstractTypeExtension
 18{
 19    const PRODUCT_OPTIONS_FIELD_NAME = 'productOptions';
 20
 21    /** @var ManagerRegistry */
 22    protected $registry;
 23
 24    /**
 25     * @param ManagerRegistry $registry
 26     */
 27    public function __construct(ManagerRegistry $registry)
 28    {
 29        $this->registry = $registry;
 30    }
 31
 32    /**
 33     * {@inheritdoc}
 34     */
 35    public function getExtendedType()
 36    {
 37        return ProductType::class;
 38    }
 39
 40    /**
 41     * {@inheritdoc}
 42     */
 43    public function buildForm(FormBuilderInterface $builder, array $options)
 44    {
 45        $builder->add(
 46            self::PRODUCT_OPTIONS_FIELD_NAME,
 47            ProductOptionsType::class,
 48            [
 49                'label' => 'oro.blogpostexample.product_options.entity_label',
 50                'required' => false,
 51                'mapped' => false,
 52            ]
 53        );
 54
 55        $builder->addEventListener(FormEvents::POST_SET_DATA, [$this, 'onPostSetData']);
 56        $builder->addEventListener(FormEvents::POST_SUBMIT, [$this, 'onPostSubmit'], 10);
 57    }
 58
 59    /**
 60     * @param FormEvent $event
 61     */
 62    public function onPostSetData(FormEvent $event)
 63    {
 64        /** @var Product|null $product */
 65        $product = $event->getData();
 66        if (!$product || !$product->getId()) {
 67            return;
 68        }
 69
 70        $options = $this->getProductOptionsRepository()
 71            ->findOneBy(['product' => $product]);
 72
 73        $event->getForm()->get(self::PRODUCT_OPTIONS_FIELD_NAME)->setData($options);
 74    }
 75
 76    /**
 77     * @param FormEvent $event
 78     */
 79    public function onPostSubmit(FormEvent $event)
 80    {
 81        /** @var Product|null $product */
 82        $product = $event->getData();
 83        if (!$product) {
 84            return;
 85        }
 86
 87        /** @var ProductOptionsType $form */
 88        $form = $event->getForm();
 89
 90        /** @var ProductOptions $options */
 91        $options = $form->get(self::PRODUCT_OPTIONS_FIELD_NAME)->getData();
 92        $options->setProduct($product);
 93
 94        if (!$form->isValid()) {
 95            return;
 96        }
 97
 98        $this->getProductOptionsEntityManager()->persist($options);
 99    }
100
101    /**
102     * @return ObjectManager
103     */
104    protected function getProductOptionsEntityManager()
105    {
106        return $this->registry->getManagerForClass(ProductOptions::class);
107    }
108
109    /**
110     * @return ObjectRepository
111     */
112    protected function getProductOptionsRepository()
113    {
114        return $this->getProductOptionsEntityManager()
115            ->getRepository(ProductOptions::class);
116    }
117}

Our new form type extension should also be registered in the service container:

src/Oro/Bundle/BlogPostExampleBundle/Resources/config/form_types.yml
1services:
2    oro_blogpostexample.form.extension.product_type:
3        class: Oro\Bundle\BlogPostExampleBundle\Form\Extension\ProductOptionsFormTypeExtension
4        public: true
5        arguments:
6            - "@doctrine"
7        tags:
8            - { name: form.type_extension, extended_type: Oro\Bundle\ProductBundle\Form\Type\ProductType }

UI Data Targets and Listener

Once the entity, the form type, and the form type extension are created, we can start customizing the User Interface.

Note

Additional information about the UI customization is available in the Layout section.

In our case, the custom data should be added to the product view page and the product edit/create pages, so we will use the following dataTargets:

  • product-view will be used to display our custom data on the product view page;

  • product-edit will be used to show our custom data on the product edit page;

  • product-create-step-two will be used to add our custom data to the product creation page.

src/Oro/Bundle/BlogPostExampleBundle/Resources/config/services.yml
 1services:
 2    oro_blogpostexample.event_listener.form_view.product:
 3        class: Oro\Bundle\BlogPostExampleBundle\EventListener\ProductFormListener
 4        arguments:
 5            - '@translator'
 6            - '@oro_entity.doctrine_helper'
 7            - '@request_stack'
 8        tags:
 9            - { name: kernel.event_listener, event: oro_ui.scroll_data.before.product-view, method: onProductView }
10            - { name: kernel.event_listener, event: oro_ui.scroll_data.before.product-edit, method: onProductEdit }
11            - { name: kernel.event_listener, event: oro_ui.scroll_data.before.product-create-step-two, method: onProductEdit }

The event listener may be implemented as follows:

src/Oro/Bundle/BlogPostExampleBundle/EventListener/ProductFormListener.php
  1<?php
  2
  3namespace Oro\Bundle\BlogPostExampleBundle\EventListener;
  4
  5use Oro\Bundle\BlogPostExampleBundle\Entity\ProductOptions;
  6use Oro\Bundle\EntityBundle\ORM\DoctrineHelper;
  7use Oro\Bundle\ProductBundle\Entity\Product;
  8use Oro\Bundle\UIBundle\Event\BeforeListRenderEvent;
  9use Symfony\Component\HttpFoundation\RequestStack;
 10use Symfony\Component\Translation\TranslatorInterface;
 11
 12/**
 13{
 14    /** @var TranslatorInterface */
 15    protected $translator;
 16
 17    /** @var DoctrineHelper */
 18    protected $doctrineHelper;
 19
 20    /** @var RequestStack */
 21    protected $requestStack;
 22
 23    /**
 24     * @param TranslatorInterface $translator
 25     * @param DoctrineHelper $doctrineHelper
 26     * @param RequestStack $requestStack
 27     */
 28    public function __construct(
 29        TranslatorInterface $translator,
 30        DoctrineHelper $doctrineHelper,
 31        RequestStack $requestStack
 32    ) {
 33        $this->translator = $translator;
 34        $this->doctrineHelper = $doctrineHelper;
 35        $this->requestStack = $requestStack;
 36    }
 37
 38    /**
 39     * @param BeforeListRenderEvent $event
 40     */
 41    public function onProductView(BeforeListRenderEvent $event)
 42    {
 43        $request = $this->requestStack->getCurrentRequest();
 44        if (!$request) {
 45            return;
 46        }
 47
 48        // Retrieving current Product Id from request
 49        $productId = (int)$request->get('id');
 50        if (!$productId) {
 51            return;
 52        }
 53
 54        /** @var Product $product */
 55        $product = $this->doctrineHelper->getEntityReference(Product::class, $productId);
 56        if (!$product) {
 57            return;
 58        }
 59
 60        /** @var ProductOptions $productOptions */
 61        $productOptions = $this->doctrineHelper
 62            ->getEntityRepository(ProductOptions::class)
 63            ->findOneBy(['product' => $product]);
 64
 65        if (null === $productOptions) {
 66            return;
 67        }
 68
 69        $template = $event->getEnvironment()->render(
 70            'OroBlogPostExampleBundle:Product:product_options_view.html.twig',
 71            [
 72                'entity' => $product,
 73                'productOptions' => $productOptions
 74            ]
 75        );
 76        $this->addBlock($event->getScrollData(), $template, 'oro.blogpostexample.product.section.product_options');
 77    }
 78
 79    /**
 80     * @param BeforeListRenderEvent $event
 81     */
 82    public function onProductEdit(BeforeListRenderEvent $event)
 83    {
 84        $template = $event->getEnvironment()->render(
 85            'OroBlogPostExampleBundle:Product:product_options_update.html.twig',
 86            ['form' => $event->getFormView()]
 87        );
 88        $this->addBlock($event->getScrollData(), $template, 'oro.blogpostexample.product.section.product_options');
 89    }
 90
 91    /**
 92     * @param ScrollData $scrollData
 93     * @param string $html
 94     * @param string $label
 95     * @param int $priority
 96     */
 97    protected function addBlock(ScrollData $scrollData, $html, $label, $priority = 100)
 98    {
 99        $blockLabel = $this->translator->trans($label);
100        $blockId    = $scrollData->addBlock($blockLabel, $priority);
101        $subBlockId = $scrollData->addSubBlock($blockId);
102        $scrollData->addSubBlockData($blockId, $subBlockId, $html);
103    }
104}

Templates

And finally, we can define the templates – one for the form:

src/Oro/Bundle/BlogPostExampleBundle/Resources/views/Product/product_options_update.html.twig
1{#Render entire child form#}
2{{ form_widget(form.productOptions) }}
3{#Display Errors#}
4{{ form_errors(form.productOptions) }}

and one for the view:

src/Oro/Bundle/BlogPostExampleBundle/Resources/views/Product/product_options_view.html.twig
1{% import 'OroUIBundle::macros.html.twig' as UI %}
2{#Display our custom value#}
3{{ UI.renderHtmlProperty('oro.blogpostexample.product_options.label'| trans, productOptions.value) }}

As a result, the following blocks will be shown on the product edit and create pages:

../../../_images/crud_result_edit.png

In view mode, the block looks as follows:

../../../_images/crud_result_view.png

A fully working example, organized into a custom bundle is available in the OroB2BBlogPostExampleBundle.

In order to add this bundle to your application:

  • Please extract the content of the zip archive into a source code directory that is recognized by your composer autoload settings;

  • Clear the application cache with the following command:

    php bin/console cache:clear

    and run the migrations with the following command:

    app oro:migration:load –force –bundles=OroBlogPostExampleBundle