Enables snapshots for enhanced history and modification status for deeply nested ownership structures. It's solving an important UX issue with versioning, which is particularly visible in content blocks implementations.
This module enables the data model for snapshots. To take full advantage of its core offering, you should install silverstripe/versioned-snapshot-admin to expose these snapshots through the "History" tab of the CMS.
WARNING: This module is experimental, and not considered stable.
$ composer require silverstripe/versioned-snapshots
You'll also need to run dev/build
.
Imagine you have a content model that relies on an ownership structure, using the $owns
setting.
BlockPage
(has_many) Blocks
(has_one) Gallery
(many_many) Image
Ownership between each of those nodes affords publication of the entire graph through one commmand
(or click of a button). But it is not apparent to the user what owned content, if any, will
be published. If the Gallery is modified, BlockPage
will not show a modified state.
This module aims to make these modification states and implicit edit history more transparent.
Currently, rolling back a record that owns other content is not supported and will produce unexpected results. Further, comparing owned changes between two versions of a parent is not supported.
Yes, with few caveats:
many_many
relationships must use "through" objects. (implicit many_many is not versionable)- You will have to migrate all of your versioned content to snapshots (See Migrating from versioned)
- Some editing events may not be captured, particularly some provided by thirdparty modules. See (Adding your own snapshot creator)
- Does not (yet) fully work with Postgres. Pull requests welcome!
While the SnapshotPublishable
extension offers a large API surface, there are only a few primary methods
that are relevant to the end user:
$myDataObject->hasOwnedModifications(): bool
returns true if the record owns records that have changes$myDataObject->getPublishableObjects(): ArrayList
: returns a list ofDataObject
instances that will be published along with the owner.$myDataObject->getActivityFeed(): ArrayList
Provides a collection of objects that can be rendered on a template to create a human-readable activity feed. Returns an array ofActivityEntry
objects containing the following:Subject
: TheDataObject
record that instantiated the activityAction
: One of:CREATED
,MODIFIED
,DELETED
,ADDED
, orREMOVED
.Owner
: Only defined inmany_many
reltionships. Provides information on what the record was linked to. Informs theADDED
andREMOVED
actions.
The snapshot functionality is provided through the SnapshotPublishable
extension, which
is a drop-in replacement for RecursivePublishable
. By default, this module will replace
RecursivePublishable
, which is added to all dataobjects by silverstripe-versioned
, with
this custom subclass.
Snapshots are created with handlers registered to user events in the CMS triggered by
the silverstripe/cms-events
module.
By default, these events will trigger the message defined in the language file, e.g.
_t('SilverStripe\Snapshots\Handler\Form\FormSubmissionHandler.HANDLER_publish', 'Publish page')
. However, if you want
to customise this message at the configuration level, simply override the message on the handler class.
SilverStripe\Snapshots\Handler\Form\FormSubmissionHandler:
messages:
publish: 'My publish message'
In this case "publish" is the action identifier (the function that handles the form).
All of the handlers are registered with injector, so the simplest way to customise them is to override their definitions in the configuration.
For instance, if you have something custom you with a snapshot when a page is saved:
use SilverStripe\Snapshots\Handler\Form\SaveHandler;
use SilverStripe\EventDispatcher\Event\EventContextInterface;
use SilverStripe\Snapshots\Snapshot;
class MySaveHandler extends SaveHandler
{
protected function createSnapshot(EventContextInterface $context): ?Snapshot
{
//...
}
}
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Handler\Form\SaveHandler:
class: MyProject\MySaveHandler
If you have custom actions or form handlers you've added to the CMS, you might want to either ensure their tracked
by the default snapshot creators, or maybe even build your own snapshot creator for them. In this case, you can
use the declarative API on Dispatcher
to subscribe to the events you need.
Let's say we have a form that submits to a function: public function myFormHandler($data, $form)
.
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Dispatch\Dispatcher:
properties:
handlers:
myForm:
on:
- 'formSubmitted.myFormHandler'
handler: %$MyProject\Handlers\MyHandler
Notice that the event name is in the key of the configuration. This makes it possible for another layer of configuration to disable it. See below.
To remove an event from a handler, simply add it to the off
array.
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Dispatch\Dispatcher:
properties:
handlers:
myForm:
off:
- 'formSubmitted.myFormHandler'
You can register a EventHandlerLoader
implementation with Dispatcher
to procedurally register and unregister
events.
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Dispatch\Dispatcher:
properties:
loaders:
myLoader: %$MyProject\MyEventLoader
use SilverStripe\Snapshots\Dispatch\DispatcherLoaderInterface;
use SilverStripe\Snapshots\Dispatch\Dispatcher;
use SilverStripe\Snapshots\Handler\Form\SaveHandler;
class MyEventLoader implements DispatcherLoaderInterface
{
public function addToDispatcher(Dispatcher $dispatcher): void
{
$dispatcher->removeListenerByClassName('formSubmitted.save', SaveHandler::class);
}
}
To cover all cases, this module allows you to invoke snapshot creation in any part of your code outside of normal action flow.
When you want to create a snapshot just call createSnapshot
function like this:
Snapshot::singleton()->createSnapshot(DataObject $origin, array $extraObjects = []);
$origin
is the object which should be matching the action, i.e. the action is changing the origin object.
$extraObjects
is an array of extra dataobjects you want to be in the snapshot. Every call to createSnapshot
implicitly includes the following records in addition to the origin:
- All of the records the origin is "owned" by, e.g.
BlockImage > BaseElement > ElementalArea > Page
- All of the records the origin has implicitly modified. (See Implicit modifications)
Some modifications to your content aren't necessarily triggered by editing event to a specific entity. For these cases, you can use the createSnapshotFromEvent
API.
Snapshot::singleton()->createSnapshotFromEvent('Description of event');
Examples of generic events include reordering the site tree, copying translations, importing content, and more. Think of it as a simple "git commit" message for your content. It creates a marker on your timeline that content editors can refer back to at some point in the future.
Sometimes edits to the record that appears to be the "origin" are implicitly edits to other records. The most common case of this is adding related records. If a user makes a change to a CheckboxSetField
that manages a many_many
relation, for instance, the record that displays those checkboxes remains unchanged and does not merit a new version. The addition or removal of new related records, however,
does merit a new snapshot as the ownership chain has been updated.
The createSnapshot
API is aware of these kinds of modifications, and attempts to detect them using the RelationDiffer
service. When a modification includes changes to relationships, createSnapshot
will fallback to create a generic event that describes what changes happened, for instance: 'Added two categories'
.
This relation diffing is expensive to run on every save for every relationship, however, and therefore, you need to opt-in to it using the $snapshot_relation_tracking
setting.
class Product extends DataObject
{
private static $many_many = [
'Categories' => Category::class,
];
private static $snapshot_relation_tracking = ['Categories'];
}
Another common example of implicit modifications is the ElementalEditor
field in silverstripe-elemental version 4.x. When the page is saved, it actually saves all the blocks in the editor, which are has_many
relations. Because it is such a common use case, blocks are tracked in snapshot_relation_tracking
by default, so that page saves will result in "Modified/added/deleted block" snapshots where appropriate.
To migrate all your _versions
tables to snapshots, use the snapshot-migration
task:
$ vendor/bin/sake dev/tasks/snapshot-migration
Alternatively, this task is available as a queued job.
The task should be fairly low-impact, as it only writes to the new (and presumably empty) snapshots tables. It should also perform at scale, since it doesn't do any processing of the records in PHP. The migration is pure SQL.
Some common thirdparty modules are supported out of the box. The most notable is silverstripe-elemental, which has several specific snapshot creators installed by default, including:
- Archive element
- Save individual element
- Create element (GraphQL query)
- Edit individual element
- Save all elements via page save
- Sort elements
- ModelAdmin and GridField CSV imports
As mentioned above, elements all receive snapshot_relation_tracking
on their pages by default, as well.
Another module that is supported out of the box is GridFieldExtensions. A handler is provided
for its GridFieldOrderableRows
component.
This library follows Semver. According to Semver, you will be able to upgrade to any minor or patch version of this library without any breaking changes to the public API. Semver also requires that we clearly define the public API for this library.
All methods, with public
visibility, are part of the public API. All
other methods are not part of the public API. Where possible, we'll try
to keep protected
methods backwards-compatible in minor/patch versions,
but if you're overriding methods then please test your work before upgrading.
Please create an issue for any bugs you've found, or features you're missing.
This module is released under the BSD 3-Clause License