Browse Source

Merge pull request #2266 from nlzet/add_entity_audit_compare

Add Entity Audit diff to support revision comparison
Thomas 11 years ago
parent
commit
ef1e5d3f38

+ 60 - 3
Controller/CRUDController.php

@@ -700,9 +700,10 @@ class CRUDController extends Controller
         $revisions = $reader->findRevisions($this->admin->getClass(), $id);
 
         return $this->render($this->admin->getTemplate('history'), array(
-            'action'    => 'history',
-            'object'    => $object,
-            'revisions' => $revisions,
+            'action'            => 'history',
+            'object'            => $object,
+            'revisions'         => $revisions,
+            'currentRevision'   => $revisions ? current($revisions) : false,
         ));
     }
 
@@ -755,6 +756,62 @@ class CRUDController extends Controller
         ));
     }
 
+    /**
+     * Compare history revisions of object
+     *
+     * @param int|string|null $id
+     * @param int|string|null $base_revision
+     * @param int|string|null $compare_revision
+     *
+     * @return Response
+     *
+     * @throws AccessDeniedException If access is not granted
+     * @throws NotFoundHttpException If the object or revision does not exist or the audit reader is not available
+     */
+    public function historyCompareRevisionsAction($id = null, $base_revision = null, $compare_revision = null)
+    {
+        if (false === $this->admin->isGranted('EDIT')) {
+            throw new AccessDeniedException();
+        }
+
+        $id = $this->get('request')->get($this->admin->getIdParameter());
+
+        $object = $this->admin->getObject($id);
+
+        if (!$object) {
+            throw new NotFoundHttpException(sprintf('unable to find the object with id : %s', $id));
+        }
+
+        $manager = $this->get('sonata.admin.audit.manager');
+
+        if (!$manager->hasReader($this->admin->getClass())) {
+            throw new NotFoundHttpException(sprintf('unable to find the audit reader for class : %s', $this->admin->getClass()));
+        }
+
+        $reader = $manager->getReader($this->admin->getClass());
+
+        // retrieve the base revision
+        $base_object = $reader->find($this->admin->getClass(), $id, $base_revision);
+        if (!$base_object) {
+            throw new NotFoundHttpException(sprintf('unable to find the targeted object `%s` from the revision `%s` with classname : `%s`', $id, $base_revision, $this->admin->getClass()));
+        }
+
+        // retrieve the compare revision
+        $compare_object = $reader->find($this->admin->getClass(), $id, $compare_revision);
+        if (!$compare_object) {
+            throw new NotFoundHttpException(sprintf('unable to find the targeted object `%s` from the revision `%s` with classname : `%s`', $id, $compare_revision, $this->admin->getClass()));
+        }
+
+        $this->admin->setSubject($base_object);
+
+        return $this->render($this->admin->getTemplate('show_compare'), array(
+            'action'            => 'show',
+            'object'            => $base_object,
+            'object_compare'    => $compare_object,
+            'elements'          => $this->admin->getShow()
+        ));
+    }
+
     /**
      * Export data to specified format
      *

+ 1 - 0
DependencyInjection/Compiler/AddDependencyCallsCompilerPass.php

@@ -284,6 +284,7 @@ class AddDependencyCallsCompilerPass implements CompilerPassInterface
             'list'                     => 'SonataAdminBundle:CRUD:list.html.twig',
             'filter'                   => 'SonataAdminBundle:Form:filter_admin_fields.html.twig',
             'show'                     => 'SonataAdminBundle:CRUD:show.html.twig',
+            'show_compare'             => 'SonataAdminBundle:CRUD:show_compare.html.twig',
             'edit'                     => 'SonataAdminBundle:CRUD:edit.html.twig',
             'history'                  => 'SonataAdminBundle:CRUD:history.html.twig',
             'history_revision_timestamp' => 'SonataAdminBundle:CRUD:history_revision_timestamp.html.twig',

+ 1 - 0
DependencyInjection/Configuration.php

@@ -163,6 +163,7 @@ class Configuration implements ConfigurationInterface
                         ->scalarNode('list')->defaultValue('SonataAdminBundle:CRUD:list.html.twig')->cannotBeEmpty()->end()
                         ->scalarNode('filter')->defaultValue('SonataAdminBundle:Form:filter_admin_fields.html.twig')->cannotBeEmpty()->end()
                         ->scalarNode('show')->defaultValue('SonataAdminBundle:CRUD:show.html.twig')->cannotBeEmpty()->end()
+                        ->scalarNode('show_compare')->defaultValue('SonataAdminBundle:CRUD:show_compare.html.twig')->cannotBeEmpty()->end()
                         ->scalarNode('edit')->defaultValue('SonataAdminBundle:CRUD:edit.html.twig')->cannotBeEmpty()->end()
                         ->scalarNode('preview')->defaultValue('SonataAdminBundle:CRUD:preview.html.twig')->cannotBeEmpty()->end()
                         ->scalarNode('history')->defaultValue('SonataAdminBundle:CRUD:history.html.twig')->cannotBeEmpty()->end()

+ 1 - 0
Resources/doc/reference/configuration.rst

@@ -105,6 +105,7 @@ Full Configuration Options
                 list:                 SonataAdminBundle:CRUD:list.html.twig
                 filter:               SonataAdminBundle:Form:filter_admin_fields.html.twig
                 show:                 SonataAdminBundle:CRUD:show.html.twig
+                show_compare:         SonataAdminBundle:CRUD:show_compare.html.twig
                 edit:                 SonataAdminBundle:CRUD:edit.html.twig
                 preview:              SonataAdminBundle:CRUD:preview.html.twig
                 history:              SonataAdminBundle:CRUD:history.html.twig

+ 28 - 27
Resources/doc/reference/templates.rst

@@ -110,33 +110,34 @@ You can specify your templates in the config.yml file, like so:
 
         sonata_admin:
             templates:
-                layout:  SonataAdminBundle::standard_layout.html.twig
-                ajax:    SonataAdminBundle::ajax_layout.html.twig
-                list:    SonataAdminBundle:CRUD:list.html.twig
-                show:    SonataAdminBundle:CRUD:show.html.twig
-                edit:    SonataAdminBundle:CRUD:edit.html.twig
-                history: SonataAdminBundle:CRUD:history.html.twig
-                preview: SonataAdminBundle:CRUD:preview.html.twig
-                delete:  SonataAdminBundle:CRUD:delete.html.twig
-                batch:   SonataAdminBundle:CRUD:list__batch.html.twig
-                acl:     SonataAdminBundle:CRUD:acl.html.twig
-                action:  SonataAdminBundle:CRUD:action.html.twig
-                select:  SonataAdminBundle:CRUD:list__select.html.twig
-                filter:  SonataAdminBundle:Form:filter_admin_fields.html.twig
-                dashboard:           SonataAdminBundle:Core:dashboard.html.twig
-                search:              SonataAdminBundle:Core:search.html.twig
-                batch_confirmation:  SonataAdminBundle:CRUD:batch_confirmation.html.twig
-                inner_list_row:      SonataAdminBundle:CRUD:list_inner_row.html.twig
-                base_list_field:     SonataAdminBundle:CRUD:base_list_field.html.twig
-                list_block:          SonataAdminBundle:Block:block_admin_list.html.twig
-                user_block:          SonataAdminBundle:Core:user_block.html.twig
-                add_block:           SonataAdminBundle:Core:add_block.html.twig
-                pager_links:         SonataAdminBundle:Pager:links.html.twig
-                pager_results:       SonataAdminBundle:Pager:results.html.twig
-                tab_menu_template:   SonataAdminBundle:Core:tab_menu_template.html.twig
-                history_revision_timestamp:  SonataAdminBundle:CRUD:history_revision_timestamp.html.twig
-                short_object_description:    SonataAdminBundle:Helper:short-object-description.html.twig
-                search_result_block: SonataAdminBundle:Block:block_search_result.html.twig
+                layout:                         SonataAdminBundle::standard_layout.html.twig
+                ajax:                           SonataAdminBundle::ajax_layout.html.twig
+                list:                           SonataAdminBundle:CRUD:list.html.twig
+                show:                           SonataAdminBundle:CRUD:show.html.twig
+                show_compare:                   SonataAdminBundle:CRUD:show_compare.html.twig
+                edit:                           SonataAdminBundle:CRUD:edit.html.twig
+                history:                        SonataAdminBundle:CRUD:history.html.twig
+                preview:                        SonataAdminBundle:CRUD:preview.html.twig
+                delete:                         SonataAdminBundle:CRUD:delete.html.twig
+                batch:                          SonataAdminBundle:CRUD:list__batch.html.twig
+                acl:                            SonataAdminBundle:CRUD:acl.html.twig
+                action:                         SonataAdminBundle:CRUD:action.html.twig
+                select:                         SonataAdminBundle:CRUD:list__select.html.twig
+                filter:                         SonataAdminBundle:Form:filter_admin_fields.html.twig
+                dashboard:                      SonataAdminBundle:Core:dashboard.html.twig
+                search:                         SonataAdminBundle:Core:search.html.twig
+                batch_confirmation:             SonataAdminBundle:CRUD:batch_confirmation.html.twig
+                inner_list_row:                 SonataAdminBundle:CRUD:list_inner_row.html.twig
+                base_list_field:                SonataAdminBundle:CRUD:base_list_field.html.twig
+                list_block:                     SonataAdminBundle:Block:block_admin_list.html.twig
+                user_block:                     SonataAdminBundle:Core:user_block.html.twig
+                add_block:                      SonataAdminBundle:Core:add_block.html.twig
+                pager_links:                    SonataAdminBundle:Pager:links.html.twig
+                pager_results:                  SonataAdminBundle:Pager:results.html.twig
+                tab_menu_template:              SonataAdminBundle:Core:tab_menu_template.html.twig
+                history_revision_timestamp:     SonataAdminBundle:CRUD:history_revision_timestamp.html.twig
+                short_object_description:       SonataAdminBundle:Helper:short-object-description.html.twig
+                search_result_block:            SonataAdminBundle:Block:block_search_result.html.twig
 
 Notice that this is a global change, meaning it will affect all model mappings automatically,
 both for ``Admin`` mappings defined by you and by other bundles.

+ 11 - 0
Resources/public/css/layout.css

@@ -242,6 +242,17 @@ div.sonata-medium-date div {
     background-color: #f5f5f5;
 }
 
+.sonata-ba-view-container.history-audit-compare th {
+     width: 10%;
+}
+
+.sonata-ba-view-container.history-audit-compare td {
+     width: 40%;
+}
+.sonata-ba-view-container.history-audit-compare th.diff {
+     background: pink;
+}
+
 .container-fluid > .sidebar {
     top: auto;
 }

+ 8 - 0
Resources/translations/SonataAdminBundle.en.xliff

@@ -302,6 +302,10 @@
                 <source>td_action</source>
                 <target>Action</target>
             </trans-unit>
+            <trans-unit id="td_compare">
+                <source>td_compare</source>
+                <target>Compare</target>
+            </trans-unit>
             <trans-unit id="td_revision">
                 <source>td_revision</source>
                 <target>Revisions</target>
@@ -318,6 +322,10 @@
                 <source>label_view_revision</source>
                 <target>View Revision</target>
             </trans-unit>
+            <trans-unit id="label_compare_revision">
+                <source>label_compare_revision</source>
+                <target>Compare revision</target>
+            </trans-unit>
             <trans-unit id="list_results_count">
                 <source>list_results_count</source>
                 <target>1 result|%count% results</target>

+ 8 - 0
Resources/translations/SonataAdminBundle.nl.xliff

@@ -302,6 +302,10 @@
                 <source>td_action</source>
                 <target>Handeling</target>
             </trans-unit>
+            <trans-unit id="td_compare">
+                <source>td_compare</source>
+                <target>Vergelijk</target>
+            </trans-unit>
             <trans-unit id="td_revision">
                 <source>td_revision</source>
                 <target>Revisies</target>
@@ -318,6 +322,10 @@
                 <source>label_view_revision</source>
                 <target>Toon revisie</target>
             </trans-unit>
+            <trans-unit id="label_compare_revision">
+                <source>label_compare_revision</source>
+                <target>Vergelijk revisie</target>
+            </trans-unit>
             <trans-unit id="list_results_count">
                 <source>list_results_count</source>
                 <target>1 resultaat|%count% resultaten</target>

+ 21 - 5
Resources/views/CRUD/base_history.html.twig

@@ -28,15 +28,23 @@ file that was distributed with this source code.
                     <th>{{ "td_timestamp"|trans({}, 'SonataAdminBundle') }}</th>
                     <th>{{ "td_username"|trans({}, 'SonataAdminBundle') }}</th>
                     <th>{{ "td_action"|trans({}, 'SonataAdminBundle') }}</th>
+                    <th>{{ "td_compare"|trans({}, 'SonataAdminBundle') }}</th>
                 </tr>
             </thead>
             <tbody>
                 {% for revision in revisions %}
-                    <tr>
+                    <tr class="{% if (currentRevision != false and revision.rev == currentRevision.rev) %}current-revision{% endif %}">
                         <td>{{ revision.rev}}</td>
                         <td>{% include admin.getTemplate('history_revision_timestamp') %}</td>
                         <td>{{ revision.username}}</td>
                         <td><a href="{{ admin.generateObjectUrl('history_view_revision', object, {'revision': revision.rev }) }}" class="revision-link" rel="{{ revision.rev }}">{{ "label_view_revision"|trans({}, 'SonataAdminBundle') }}</a></td>
+                        <td>
+                            {% if (currentRevision == false or revision.rev == currentRevision.rev) %}
+                                /
+                            {% else %}
+                                <a href="{{ admin.generateObjectUrl('history_compare_revisions', object, {'base_revision': currentRevision.rev, 'compare_revision': revision.rev }) }}" class="revision-compare-link" rel="{{ revision.rev }}">{{ "label_compare_revision"|trans({}, 'SonataAdminBundle') }}</a>
+                            {% endif %}
+                        </td>
                     </tr>
                 {% endfor %}
             </tbody>
@@ -49,13 +57,20 @@ file that was distributed with this source code.
     <script>
         jQuery(document).ready(function() {
 
-            jQuery('a.revision-link').bind('click', function(event) {
+            jQuery('a.revision-link, a.revision-compare-link').bind('click', function(event) {
                 event.stopPropagation();
                 event.preventDefault();
 
+                action = jQuery(this).hasClass('revision-link')
+                    ? 'show'
+                    : 'compare';
+
                 jQuery('#revision-detail').html('');
-                jQuery('table#revisions tbody tr').removeClass('current');
-                jQuery(this).parent('').removeClass('current');
+
+                if(action == 'show'){
+                    jQuery('table#revisions tbody tr').removeClass('current');
+                    jQuery(this).parent('').removeClass('current');
+                }
 
                 jQuery.ajax({
                     url: jQuery(this).attr('href'),
@@ -66,7 +81,8 @@ file that was distributed with this source code.
                 });
 
                 return false;
-            })
+            });
+
         });
     </script>
 {% endblock %}

+ 14 - 10
Resources/views/CRUD/base_show.html.twig

@@ -29,21 +29,25 @@ file that was distributed with this source code.
             <table class="table table-bordered">
                 {% if name %}
                     <thead>
-                        <tr class="sonata-ba-view-title">
-                            <th colspan="2">
-                                {{ admin.trans(name) }}
-                            </th>
-                        </tr>
+                        {% block show_title %}
+                            <tr class="sonata-ba-view-title">
+                                <th colspan="2">
+                                    {{ admin.trans(name) }}
+                                </th>
+                            </tr>
+                        {% endblock %}
                     </thead>
                 {% endif %}
 
                 <tbody>
                     {% for field_name in view_group.fields %}
-                        <tr class="sonata-ba-view-container">
-                            {% if elements[field_name] is defined %}
-                                {{ elements[field_name]|render_view_element(object) }}
-                            {% endif %}
-                        </tr>
+                        {% block show_field %}
+                            <tr class="sonata-ba-view-container">
+                                {% if elements[field_name] is defined %}
+                                    {{ elements[field_name]|render_view_element(object) }}
+                                {% endif %}
+                            </tr>
+                        {% endblock %}
                     {% endfor %}
                 </tbody>
             </table>

+ 28 - 0
Resources/views/CRUD/base_show_compare.html.twig

@@ -0,0 +1,28 @@
+{#
+
+This file is part of the Sonata package.
+
+(c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+
+For the full copyright and license information, please view the LICENSE
+file that was distributed with this source code.
+
+#}
+
+{% extends 'SonataAdminBundle:CRUD:base_show.html.twig' %}
+
+{% block show_title %}
+    <tr class="sonata-ba-view-title">
+        <th colspan="3">
+            {{ admin.trans(name) }}
+        </th>
+    </tr>
+{% endblock %}
+
+{% block show_field %}
+    <tr class="sonata-ba-view-container history-audit-compare">
+        {% if elements[field_name] is defined %}
+            {{ elements[field_name]|render_view_element_compare(object, object_compare) }}
+        {% endif %}
+    </tr>
+{% endblock %}

+ 10 - 1
Resources/views/CRUD/base_show_field.html.twig

@@ -9,5 +9,14 @@ file that was distributed with this source code.
 
 #}
 
-<th>{% block name %}{{ admin.trans(field_description.label, {}, field_description.translationDomain) }}{% endblock %}</th>
+<th{% if(is_diff|default(false)) %} class="diff"{% endif %}>{% block name %}{{ admin.trans(field_description.label, {}, field_description.translationDomain) }}{% endblock %}</th>
 <td>{% block field %}{% if field_description.options.safe %}{{ value|raw }}{% else %}{{ value|nl2br }}{% endif %}{% endblock %}</td>
+
+{% block field_compare %}
+    {% if(value_compare is defined) %}
+        <td>
+            {% set value = value_compare %}
+            {{ block('field') }}
+        </td>
+    {% endif %}
+{% endblock %}

+ 12 - 0
Resources/views/CRUD/show_compare.html.twig

@@ -0,0 +1,12 @@
+{#
+
+This file is part of the Sonata package.
+
+(c) Thomas Rabaix <thomas.rabaix@sonata-project.org>
+
+For the full copyright and license information, please view the LICENSE
+file that was distributed with this source code.
+
+#}
+
+{% extends 'SonataAdminBundle:CRUD:base_show_compare.html.twig' %}

+ 1 - 0
Route/PathInfoBuilder.php

@@ -43,6 +43,7 @@ class PathInfoBuilder implements RouteBuilderInterface
         if ($this->manager->hasReader($admin->getClass())) {
             $collection->add('history', $admin->getRouterIdParameter().'/history');
             $collection->add('history_view_revision', $admin->getRouterIdParameter().'/history/{revision}/view');
+            $collection->add('history_compare_revisions', $admin->getRouterIdParameter().'/history/{base_revision}/{compare_revision}/compare');
         }
 
         if ($admin->isAclEnabled()) {

+ 1 - 0
Route/QueryStringBuilder.php

@@ -43,6 +43,7 @@ class QueryStringBuilder implements RouteBuilderInterface
         if ($this->manager->hasReader($admin->getClass())) {
             $collection->add('history', '/audit-history');
             $collection->add('history_view_revision', '/audit-history-view');
+            $collection->add('history_compare_revisions', '/audit-history-compare');
         }
 
         if ($admin->isAclEnabled()) {

+ 225 - 0
Tests/Controller/CRUDControllerTest.php

@@ -258,6 +258,8 @@ class CRUDControllerTest extends \PHPUnit_Framework_TestCase
                         return 'SonataAdminBundle::standard_layout.html.twig';
                     case 'show':
                         return 'SonataAdminBundle:CRUD:show.html.twig';
+                    case 'show_compare':
+                        return 'SonataAdminBundle:CRUD:show_compare.html.twig';
                     case 'edit':
                         return 'SonataAdminBundle:CRUD:edit.html.twig';
                     case 'dashboard':
@@ -2245,6 +2247,229 @@ class CRUDControllerTest extends \PHPUnit_Framework_TestCase
         $this->assertEquals('SonataAdminBundle:CRUD:show.html.twig', $this->template);
     }
 
+    public function testhistoryCompareRevisionsActionAccessDenied()
+    {
+        $this->setExpectedException('Symfony\Component\Security\Core\Exception\AccessDeniedException');
+
+        $this->admin->expects($this->once())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(false));
+
+        $this->controller->historyCompareRevisionsAction();
+    }
+
+    public function testhistoryCompareRevisionsActionNotFoundException()
+    {
+        $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException', 'unable to find the object with id : 123');
+
+        $this->request->query->set('id', 123);
+
+        $this->admin->expects($this->once())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(true));
+
+        $this->admin->expects($this->once())
+            ->method('getObject')
+            ->will($this->returnValue(false));
+
+        $this->controller->historyCompareRevisionsAction();
+    }
+
+    public function testhistoryCompareRevisionsActionNoReader()
+    {
+        $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException', 'unable to find the audit reader for class : Foo');
+
+        $this->request->query->set('id', 123);
+
+        $this->admin->expects($this->once())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(true));
+
+        $object = new \stdClass();
+
+        $this->admin->expects($this->once())
+            ->method('getObject')
+            ->will($this->returnValue($object));
+
+        $this->admin->expects($this->any())
+            ->method('getClass')
+            ->will($this->returnValue('Foo'));
+
+        $this->auditManager->expects($this->once())
+            ->method('hasReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue(false));
+
+        $this->controller->historyCompareRevisionsAction();
+    }
+
+    public function testhistoryCompareRevisionsActionNotFoundBaseRevision()
+    {
+        $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException', 'unable to find the targeted object `123` from the revision `456` with classname : `Foo`');
+
+        $this->request->query->set('id', 123);
+
+        $this->admin->expects($this->once())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(true));
+
+        $object = new \stdClass();
+
+        $this->admin->expects($this->once())
+            ->method('getObject')
+            ->will($this->returnValue($object));
+
+        $this->admin->expects($this->any())
+            ->method('getClass')
+            ->will($this->returnValue('Foo'));
+
+        $this->auditManager->expects($this->once())
+            ->method('hasReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue(true));
+
+        $reader = $this->getMock('Sonata\AdminBundle\Model\AuditReaderInterface');
+
+        $this->auditManager->expects($this->once())
+            ->method('getReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue($reader));
+
+        // once because it will not be found and therefore the second call won't be executed
+        $reader->expects($this->once())
+            ->method('find')
+            ->with($this->equalTo('Foo'), $this->equalTo(123), $this->equalTo(456))
+            ->will($this->returnValue(null));
+
+        $this->controller->historyCompareRevisionsAction(123, 456, 789);
+    }
+
+    public function testhistoryCompareRevisionsActionNotFoundCompareRevision()
+    {
+        $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException', 'unable to find the targeted object `123` from the revision `789` with classname : `Foo`');
+
+        $this->request->query->set('id', 123);
+
+        $this->admin->expects($this->once())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(true));
+
+        $object = new \stdClass();
+
+        $this->admin->expects($this->once())
+            ->method('getObject')
+            ->will($this->returnValue($object));
+
+        $this->admin->expects($this->any())
+            ->method('getClass')
+            ->will($this->returnValue('Foo'));
+
+        $this->auditManager->expects($this->once())
+            ->method('hasReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue(true));
+
+        $reader = $this->getMock('Sonata\AdminBundle\Model\AuditReaderInterface');
+
+        $this->auditManager->expects($this->once())
+            ->method('getReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue($reader));
+
+        $objectRevision = new \stdClass();
+        $objectRevision->revision = 456;
+
+        // first call should return, so the second call will throw an exception
+        $reader->expects($this->at(0))
+            ->method('find')
+            ->with($this->equalTo('Foo'), $this->equalTo(123), $this->equalTo(456))
+            ->will($this->returnValue($objectRevision));
+
+        $reader->expects($this->at(1))
+            ->method('find')
+            ->with($this->equalTo('Foo'), $this->equalTo(123), $this->equalTo(789))
+            ->will($this->returnValue(null));
+
+        $this->controller->historyCompareRevisionsAction(123, 456, 789);
+    }
+
+    public function testhistoryCompareRevisionsActionAction()
+    {
+        $this->request->query->set('id', 123);
+
+        $this->admin->expects($this->once())
+            ->method('isGranted')
+            ->with($this->equalTo('EDIT'))
+            ->will($this->returnValue(true));
+
+        $object = new \stdClass();
+
+        $this->admin->expects($this->once())
+            ->method('getObject')
+            ->will($this->returnValue($object));
+
+        $this->admin->expects($this->any())
+            ->method('getClass')
+            ->will($this->returnValue('Foo'));
+
+        $this->auditManager->expects($this->once())
+            ->method('hasReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue(true));
+
+        $reader = $this->getMock('Sonata\AdminBundle\Model\AuditReaderInterface');
+
+        $this->auditManager->expects($this->once())
+            ->method('getReader')
+            ->with($this->equalTo('Foo'))
+            ->will($this->returnValue($reader));
+
+        $objectRevision = new \stdClass();
+        $objectRevision->revision = 456;
+
+        $compareObjectRevision = new \stdClass();
+        $compareObjectRevision->revision = 789;
+
+        $reader->expects($this->at(0))
+            ->method('find')
+            ->with($this->equalTo('Foo'), $this->equalTo(123), $this->equalTo(456))
+            ->will($this->returnValue($objectRevision));
+
+        $reader->expects($this->at(1))
+            ->method('find')
+            ->with($this->equalTo('Foo'), $this->equalTo(123), $this->equalTo(789))
+            ->will($this->returnValue($compareObjectRevision));
+
+        $this->admin->expects($this->once())
+            ->method('setSubject')
+            ->with($this->equalTo($objectRevision))
+            ->will($this->returnValue(null));
+
+        $fieldDescriptionCollection = new FieldDescriptionCollection();
+        $this->admin->expects($this->once())
+            ->method('getShow')
+            ->will($this->returnValue($fieldDescriptionCollection));
+
+        $this->assertInstanceOf('Symfony\Component\HttpFoundation\Response', $this->controller->historyCompareRevisionsAction(123, 456, 789));
+
+        $this->assertEquals($this->admin, $this->parameters['admin']);
+        $this->assertEquals('SonataAdminBundle::standard_layout.html.twig', $this->parameters['base_template']);
+        $this->assertEquals($this->pool, $this->parameters['admin_pool']);
+
+        $this->assertEquals('show', $this->parameters['action']);
+        $this->assertEquals($objectRevision, $this->parameters['object']);
+        $this->assertEquals($compareObjectRevision, $this->parameters['object_compare']);
+        $this->assertEquals($fieldDescriptionCollection, $this->parameters['elements']);
+
+        $this->assertEquals(array(), $this->session->getFlashBag()->all());
+        $this->assertEquals('SonataAdminBundle:CRUD:show_compare.html.twig', $this->template);
+    }
+
     public function testBatchActionWrongMethod()
     {
         $this->setExpectedException('Symfony\Component\HttpKernel\Exception\NotFoundHttpException', 'Invalid request type "GET", POST expected');

+ 1 - 1
Tests/Route/PathInfoBuilderTest.php

@@ -32,6 +32,6 @@ class PathInfoBuilderTest extends \PHPUnit_Framework_TestCase
 
         $pathBuilder->build($admin, $routeCollection);
 
-        $this->assertCount(10, $routeCollection->getElements());
+        $this->assertCount(11, $routeCollection->getElements());
     }
 }

+ 4 - 4
Tests/Route/QueryStringBuilderTest.php

@@ -45,10 +45,10 @@ class QueryStringBuilderTest extends \PHPUnit_Framework_TestCase
     public function getBuildTests()
     {
         return array(
-            array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'acl'), true, true, null),
+            array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'history_compare_revisions', 'acl'), true, true, null),
             array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'acl'), false, true, null),
-            array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision'), true, false, null),
-            array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'acl'), true, true, $this->getMock('Sonata\AdminBundle\Admin\AdminInterface')),
+            array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'history_compare_revisions'), true, false, null),
+            array(array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'history_compare_revisions', 'acl'), true, true, $this->getMock('Sonata\AdminBundle\Admin\AdminInterface')),
         );
     }
 
@@ -81,7 +81,7 @@ class QueryStringBuilderTest extends \PHPUnit_Framework_TestCase
 
         $pathBuilder->build($admin, $routeCollection);
 
-        $expectedRoutes = array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'acl', 'child1.Code.Route.foo', 'child1.Code.Route.bar', 'child2.Code.Route.baz');
+        $expectedRoutes = array('list', 'create', 'batch', 'edit', 'delete', 'show', 'export', 'history', 'history_view_revision', 'history_compare_revisions', 'acl', 'child1.Code.Route.foo', 'child1.Code.Route.bar', 'child2.Code.Route.baz');
         $this->assertCount(count($expectedRoutes), $routeCollection->getElements());
 
         foreach ($expectedRoutes as $expectedRoute) {

+ 55 - 5
Twig/Extension/SonataAdminExtension.php

@@ -51,11 +51,12 @@ class SonataAdminExtension extends \Twig_Extension
     public function getFilters()
     {
         return array(
-            'render_list_element'     => new \Twig_Filter_Method($this, 'renderListElement', array('is_safe' => array('html'))),
-            'render_view_element'     => new \Twig_Filter_Method($this, 'renderViewElement', array('is_safe' => array('html'))),
-            'render_relation_element' => new \Twig_Filter_Method($this, 'renderRelationElement'),
-            'sonata_urlsafeid'        => new \Twig_Filter_Method($this, 'getUrlsafeIdentifier'),
-            'sonata_xeditable_type'   => new \Twig_Filter_Method($this, 'getXEditableType'),
+            'render_list_element'           => new \Twig_Filter_Method($this, 'renderListElement', array('is_safe' => array('html'))),
+            'render_view_element'           => new \Twig_Filter_Method($this, 'renderViewElement', array('is_safe' => array('html'))),
+            'render_view_element_compare'   => new \Twig_Filter_Method($this, 'renderViewElementCompare', array('is_safe' => array('html'))),
+            'render_relation_element'       => new \Twig_Filter_Method($this, 'renderRelationElement'),
+            'sonata_urlsafeid'              => new \Twig_Filter_Method($this, 'getUrlsafeIdentifier'),
+            'sonata_xeditable_type'         => new \Twig_Filter_Method($this, 'getXEditableType'),
         );
     }
 
@@ -195,6 +196,55 @@ class SonataAdminExtension extends \Twig_Extension
         ));
     }
 
+    /**
+     * render a compared view element
+     *
+     * @param FieldDescriptionInterface $fieldDescription
+     * @param mixed                     $baseObject
+     * @param mixed                     $compareObject
+     *
+     * @return string
+     */
+    public function renderViewElementCompare(FieldDescriptionInterface $fieldDescription, $baseObject, $compareObject)
+    {
+        $template = $this->getTemplate($fieldDescription, 'SonataAdminBundle:CRUD:base_show_field.html.twig');
+
+        try {
+            $baseValue = $fieldDescription->getValue($baseObject);
+        } catch (NoValueException $e) {
+            $baseValue = null;
+        }
+
+        try {
+            $compareValue = $fieldDescription->getValue($compareObject);
+        } catch (NoValueException $e) {
+            $compareValue = null;
+        }
+
+        $baseValueOutput = $template->render(array(
+            'admin'             => $fieldDescription->getAdmin(),
+            'field_description' => $fieldDescription,
+            'value'             => $baseValue
+        ));
+
+        $compareValueOutput = $template->render(array(
+            'field_description' => $fieldDescription,
+            'admin'             => $fieldDescription->getAdmin(),
+            'value'             => $compareValue
+        ));
+
+        // Compare the rendered output of both objects by using the (possibly) overridden field block
+        $isDiff = $baseValueOutput !== $compareValueOutput;
+
+        return $this->output($fieldDescription, $template, array(
+            'field_description' => $fieldDescription,
+            'value'             => $baseValue,
+            'value_compare'     => $compareValue,
+            'is_diff'           => $isDiff,
+            'admin'             => $fieldDescription->getAdmin()
+        ));
+    }
+
     /**
      * @throws \RunTimeException
      *