How to Use Drafts

This topic describes how to use draft.

For example, let’s create a simple bundle using instructions provided in the How to create a new bundle topic.

Following the steps described below, the released bundle will implement all the functionality provided by DraftBundle.

Add and Configure an Entity

Create an entity with 2 fields: title and content and implement the entity from DraftableInterface.

Name Type
title string
content text

Note

For simplicity, use the predefined DraftableTrait trait:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<?php

namespace ACME\Bundle\CMSBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Oro\Bundle\DraftBundle\Entity\DraftableInterface;
use Oro\Bundle\DraftBundle\Entity\DraftableTrait;
use Oro\Bundle\EntityBundle\EntityProperty\DatesAwareInterface;
use Oro\Bundle\EntityBundle\EntityProperty\DatesAwareTrait;
use Oro\Bundle\EntityConfigBundle\Metadata\Annotation\Config;
use Oro\Bundle\EntityConfigBundle\Metadata\Annotation\ConfigField;
use Oro\Bundle\UserBundle\Entity\Ownership\UserAwareTrait;

/**
 * Represents ACME Block
 *
 * @ORM\Table(name="acme_cms_block")
 * @ORM\Entity()
 * @Config(
 *      routeName="acme_cms_block_index",
 *      routeView="acme_cms_block_view",
 *      routeUpdate="acme_cms_block_update",
 *      defaultValues={
 *          "entity"={
 *              "icon"="fa-book"
 *          },
 *          "ownership"={
 *              "owner_type"="USER",
 *              "owner_field_name"="owner",
 *              "owner_column_name"="user_owner_id",
 *              "organization_field_name"="organization",
 *              "organization_column_name"="organization_id"
 *          },
 *          "security"={
 *              "type"="ACL",
 *              "group_name"=""
 *          }
 *      }
 * )
 */
class Block implements DraftableInterface, DatesAwareInterface
{
    use DraftableTrait;
    use UserAwareTrait;
    use DatesAwareTrait;

    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=false, length=255, unique=true)
     * @ConfigField(
     *      defaultValues={
     *          "draft"={
     *              "draftable"=true
     *          }
     *      }
     * )
     */
    protected $title;

    /**
     * @var string
     *
     * @ORM\Column(type="text", nullable=true)
     * @ConfigField(
     *      defaultValues={
     *          "draft"={
     *              "draftable"=true
     *          }
     *      }
     * )
     */
    protected $content;

    /**
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return Block
     */
    public function setTitle(string $title): Block
    {
        $this->title = $title;

        return $this;
    }

    /**
     * @return string
     */
    public function getContent(): ?string
    {
        return $this->content;
    }

    /**
     * @param string|null $content
     *
     * @return $this
     */
    public function setContent(?string $content): Block
    {
        $this->content = $content;

        return $this;
    }
}

Then, add the entity configuration to the title and content fields. The draftable parameter ensures that the field is involved in the draft operation.

Follow the instructions provided in the Configure Entities topic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    /**
     * @var string
     *
     * @ORM\Column(type="string", nullable=false, length=255, unique=true)
     * @ConfigField(
     *      defaultValues={
     *          "draft"={
     *              "draftable"=true
     *          }
     *      }
     * )
     */
    protected $title;

    /**
     * @var string
     *
     * @ORM\Column(type="text", nullable=true)
     * @ConfigField(
     *      defaultValues={
     *          "draft"={
     *              "draftable"=true
     *          }
     *      }
     * )
     */
    protected $content;

Important

To be able to restrict the entity from using ACL, follow the instructions provided in the Protect Entities Using ACLs topic.

Restrictions for drafts have specific peculiarities. For more information, see the How to use draft ACL topic.

Create an Installer/Migration

An installer ensures that upon the application installation, the database will contain the entity with the fields defined within bundle. Follow the instructions provided in the How to generate an installer topic for more details.

The fields responsible for the draft must match the interface and have the appropriate types.

Name Type Attributes
draft_project_id ManyToOne nullable
draft_source_id ManyToOne nullable
draft_uuid guid nullable
draft_owner_id ManyToOne nullable

After you complete it, you will have the installer with the following content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<?php

namespace ACME\Bundle\CMSBundle\Migrations\Schema;

use Doctrine\DBAL\Schema\Schema;
use Oro\Bundle\MigrationBundle\Migration\Installation;
use Oro\Bundle\MigrationBundle\Migration\QueryBag;

/**
 * Creates all tables required for ACME CMSBundle.
 */
class ACMECMSBundleInstaller implements Installation
{
    /**
     * {@inheritdoc}
     */
    public function getMigrationVersion()
    {
        return 'v1_0';
    }

