Dynamic shopping worlds

The shopping worlds (also called emotions) are one of the central content pages in Shopware. With them, even non-technical persons can create landing pages, product, category or blog teasers and many other content types. In addition to that there is the easy to use editor, powerful components and extensibility, of course.

There are cases, however, where you might want to use the shopping worlds for other kind of content pages - or where you don't want to create one shopping world per page - but one "template" shopping world, that is dynamically populated. The following blog post will discuss this exact scenario.

The goal

In my example there will be a new "store manager" backend module. It will allow the user to define stores (name, description, address, opening times). In addition to that, the "store manager" will allow the user to edit a shopping world template - which is used for all stores. So the user can arrange name, description, google map and opening times.

In order not to bloat the shopping world module, the "store template" shopping world should not be visible there. Same applies to our store shopping world components: they should only be visible in the store manager - not in the default shopping world module.

On the image on the left you can see the frontend result. It is a simple store page, which shows some details for a given store - in this example name, description, google map and opening times can be seen.

The marked blocks are completely independent. By modifying the "store template" shopping world, the user can easily move those boxes around.

The backend module is a simple CRUD module, that allows the user to create / edit stores. In our case stores in Berlin, Schöppingen and Münster have been defined. As the Shopware backend components were used, this module is quite simple to create - the only special thing is the "edit store template" button in the list window. This will open up the template shopping world that defines the layout for all stores. As mentioned before, this specific shopping world can only be opened from the "store manager" module, the highlights components will also only be visible there.

Tasks

Looking at the goal, the plugin can roughly be split into the following tasks:

  • Shopping world
    • Create a "store template" shopping world
    • Create new shopping world components, that can be filled dynamically (e.g. description, map, opening times)
    • hide those components and the "store template" from the default shopping world module
  • Frontend
    • new "store" controller
    • store selector template (combobox)
    • include the "store template" shopping world for the current store
    • wiring up the custom shopping world component
  • Store manager backend module
    • CRUD for store infos
    • add "open store template" button, which opens the store template shopping world

I will quickly show the more interesting parts of this plugin - if you want to have the full example, please see the download section below.

Shopping world

Create a "store template" shopping world during plugin install

The store template shopping world can easily be created using the Emotion Doctrine model. The only special thing is the attribute swagShopTemplate that I use to tell my "store template" apart from all the other shopping worlds.

Shopware()->Models()->addAttribute(
    's_emotion_attributes',
    'swag',
    'shop_template',
    'INT(1)'
);
Shopware()->Models()->generateAttributeModels(
    [
        's_emotion_attributes'
    ]
);

Shopping world components

As the shopping world components are intended to be dynamic, there is not much configuration needed:

private function createMyEmotionComponent()
{
    $descriptionComponent = $this->bootstrap->createEmotionComponent(
        array(
            'name' => 'Description',
            'template' => 'component_description',
            'cls' => 'dynamic_emotion_description',
            'description' => 'Shop description - will show the shop\'s description'
        )
    );
    $openInfoComponent = $this->bootstrap->createEmotionComponent(
        array(
            'name' => 'Opening times',
            'template' => 'component_opening',
            'cls' => 'dynamic_emotion_opening_times',
            'description' => 'Shop opening times'
        )
    );
    $mapComponent = $this->bootstrap->createEmotionComponent(
        [
            'name' => 'Map',
            'template' => 'component_map',
            'cls' => 'dynamic_emotion_map',
            'description' => 'Shop a google map link'
        ]
    );
    $mapComponent->createNumberField(
        [
            'name' => 'zoom',
            'defaultValue' => '17',
            'minValue' => 1,
            'maxValue' => 21,
            'position' => 1
        ]
    );


    return [$openInfoComponent, $mapComponent, $descriptionComponent];

}

As you can see, this create the components description, opening times and map. Only the map components has a configuration field - for the zoom factor. The rest of the data will be read dynamically depending on the current store selection.

Hide all this from the shopping world module

In order not to bloat the shopping world module, my "store template" and the "store components" should not be seen in the default module.

To do so, a simple subscriber is implemented:


{
    public static function getSubscribedEvents()
    {
        return array(
            'Enlight_Controller_Action_PostDispatchSecure_Backend_Emotion' => 'modifyEmotionModule',
            'Shopware\Models\Emotion\Repository::getListingQuery::after' => 'removeStoreTemplateEmotionFromListing'
        );
    }

    // do not show the store template shopping world in the emotion module
    public function removeStoreTemplateEmotionFromListing(\Enlight_Hook_HookArgs $args)
    {
        $builder = $args->getReturn();

        $builder->leftJoin('emotions', 's_emotion_attributes', 'attribute', 'attribute.emotionID = emotions.id')
            ->andWhere('attribute.swag_shop_template IS NULL or attribute.swag_shop_template != 1');

        return $builder;

    }

    public function modifyEmotionModule(\Enlight_Event_EventArgs $args)
    {
        /** @var $controller \Enlight_Controller_Action */
        $controller = $args->get('subject');
        $request = $controller->Request();
        $view = $controller->View();

        // remove our components from the default emotion library
        // our components should just be visible when editing our store emotion template
        if ($request->getActionName() == 'library' && !$request->has('showStoreComponents')) {
            /** @var CustomComponents $customComponents */
            $customComponents = $controller->get('swag_dynamic_emotion.custom_components');

            $data = $view->getAssign('data');
            foreach ($data as $key => $component) {
                // remove the custom elements from the default emotion module
                if ($customComponents->isCustomComponents($component['cls'])) {
                    unset($data[$key]);
                }
            }
            $view->assign('data', $data);
        }

    }
}

