eZ Platform UI is a traditional Symfony application with React modules and interface using Bootstrap.
We'll show you how to extend eZ Platform by guiding you through building a poll system – from the customization of the Content Type to the visualization of the collected data. We will cover topics such as how to create a Field Type, how to add new admin interfaces and send notifications to the users of the admin interface.
represents an instance of the Field Type within a Content item
// AppBundle/eZ/Publish/FieldType/Poll/Value.php
use eZ\Publish\Core\FieldType\Value as BaseValue;
class Value extends BaseValue
{
}
__toString()
contains the logic of the Field Type
e.g. validating and transforming data
// AppBundle/eZ/Publish/FieldType/Poll/Type.php
use eZ\Publish\Core\FieldType\FieldType;
use eZ\Publish\SPI\FieldType\Nameable;
class Type extends FieldType implements Nameable
{
}
getFieldTypeIdentifier()
returns the string that uniquely identifies the Field TypecreateValueFromInput()
provides convenient way to set an attribute's value using the APIcheckValueStructure()
checks that the Value fed to the Type is acceptablegetEmptyValue()
provides what is considered an empty value of this typevalidate()
runs the validation on data, when a Content item is created with a Field of this typegetFieldName()
generates a name out of a Field valuegetSortInfo()
is used by the persistence layer to obtain the value it can use to sort and filter on a Field of this Field TypefromHash()
builds a hash with every property from Value
toHash()
instantiates a Value
with the hash it receivesPersistenceValue
This is a simple value object with three properties:
fromPersistenceValue()
builds a hash with every property from Value
toPersistenceValue()
instantiates a Value
with the hash it receivesgetName()
replaced by getFieldName()
to be closer to kernel best practices, you should declare the Field Type services in a custom fieldtypes.yml file
// AppBundle/DependencyInjection/AppExtension
$loader->load('fieldtypes.yml');
ezpublish.fieldType
is added to a registry using the alias argument as its unique identifier# AppBundle/Resources/config/fieldtypes.yml
services:
AppBundle\eZ\Publish\FieldType\Poll\Type:
parent: ezpublish.fieldType
tags:
- {name: ezpublish.fieldType, alias: ezpoll}
- {name: ezpublish.fieldType.nameable, alias: ezpoll}
Poll\LegacyConverter
//AppBundle/eZ/Publish/FieldType/PollLegacyConverter.php
namespace AppBundle\eZ\Publish\FieldType\Poll;
use eZ\Publish\Core\Persistence\Legacy\Content\FieldValue\Converter;
class LegacyConverter implements Converter
{
}
toStorageValue()
and toFieldValue()
used to convert between an API field value and legacy storage valuetoStorageFieldDefinition()
and toFieldDefinition()
used to convert between a Field definition and a legacy onegetIndexColumn()
tells the API which legacy database field should be used to sort and filter content# AppBundle/Resources/config/fieldtypes.yml
services:
...
AppBundle\eZ\Publish\FieldType\Poll\LegacyConverter:
tags:
- {name: ezpublish.storageEngine.legacy.converter, alias: ezpoll}
EzPublishCoreBundle::content_fields.html.twig
ezpoll.html.twig
templatefield
, content
.field
is an instance of eZ\Publish\API\Repository\Values\Content\Field
.value
property.PrependExtensionInterface
from Symfony\Component\DependencyInjection\Extension\
will let us prepend bundle configuration.
namespace AppBundle\DependencyInjection;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
class AppExtension extends Extension implements PrependExtensionInterface
{
public function prepend(ContainerBuilder $container)
{
$configFilePath = __DIR__.'/../Resources/config/ez_field_templates.yml';
$config = Yaml::parse(file_get_contents($configFilePath));
$container->prependExtensionConfig('ezpublish', $config);
}
...
}
# AppBundle/Resources/config/ez_field_templates.yml
system:
admin_group:
field_templates:
- { template: 'AppBundle:platformui/field:ezpoll_view.html.twig', priority: 0 }
We need to create a FormMapper
with FieldValueFormMapperInterface
. It is used to automatically add the input field to the Content Type edit form.
// AppBundle/eZ/Publish/FieldType/Poll/FormMapper.php
public function mapFieldValueForm(FormInterface $fieldForm, FieldData $data) {
...
$fieldForm->add(
$formConfig->getFormFactory()
->createBuilder()
->create('value', PollFieldType::class, [
'required' => false,
'label' => $label,
'answer_limit' => $answerLimit,
])
// Deactivate auto-initialize as we're not on the root form.
->setAutoInitialize(false)
->getForm()
);
}
# AppBundle/Resources/config/fieldtypes.yml
services:
...
AppBundle\eZ\Publish\FieldType\Poll\FormMapper:
tags:
- {name: ez.fieldFormMapper.value, fieldType: ezpoll}
FormMapper
how to transform between the value object and hash.namespace AppBundle\Form;
use Symfony\Component\Form\DataTransformerInterface;
class PollValueTransformer implements DataTransformerInterface
{
}
DataTransformer
to FormMapper
// AppBundle/eZ/Publish/FieldType/Poll/FormMapper.php
$formConfig->addModelTransformer(
new PollValueTransformer($fieldType)
)
validateValidatorConfiguration()
and validate()
methods in the Type classtoStorageFieldDefinition()
and toFieldDefinition()
methods in LegacyConvertervalidateValidatorConfiguration()
is called when an instance of the Field Type is added to a Content Type, to ensure that the validator configuration is valid.
// AppBundle/eZ/Publish/FieldType/Poll/Type.php
protected $validatorConfigurationSchema = [
'QuestionLengthValidator' => [
'minStringLength' => [
'type' => 'int',
'default' => 0,
],
],
];
validateValidatorConfiguration()
We will iterate over the items in $validatorConfiguration
and
validate()
is the method that runs the actual validation on data// AppBundle/eZ/Publish/FieldType/Poll/Type.php
public function validate(FieldDefinition $fieldDefinition, SPIValue $fieldValue)
{
$validationErrors = [];
$validatorConfiguration = $fieldDefinition->getValidatorConfiguration();
$questionLengthConstraints = $validatorConfiguration['QuestionLengthValidator'] ?? [];
...
$validationErrors[] = new ValidationError(
...
return $validationErrors;
}
Implement FieldDefinitionFormMapperInterface
in FormMapper
that allows us to define the necessary input field.
// AppBundle/eZ/Publish/FieldType/Poll/FormMapper.php
public function mapFieldDefinitionForm(FormInterface $fieldDefinitionForm, FieldDefinitionData $data)
{
$fieldDefinitionForm
->add('minLength', IntegerType::class, [
'required' => false,
'property_path' => 'validatorConfiguration[QuestionLengthValidator][minStringLength]',
'label' => 'field_definition.ezpoll.min_length',
'attr' => ['min' => 0],
])
}
Add an extra tag definition in fieldtypes.yml
to tell the system that the FormMapper
right now works also as FieldDefinitionFormMapper
.
# AppBundle/Resources/config/fieldtypes.yml
services:
...
AppBundle\eZ\Publish\FieldType\Poll\FormMapper:
tags:
...
- {name: ez.fieldFormMapper.definition, fieldType: ezpoll}
{# AppBundle/Resources/views/platformui/content_type/edit/ezpoll.html.twig #}
{% raw %}
{% block ezpoll_field_definition_edit %}
<div class="ezpoll-validator answer_limit{% if group_class is not empty %} {{ group_class }}{% endif %}">
{{- form_row(form.answerLimit) -}}
</div>
{% endblock %}
{% endraw %}
Register the new template in the configuration by editing the ez_field_templates.yml
file
# AppBundleResources/config/ez_field_templates.yml
system:
admin_group:
fielddefinition_edit_templates:
- {template: AppBundle:platformui/content_type/edit:ezpoll.html.twig, priority: 0}
toStorageFieldDefinition()
and toFieldDefinition()
methods in LegacyConverter
to make sure that validation data is properly saved into and retrieved from the database
toStorageFieldDefinition()
converts a Field definition to a legacy one using the proper field, e.g. dataText1
, dataInt1
.
toFieldDefinition()
converts a stored legacy Field definition to an API Field definition (which means converting it back to an array according to validation schema).
Components enable you to inject widgets (e.g. Dashboard blocks) and HTML code (e.g. a tag for loading JS or CSS files) into specific places in the Back Office
A component is any class that implements the Renderable
interface. It must be
tagged as a service:
# Resources/config/services.yml
EzSystems\EzPlatformAdminUi\Component\TwigComponent:
tags:
- { name: ezplatform.admin_ui.component, group: 'content-edit-form-after' }
If you want to inject a short element, render a Twig template or add a CSS link, you can make use of pre-existing base classes. You need to add a service definition with proper parameters.
EzSystems\EzPlatformAdminUi\Component\TwigComponent:
arguments:
$template: 'AppBundle:adminui/component:ezpoll_edit_js.html.twig'
$parameters: []
tags:
- { name: ezplatform.admin_ui.component, group: 'content-edit-form-after' }
There are three such base components:
TwigComponent
renders a Twig template, like aboveLinkComponent
renders the HTML tag:PollVote
Entity Classbin/console doctrine:schema:update --dump-sql
PollVoteRepository
PollType
classpublic function buildForm(FormBuilderInterface $builder, array $options)
{
$choices = array_filter($options['answers'], function($value) {
return $value !== null;
});
...
}
public function configureOptions(OptionsResolver $resolver)
{
....
$resolver->setRequired('answers');
}
FormFactory
public function createPollForm(
PollVote $data,
?string $name = null,
array $answers
): FormInterface {
$name = $name ?: StringUtil::fqcnToBlockPrefix(PollType::class);
$options = null !== $answers ? ['answers' => $answers] : [];
return $this->formFactory->createNamed(
$name,
PollType::class,
$data,
$options
);
}
PollController::voteAction()
We need a new method in it to handle voting action.
ParameterProvider
provides additional parameters to a Field Type's view template.
class ParameterProvider implements ParameterProviderInterface
{
public function getViewParameters(Field $field)
{
$pollData = new PollVote();
$pollData->setQuestion($field->value->question);
$pollForm = $this->formFactory->createPollForm($pollData, null, $field->value->answers);
return [
'pollForm' => $pollForm->createView(),
];
}
}
ParameterProvider
needs to be registered in the ParameterProviderRegistry
services:
...
AppBundle\eZ\Publish\FieldType\Poll\ParameterProvider:
tags:
- {name: ezpublish.fieldType.parameterProvider, alias: ezpoll}
{# Resources/views/ezpoll_view.html.twig #}
{% raw %}
{% extends "EzPublishCoreBundle::content_fields.html.twig" %}
{% block ezpoll_field %}
...
{{ form_start(parameters.pollForm, {'action': path('ez_systems_poll_vote', {
'contentId': content.id,
'fieldDefIdentifier': field.fieldDefIdentifier
}), 'method': 'POST'}) }}
{{ form_widget(parameters.pollForm) }}
...
{% endblock %}
{% endraw %}
# Resources/config/ez_field_templates.yml
system:
site:
field_templates:
- {template: 'AppBundle::ezpoll_view.html.twig', priority: 0}
You can hook into the following events:
ConfigureMenuEvent::MAIN_MENU
ConfigureMenuEvent::USER_MENU
ConfigureMenuEvent::CONTENT_SIDEBAR_RIGHT
ConfigureMenuEvent::USER_MENU
and a lot more...
EventSubscriberInterface
Symfony\Component\EventDispatcher\EventSubscriberInterface
$menu = $event->getMenu();
$menu->addChild(
self::ITEM_POLL_LIST,
[
'route' => 'ez_systems_poll_list',
'extras' => ['translation_domain' => 'menu'],
]
);
We rely on Bootstrap's framework which makes eZ Platform UI easier to extend. It facilitates adapting and styling the interface to your needs.
ez_systems_poll_list:
path: /poll/list
defaults: { _controller: AppBundle:Poll:list }
ez_systems_poll_show:
path: /poll/show/{fieldId}/{contentId}
defaults: { _controller: AppBundle:Poll:show }
listAction(Request $request): Response
showAction(Request $request): Response
{% raw %}
{% extends '@ezdesign/layout.html.twig' %}
{% endraw %}
{% raw %}
{% block body_class %}
{% block breadcrumbs %}
{% block page_title %}
{% block content %}
{% endraw %}
{% raw %}
{% if pager.haveToPaginate %}
...
{{ pagerfanta(pager, 'ez') }}
{% endif %}
{% endraw %}
NotificationService
// Controller/PollController.php
use eZ\Publish\API\Repository\NotificationService;
public function __construct( NotificationService $notificationService)
{
$this->notificationService = $notificationService;
}
CreateStruct
// Controller/PollController.php
$notificationStruct = new CreateStruct();
$notificationStruct->ownerId = $sendToUserId;
$notificationStruct->type = 'Poll:Vote';
$notificationStruct->data = [
'fieldId' => $pollData->getFieldId(),
'question' => $pollData->getQuestion()
];
createNotification()
method// Controller/PollController.php
$this->notificationService->createNotification($notificationStruct);
use eZ\Publish\Core\Notification\Renderer\NotificationRenderer;
class Renderer implements NotificationRenderer {
public function render(Notification $notification): string {
...
}
public function generateUrl(Notification $notification): ?string {
...
}
}
{# Resources/views/notification/notification_row.html.twig #}
{% raw %}
{% extends '@EzPlatformAdminUi/notifications/notification_row.html.twig' %}
{% block icon %}
{% endblock %}
{% block message %}
{% endblock %}
{% endraw %}
services.yml
# Resources/config/services.yml
AppBundle\Notification\Renderer:
tags:
- { name: ezpublish.notification.renderer, alias: 'Poll:Vote' }
A Role is composed of Policies and can be assigned to a User or a User Group.
A Policy is composed of a combination of module and function.
Depending on module and function combination, a Policy can also contain Limitations.
A bundle can expose Policies via a PolicyProvider
which can be added to
EzPublishCoreBundle
's DIC extension.
PolicyProviderInterface
// Security/PollPolicyProvider.php
use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Configuration\ConfigBuilderInterface;
use eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Security\PolicyProvider\PolicyProviderInterface;
class PollPolicyProvider implements PolicyProviderInterface
{
public function addPolicies(ConfigBuilderInterface $configBuilder)
{
}
}
Policies configuration hash contains declared modules, functions and limitations
[
'poll' => [
'list' => null,
'show' => ['Question'],
],
];
PolicyProvider
into EzPublishCoreBundle
// AppBundle.php
public function build(ContainerBuilder $container)
{
parent::build($container);
// Retrieve "ezpublish" container extension.
$eZExtension = $container->getExtension('ezpublish');
// Add the policy provider.
$eZExtension->addPolicyProvider(
new PollPolicyProvider()
);
}
Add policy check to the PollVoteRepository
methods
// Repository/PollVoteRepository.php
public function findAllOrderedByQuestion()
{
if ($this->permissionResolver->hasAccess('poll', 'list') !== true) {
throw new UnauthorizedException('poll', 'list');
}
...
}
QuestionLimitation
represents the valueQuestionLimitationType
deals with the business logicQuestionLimitation
classuse eZ\Publish\API\Repository\Values\User\Limitation;
class QuestionLimitation extends Limitation
{
public const QUESTION = 'Question';
public function getIdentifier(): string
{
return self::QUESTION;
}
}
QuestionLimitationType
// Security/Limitation/QuestionLimitationType.php
use eZ\Publish\Core\Limitation\AbstractPersistenceLimitationType;
use eZ\Publish\SPI\Limitation\Type as SPILimitationTypeInterface;
class QuestionLimitationType
extends AbstractPersistenceLimitationType
implements SPILimitationTypeInterface {
}
acceptValue()
Accepts a Limitation value and checks for structural validity. Makes sure
LimitationValue
object and LimitationValue->limitationValues
is of correct type.
validate()
Makes sure LimitationValue->limitationValues
is valid according to valueSchema().
buildValue()
Create the Limitation Value.
evaluate()
Evaluate permission against content & target(placement/parent/assignment).
public function evaluate(
APILimitationValue $value,
APIUserReference $currentUser,
ValueObject $object,
array $targets = nullss)
{
...
return in_array($object->contentInfo->id, $value->limitationValues);
}
# Resources/config/services.yml
AppBundle\Security\Limitation\QuestionLimitationType:
arguments: ["@ezpublish.api.persistence_handler"]
tags:
- {name: ezpublish.limitationType, alias: Question }
# Name provided in the hash for each Limitation is the same
# value set in the alias attribute in the service tag.
use eZ\Publish\API\Repository\Values\User\Limitation;
use Symfony\Component\Form\FormInterface;
// Provide support for editing custom policies in Platform UI
interface LimitationFormMapperInterface
{
public function mapLimitationForm(FormInterface $form, Limitation $data);
public function getFormTemplate();
public function filterLimitationValues(Limitation $limitation);
}
ez.limitation.formMapper
tag# Resources/config/services.yml
AppBundle\Security\Limitation\Mapper\QuestionLimitationFormMapper:
calls:
- [setFormTemplate, ["EzSystemsRepositoryFormsBundle:Limitation:base_limitation_values.html.twig"]]
tags:
- { name: ez.limitation.formMapper, limitationType: Question }
set the limitationType attribute to the Limitation type's identifier and set Twig template to use to render the limitation form
We are extending MultipleSelectionBasedMapper
to choose multiple polls
// Security/Limitation/Mapper/QuestionLimitationFormMapper.php
class QuestionLimitationFormMapper extends MultipleSelectionBasedMapper implements LimitationValueMapperInterface
{
}
getSelectionChoices()
returns value choices to display, as expected by the "choices" option from Choice field.
To provide human-readable names of the custom Limitation values, we will implement LimitationValueMapperInterface
interface LimitationValueMapperInterface
{
/**
* Map the limitation values, in order to pass them
* as context of limitation value rendering.
*
* @param Limitation $limitation
* @return mixed[]
*/
public function mapLimitationValue(Limitation $limitation);
}
register the service in DIC with the ez.limitation.valueMapper tag
and set the limitationType
attribute to Limitation type's identifier
# Resources/config/services.yml
AppBundle\Security\Limitation\Mapper\QuestionLimitationFormMapper:
...
tags:
- { name: ez.limitation.valueMapper, limitationType: Question }
override the way of rendering custom Limitation values in the role view
// Resources/views/Limitation/question_limitation_value.html.twig
{% raw %}
{% block ez_limitation_question_value %}
<span>{{ values|join(', ') }}</span>
{% endblock %}
{% endraw %}
Add the template to the configuration
# Resources/config/ez_field_templates.yml
ezpublish:
system:
admin_group:
...
limitation_value_templates:
- { template: AppBundle:Limitation:question_limitation_value.html.twig, priority: 0 }
Everyone
tab in the DashboardEveryonePollTab
as a tab# Resources/config/services.yml
services:
...
AppBundle\Tab\Dashboard\Everyone\EveryonePollTab:
tags:
- { name: ezplatform.tab, group: dashboard-everyone }
// Tab/Dashboard/Everyone/EveryonePollTab.php
use EzSystems\EzPlatformAdminUi\Tab\AbstractTab;
use EzSystems\EzPlatformAdminUi\Tab\OrderedTabInterface;
class EveryonePollTab extends AbstractTab implements OrderedTabInterface
Create ContentToPollDataMapper
: for ease of use we create a mapper which extracts polls from content.
// Tab/Dashboard/ContentToPollDataMapper.php
/** @var \eZ\Publish\Core\Repository\Values\Content\Content $content */
foreach ($pager as $content) {
foreach ($content->getFields() as $field) {
if ($field->fieldTypeIdentifier === 'ezpoll') {
$data[] = [
'contentId' => $content->id,
'name' => $contentInfo->name,
...
'question' => $field->value->question,
'answers' => $field->value->answers,
];
}
}
}
We can copy one of the existing Dashboard templates and change it to display poll data.
{# Resources/views/dashboard/tab/all_poll.html.twig #}
Slides are available at: https://mikadamczyk.github.io/presentations/extending-ez-platform-ui