    /**
     * {@inheritdoc}
     */
    public function up(Schema $schema, QueryBag $queries)
    {
        /** Tables updates **/
        $this->createAcmeCmsBlockColumns($schema);

        /** Foreign keys generation **/
        $this->addAcmeCmsBlockForeignKeys($schema);
    }

    /**
     * @param Schema $schema
     */
    private function createAcmeCmsBlockColumns(Schema $schema): void
    {
        $table = $schema->createTable('acme_cms_block');
        $table->addColumn('id', 'integer', ['autoincrement' => true]);
        $table->addColumn('title', 'string', ['length' => 255, 'notnull' => true]);
        $table->addColumn('content', 'text', ['notnull' => false]);
        $table->addColumn('created_at', 'datetime', ['comment' => '(DC2Type:datetime)']);
        $table->addColumn('updated_at', 'datetime', ['comment' => '(DC2Type:datetime)']);
        $table->addColumn('user_owner_id', 'integer', ['notnull' => false]);
        $table->addColumn('organization_id', 'integer', ['notnull' => false]);
        $table->addColumn('draft_project_id', 'integer', ['notnull' => false]);
        $table->addColumn('draft_source_id', 'integer', ['notnull' => false]);
        $table->addColumn('draft_uuid', 'guid', ['notnull' => false]);
        $table->addColumn('draft_owner_id', 'integer', ['notnull' => false]);
        $table->setPrimaryKey(['id']);
        $table->addIndex(['user_owner_id'], 'IDX_11767F6E9EB185F9');
        $table->addIndex(['organization_id'], 'IDX_11767F6E32C8A3DE');
        $table->addIndex(['draft_project_id'], 'IDX_11767F6E29386E37');
        $table->addIndex(['draft_source_id'], 'IDX_11767F6E754B6AC7');
        $table->addIndex(['draft_owner_id'], 'IDX_11767F6EDCA3D9F3');
        $table->addUniqueIndex(['title'], 'UNIQ_11767F6E2B36786B');
    }

    /**
     * @param Schema $schema
     */
    private function addAcmeCmsBlockForeignKeys(Schema $schema): void
    {
        $table = $schema->getTable('acme_cms_block');
        $table->addForeignKeyConstraint(
            $schema->getTable('acme_cms_block'),
            ['draft_source_id'],
            ['id'],
            ['onDelete' => 'CASCADE']
        );
        $table->addForeignKeyConstraint(
            $schema->getTable('oro_draft_project'),
            ['draft_project_id'],
            ['id'],
            ['onDelete' => 'CASCADE']
        );
        $table->addForeignKeyConstraint(
            $schema->getTable('oro_user'),
            ['draft_owner_id'],
            ['id'],
            ['onDelete' => 'CASCADE']
        );
        $table->addForeignKeyConstraint(
            $schema->getTable('oro_user'),
            ['user_owner_id'],
            ['id'],
            ['onDelete' => 'SET NULL', 'onUpdate' => null]
        );
        $table->addForeignKeyConstraint(
            $schema->getTable('oro_organization'),
            ['organization_id'],
            ['id'],
            ['onDelete' => 'SET NULL', 'onUpdate' => null]
        );
    }
}

Create a Controller

To work with draft entities, you must use the controller for the actions of non-draft entities.

For more details on how to create a controller and navigation, see the following guides:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
<?php

namespace ACME\Bundle\CMSBundle\Controller;

use ACME\Bundle\CMSBundle\Entity\Block;
use ACME\Bundle\CMSBundle\Form\Type\BlockType;
use Oro\Bundle\FormBundle\Model\UpdateHandler;
use Oro\Bundle\SecurityBundle\Annotation\Acl;
use Oro\Bundle\SecurityBundle\Annotation\AclAncestor;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * ACME CMS Block controller
 */
class BlockController extends AbstractController
{
    /**
     * @Route("/", name="acme_cms_block_index")
     * @Template
     * @AclAncestor("acme_cms_block_view")
     *
     * @return array
     */
    public function indexAction()
    {
        return ['entity_class' => Block::class];
    }

    /**
     * @Route("/view/{id}", name="acme_cms_block_view", requirements={"id"="\d+"})
     * @Template
     * @Acl(
     *      id="acme_cms_block_view",
     *      type="entity",
     *      class="ACMECMSBundle:Block",
     *      permission="VIEW"
     * )
     * @param Block $block
     * @return array
     */
    public function viewAction(Block $block)
    {
        return ['entity' => $block];
    }