The method removeStoreTemplateEmotionFromListing will check for a custom attribute the plugin created. This attribute is 1 for the store template shopping world, so only shopping worlds without this flag are shown. The method modifyEmotionModule modifies the libraryAction of the backend emotion controller - this method will return all available components for the toolbar on the right of the emotion designer. As our dynamic components should not be visible there, they are hidden by default using a simple service called swag_dynamic_emotion.custom_components. This services basically "knows" all emotion components of this plugin - and allows checking, if a given component is one of the plugin's components.

Frontend

Store controller

The store controller and the associated frontend template is quite simple:


class Shopware_Controllers_Frontend_Store extends Enlight_Controller_Action
{
    public function indexAction()
    {
        /** @var Repository $repo */
        $repo = Shopware()->Models()->getRepository('Shopware\CustomModels\SwagDynamicEmotion\Store');
        $stores = $repo->findAll();

        // all stores
        $this->View()->assign('stores', $stores);

        // curent store or null
        $this->View()->assign(
            'currentStore',
            $this->Request()->getParam('store', empty($stores) ? null : $stores[0]->getId())
        );

        // store template emotion id
        $this->View()->assign('storeEmotionId', $repo->getStoreEmotionId());
    }
}

The controller will basically read all stores from the database and assign them to the template. Additionally it checks for the parameter store (which is the currently selected store) and assigns it to the variable currentStore. Last of all, it assigns the id of the store template shopping world to the template, so it can be included there. In this example, Doctrine ORM is used and all store entities are fetched. Depending on the number of entities and the server load, pagination and perhaps custom SQL queries might be more suitable for your.

The template

The only frontend template needed can be found in SwagDynamicEmotion/Views/frontend/store/index.tpl.

{extends file="parent:frontend/index/index.tpl"}

{* Hide the left navigation bar*}
{block name='frontend_index_content_left'}
{/block}

{block name='frontend_index_content'}
    <div class="content content--home">


        <form>
            <select name="store" id="storeSelector" onchange="this.form.submit()">
                {foreach from=$stores item=store}
                    <option value="{$store->getId()}" {if $currentStore eq $store->getId()}selected="selected"{/if}>{$store->getName()}</option>
                {/foreach}
            </select>
        </form>

        <br>
        <div class="content--emotions">
            {action controller=Emotion module=Widgets emotionId={$storeEmotionId} currentStore={$currentStore}}
        </div>
    </div>
{/block}

It hides the left navigation menu (frontend_index_content_left) and shows a store selector select box - no rocket science. The only interesting part here is the inclusion of the store template shopping world:

{action controller=Emotion module=Widgets emotionId={$storeEmotionId} currentStore={$currentStore}}

It uses the two parameters $storeEmotionId (which identifies the shopping world to show) and $currentStore (which will later be used to populate the shopping world with the correct store data). Both have been assigned in the controller before.

Wiring up the custom shopping world component

In order to support our new, custom emotion components and fill them dynamically with the current store's content, we will need to subscribe to the Shopware_Controllers_Widgets_Emotion_AddElement event, which is useful to provide data for custom components:

class Emotion implements \Enlight\Event\SubscriberInterface
{
    public static function getSubscribedEvents()
    {
        return array(
            'Shopware_Controllers_Widgets_Emotion_AddElement' => 'handleElement',
            // … the other events we discussed before
        );
    }

    public function removeShopTemplateEmotionFromListing(\Enlight_Hook_HookArgs $args) { }

    public function modifyEmotionModule(\Enlight_Event_EventArgs $args) { }

    /**
     * handle the custom emotion components and provide the current store's content
     *
     * @param \Enlight_Event_EventArgs $args
     * @return array|mixed
     */
    public function handleElement(\Enlight_Event_EventArgs $args)
    {
        /** @var $controller \Enlight_Controller_Action */
        $controller = $args->get('subject');
        /** @var CustomComponents $customComponents */
        $customComponents = $controller->get('swag_dynamic_emotion.custom_components');

        $element = $args->get('element');
        $data = $args->getReturn();
        $storeId = $controller->Request()->getParam('currentStore');

        // just modify our own components
        if (!$customComponents->isCustomComponents($element['component']['cls'])) {
            return $data;
        }

        // if no $storeId is available (e.g. shopping world preview), get a fallback
        $storeId = isset($storeId) ? $storeId : Shopware()->Db()->fetchOne('SELECT id FROM swag_store LIMIT 1');

        // if still not available (e.g. no stores) - return
        if (!$storeId) {
            return $data;
        }

        /** @var ModelRepository $storeRepo */
        $storeRepo = Shopware()->Models()->getRepository('Shopware\CustomModels\SwagDynamicEmotion\Store');
        return array_merge($data, ['store' => $storeRepo->find($storeId)]);
    }

}

As you can see, the service swag_dynamic_emotion.custom_components is used here again, to tell apart the default components from our own ones. If no storeId is available (e.g. for the store listing page or the emotion preview in the backend) a default is used:

$storeId = isset($storeId) ? $storeId : Shopware()->Db()->fetchOne('SELECT id FROM swag_store LIMIT 1');

Then, finally, we return all the data for our component:

/** @var ModelRepository $storeRepo */
$storeRepo = Shopware()->Models()->getRepository('Shopware\CustomModels\SwagDynamicEmotion\Store');
return array_merge($data, ['store' => $storeRepo->find($storeId)]);

Merging the result array is necessary, as $data might contain the components data, e.g. zoom for the map component. We add the current store's data to it, so both are available in the emotion template.

Store manager backend module

The store manager backend module was created using Shopware's backend components and generated using the code generator. So there is nothing special here - except the "edit store template" button, which is supposed to open the template shopping world directly.

Open store template shopping world

In order to add this functionality, we need to modify the swag_store/view/list/list.js file of our backend:

Ext.define('Shopware.apps.SwagStore.view.list.List', {
    extend: 'Shopware.grid.Panel',
    alias: 'widget.swag-store-listing-grid',
    region: 'center',

    configure: function () {
        return {
            detailWindow: 'Shopware.apps.SwagStore.view.detail.Window',
            columns: {
                name: undefined,
                address: undefined
            }
        };
    },

    createToolbarItems: function () {
        var me = this,
            items = me.callParent(arguments);

        items.splice(3, 0, Ext.create('Ext.button.Button', {
            text: 'Edit store template',
            handler: function () {
                Shopware.app.Application.addSubApplication({
                    name: 'Shopware.apps.Emotion',
                    params: {
                        // the ID has been assigned in our backend controller
                        emotionId: '{$storeTemplateEmotionId}'
                    }
                })
            }
        }));
        items.splice(4, 0, '->');

        return items;
    }
});

As you can see above, the function createToolbarItems is an override of the base class Shopware.grid.Panel. The override is used to add the new button. The handler callback of the button will just open the emotion detail view for the provided emotionId. The ID {$storeTemplateEmotionId} will be filled by Smarty. In order to do so, I extended the backend controller like this:

public function loadAction()
{
    parent::loadAction();
    /** @var Repository $repo */
    $repo = Shopware()->Models()->getRepository('Shopware\CustomModels\SwagDynamicEmotion\Store');
    // this will make our emotion id available in the (smarty) template of our backend application
    $this->View()->assign('storeTemplateEmotionId', $repo->getStoreEmotionId());
}

This pattern is usually discouraged in Shopware, as the templates are cached and side effects might occur. In this case, however, the emotionId will be constant, so we can use it in this case. In order to query more dynamic information, models or Ext.Ajax queries would be required.

Modifications to the shopping world module

The shopping world module is not able to open shopping world detail pages by default. As such, it needs to be extended as well. In order to do so, I created the template file backend/swag_emotion/controller/main.js, which will extend the main controller of the shopping world module:

//{block name="backend/emotion/controller/main"}
// {$smarty.block.parent}
Ext.define('Shopware.apps.SwagEmotion.controller.Main', {
    override: 'Shopware.apps.Emotion.controller.Main',

    init: function() {
        var me = this;

        me.callParent(arguments);

        if (me.subApplication.params && me.subApplication.params.emotionId > 0) {
            me.mainWindow.hide();
            me.getStore('Library').getProxy().extraParams.showStoreComponents = true;
            me.getController('Detail').loadEmotionRecord(me.subApplication.params.emotionId, function(record) {
                me.getController('Detail').openDetailWindow(record);
            });
        }
    }
});
//{/block}

This is an Ext override, that will modify the constructor of the controller. After calling the original method, we add a check for a passed emotionId. If it was passed, the main shopping world listing window is hidden using me.mainWindow.hide();. We also add the flag showStoreComponents to the library store, so that our custom components will be shown in the designer (see our modification to the libraryAction). Then finally we will open the detail page for the given emotionId using the call

me.getController('Detail').loadEmotionRecord(me.subApplication.params.emotionId, function(record) {
    me.getController('Detail').openDetailWindow(record);
});

Round up

With the modifications described above, we created a custom content designer that makes use of the shopping world module without bloating the original module. This will allow the users to use the powerful shopping world designer for custom content pages in a very convenient way. In addition to that, we wrote "dynamic" shopping world components, that will be filled depending on the currently selected store. This way of using the shopping worlds makes them even more powerful.

Download

I didn't go into detail about some smaller modifications and classes the plugin implements. For the full example, please download the plugin.

Back to overview