    /**
     * @Route("/create", name="acme_cms_block_create")
     * @Template("ACMECMSBundle:Block:update.html.twig")
     * @Acl(
     *      id="acme_cms_block_create",
     *      type="entity",
     *      class="ACMECMSBundle:Block",
     *      permission="CREATE"
     * )
     *
     * @return array|RedirectResponse
     */
    public function createAction()
    {
        $block = new Block();

        return $this->update($block);
    }

    /**
     * @Route("/update/{id}", name="acme_cms_block_update", requirements={"id"="\d+"})
     * @Template
     * @Acl(
     *      id="acme_cms_block_update",
     *      type="entity",
     *      class="ACMECMSBundle:Block",
     *      permission="EDIT"
     * )
     * @param Block $block
     * @return array|RedirectResponse
     */
    public function updateAction(Block $block)
    {
        return $this->update($block);
    }

    /**
     * @param Block $block
     * @return array|RedirectResponse
     */
    protected function update(Block $block)
    {
        return $this->get(UpdateHandler::class)->handleUpdate(
            $block,
            $this->createForm(BlockType::class, $block),
            function (Block $block) {
                return [
                    'route' => 'acme_cms_block_update',
                    'parameters' => ['id' => $block->getId()]
                ];
            },
            function (Block $block) {
                return [
                    'route' => 'acme_cms_block_view',
                    'parameters' => ['id' => $block->getId()]
                ];
            },
            $this->get(TranslatorInterface::class)->trans('acme.cms.controller.saved.message')
        );
    }

    /**
     * {@inheritdoc}
     */
    public static function getSubscribedServices(): array
    {
        return array_merge(
            parent::getSubscribedServices(),
            [
                UpdateHandler::class,
                TranslatorInterface::class,
            ]
        );
    }
}

Note

A draft realizes the DraftKernelListener class. Listener receives the controller’s original argument, replaces it with a draft, and injects the argument to the controller again. It allows us to use a single entry point for all entities.

Add a Draft Grid

To manage the entity, we need to create a grid to display original and draft entities. Follow the instructions provided in the Configure grid topic for more details.

  1. Create a grid for entities
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
    acme-cms-block-grid:
        acl_resource: acme_cms_block_view
        options:
            entity_pagination: true
            entityHint: acme.cms.block.entity_label
        source:
            type: orm
            query:
                select:
                    - acme_cms_block.id
                    - acme_cms_block.title
                from:
                    - { table: 'ACMECMSBundle:Block', alias: acme_cms_block }
        columns:
            id:
                label: acme.cms.block.id.label
            title:
                label: acme.cms.block.title.label
        properties:
            id: ~
            view_link:
                type:   url
                route:  acme_cms_block_view
                params: [ id ]
        actions:
            view:
                type:          navigate
                label:         oro.grid.action.view
                link:          view_link
                icon:          eye
                acl_resource:  acme_cms_block_view
                rowAction:     true
  1. Create a grid for draft entities
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    acme-cms-block-drafts-grid:
        extends: acme-cms-block-grid
        options:
            entity_pagination: true
            entityHint: acme.cms.block.draft.entity_plural_label
            showDrafts: true
        source:
            query:
                select:
                    - CONCAT(draftOwner.firstName, ' ', draftOwner.lastName) as ownerName
                join:
                    left:
                        - { join: acme_cms_block.draftOwner, alias: draftOwner }
                where:
                    and:
                        - acme_cms_block.draftSource = :draft_source_id
                        - acme_cms_block.draftUuid IS NOT NULL
            bind_parameters:
                - draft_source_id
        columns:
            ownerName:
                label: acme.cms.block.draft.owner.label

Important

Use the showDrafts option in the grid configuration. This parameter is responsible for the enable/disable draft filter. This configuration changes the behavior of the grid, and the grid only reflects draft entities.

  1. Add grid operations to the grid with draft entities

Operations for the draft entities must be configured separately in action.yml.

Follow the instructions provided in the Work with Operations topic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    ACME_BLOCK_CREATE_DRAFT:
        extends: CREATE_DRAFT
        entities:
            - ACME\Bundle\CMSBundle\Entity\Block

    ACME_BLOCK_PUBLISH_DRAFT:
        extends: PUBLISH_DRAFT
        entities:
            - ACME\Bundle\CMSBundle\Entity\Block
        datagrids:
            - acme-cms-block-drafts-grid

    ACME_BLOCK_DUPLICATE_DRAFT:
        extends: DUPLICATE_DRAFT
        entities:
            - ACME\Bundle\CMSBundle\Entity\Block
        datagrids:
            - acme-cms-block-drafts-grid

    ACME_BLOCK_UPDATE_DRAFT:
        extends: UPDATE_DRAFT
        datagrids:
            - acme-cms-block-drafts-grid

    ACME_BLOCK_DELETE_DRAFT:
        extends: DELETE_DRAFT
        datagrids:
            - acme-cms-block-drafts-grid

Create a Form Type

Create a form type to handle the draft entity.

Follow the instructions provided in the The Form Type topic for more details.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace ACME\Bundle\CMSBundle\Form\Type;

use ACME\Bundle\CMSBundle\Entity\Block;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
 * Block form type
 */
class BlockType extends AbstractType
{
    public const NAME = 'acme_cms_block';

    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add(
                'title',
                TextType::class,
                [
                    'label' => 'Title',
                    'required' => true,
                ]
            )
            ->add(
                'content',
                TextareaType::class,
                [
                    'label' => 'Content',
                    'required' => true,
                ]
            );
    }

    /**
     * {@inheritDoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Block::class,
            'csrf_token_id' => 'acme_cms_block',
            'ownership_disabled' => true,
        ]);
    }

    /**
     * @return string
     */
    public function getName()
    {
        return $this->getBlockPrefix();
    }

    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return self::NAME;
    }
}

Use the Extension

Using the extension, you can change the property value when creating the draft entity. Follow the instructions provided in the How to use draft extension topic.

Take the following three steps to create an extension:

  1. Create a filter
  2. Create a matcher
  3. Create an extension

Create a Filter

Filter is a class that can modify the property value and be used in conjunction with Matcher.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace ACME\Bundle\CMSBundle\Duplicator\Filter;

use ACME\Bundle\CMSBundle\Entity\Block;
use Oro\Component\Duplicator\Filter\Filter;

/**
 * Modifies the value of the title field
 */
class UniqueTitleFilter implements Filter
{
    /**
     * @param Block $object
     * @param string $property
     * @param callable $objectCopier
     */
    public function apply($object, $property, $objectCopier): void
    {
        $resolvedField = sprintf('%s_%s', $object->getTitle(), uniqid());
        $object->setTitle($resolvedField);
    }
}

Create a Matcher

Matcher is a class that points to the field that Filter can work on. Matcher checks whether the specified field meets the criteria and applies the filter to this field.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace ACME\Bundle\CMSBundle\Duplicator\Matcher;

use ACME\Bundle\CMSBundle\Entity\Block;
use DeepCopy\Matcher\Matcher;

/**
 * Determines that a filter can be applied to the title property only
 */
class BlockTitleMatcher implements Matcher
{
    /**
     * @param Block $object
     * @param string $property
     *
     * @return bool
     */
    public function matches($object, $property): bool
    {
        return 'title' === $property;
    }
}

Create an Extension

Extension is a class that combines the logic of Filter and Matcher.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

namespace ACME\Bundle\CMSBundle\Duplicator\Extension;

use ACME\Bundle\CMSBundle\Duplicator\Filter\UniqueTitleFilter;
use ACME\Bundle\CMSBundle\Duplicator\Matcher\BlockTitleMatcher;
use ACME\Bundle\CMSBundle\Entity\Block;
use DeepCopy\Filter\Filter;
use DeepCopy\Matcher\Matcher;
use Oro\Bundle\DraftBundle\Duplicator\Extension\AbstractDuplicatorExtension;
use Oro\Bundle\DraftBundle\Entity\DraftableInterface;
use Oro\Bundle\DraftBundle\Manager\DraftManager;

/**
 * Responsible for copying behavior of Block type parameter.
 */
class BlockTitleExtension extends AbstractDuplicatorExtension
{
    /**
     * @return Filter
     */
    public function getFilter(): Filter
    {
        return new UniqueTitleFilter();
    }

    /**
     * @return Matcher
     */
    public function getMatcher(): Matcher
    {
        return new BlockTitleMatcher();
    }

    /**
     * @param DraftableInterface $source
     *
     * @return bool
     */
    public function isSupport(DraftableInterface $source): bool
    {
        return
            $this->getContext()->offsetGet('action') === DraftManager::ACTION_CREATE_DRAFT &&
            $source instanceof Block;
    }
}

Create a Template

Draft entities use the default templates, so you can use them. To identify a draft entity, you can create a block that shows whether the entity is a draft entity.

Follow the instructions provided in the Template topic.

1
2
3
4
5
6
7
8
9
{% block breadcrumbs %}
    {% import 'OroUIBundle::macros.html.twig' as UI %}
    {{ parent() }}
    {% if entity.draftUuid %}
        <span class="page-title-draft">
            {{ UI.badge('oro.draft.label'|trans, 'tentatively') }}
        </span>
    {% endif %}
{% endblock breadcrumbs %}