Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Snapshots 2.0 release candidate #4

Open
wants to merge 16 commits into
base: feature/rework
Choose a base branch
from
3 changes: 0 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ env:
matrix:
fast_finish: true
include:
- php: &min-version '7.1'
env: DB=MYSQL PHPUNIT_TEST=1

- php: '7.2'
env: DB=MYSQL PHPUNIT_TEST=1

Expand Down
314 changes: 78 additions & 236 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ which is particularly visible in [content blocks](https://github.com/dnadesign/s

This module enables the data model. To take full advantage of its core offering, you should install [silverstripe/versioned-snapshot-admin](https://github.com/silverstripe/silverstripe-versioned-snapshot-admin) to expose these snapshots through the "History" tab of the CMS.

WARNING: This module is experimental, and not considered stable.
WARNING: This module is experimental, and not considered stable.

## Installation

Expand All @@ -38,7 +38,7 @@ BlockPage

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.
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.

Expand Down Expand Up @@ -75,260 +75,123 @@ on a template to create a human-readable activity feed. Returns an array of `Act

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
`RecursivePublishable`, which is added to all dataobjects by `silverstripe-versioned`, with
this custom subclass.

For CMS views, use the `SnapshotSiteTreeExtension` to provide notifications about
owned modification state (WORK IN PROGRESS, POC ONLY)

## How it works

This module comes with two very different work flows.
Snapshots are created with handlers registered to user events in the CMS triggered by
the [`silverstripe/cms-events`](https://github.com/silverstripe/silverstripe-cms-events)
module.

* CMS action work flow (default) - trigger is on user action, actions are opt-in with some core actions already available
* model work flow - trigger is on after model write, actions are opt-out
### Customising the snapshot messages

### CMS action work flow
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.

#### Static configuration

This module comes with some CMS actions already provided. This configuration is located in `config.yml` under `snapshot-actions`.
The format is very simple:

'`identifier`': '`message`'

Where `identifier` is the action identifier (this is internal name from the component which is responsible for handling the action).
For example for page edit form we have the following rule:

'`save`': '`Save page`'

This means each time a user saves a page via page edit form a snapshot will be created with a context message `Save page`.

This configuration can be overridden via standard configuration API means.

**I want to add more actions**

Create following configuration in your project `_config` folder:

```
Name: snapshot-custom-actions
After:
- '#snapshot-actions'
---
SilverStripe\Snapshots\Snapshot:
actions:
# grid field actions (via standard action)
'togglelayoutwidth': 'Toggle layout width'
```yaml
SilverStripe\Snapshots\Handler\Form\FormSubmissionHandler:
messages:
publish: 'My publish message'
```

This will add a new action for the `togglelayoutwidth` action and the snapshot message for this action will be `Toggle layout width`.
In this case "publish" is the **action identifier** (the function that handles the form).

**I want to disable a default action**
### Customising existing snapshot creators

```
Name: snapshot-custom-actions
After:
- '#snapshot-actions'
---
SilverStripe\Snapshots\Snapshot:
actions:
# GraphQL CRUD - disable default
'graphql_crud_create': null
```
All of the handlers are registered with injector, so the simplest way to customise them is to override their
definitions in the configuration.

This will disable the action `graphql_crud_create` so no snapshot will be created when this action is executed.
For instance, if you have something custom you with a snapshot when a page is saved:

**I want to add a action but with no message**
```php
use SilverStripe\Snapshots\Handler\Form\SaveHandler;
use SilverStripe\Snapshots\Listener\EventContext;
use SilverStripe\Snapshots\Snapshot;

class MySaveHandler extends SaveHandler
{
protected function createSnapshot(EventContext $context): ?Snapshot
{
//...
}
}
```
Name: snapshot-custom-actions
After:
- '#snapshot-actions'
---
SilverStripe\Snapshots\Snapshot:
actions:
# grid field actions (via standard action)
'togglelayoutwidth': ''
```

This will still create a snapshot for the action but no snapshot message will be displayed.

**I want to change message of existing action**

```
Name: snapshot-custom-actions
After:
- '#snapshot-actions'
---
SilverStripe\Snapshots\Snapshot:
actions:
# GraphQL CRUD - disable default
'graphql_crud_create': 'My custom message'
```yaml
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Handler\Form\SaveHandler:
class: MyProject\MySaveHandler
```

This will create snapshot for the action with your custom message.
Setting empty string as a message will still create the snapshot but with no message.
### Adding snapshot creators

#### How to find your action identifier
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.

Common case is where you want to add a new action configuration but you don't know what your action identifier is.
This really depends on what the component responsible for handling the action is.
The most basic approach is to add temporary logging to start of `SilverStripe\Snapshots\Snapshot::getActionMessage()`.
Every action which is covered by this module (regardless of the configuration) flows through this function.
Let's say we have a form that submits to a function: `public function myFormHandler($data, $form)`.

```yaml
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Dispatch\Dispatcher:
properties:
handlers:
myForm:
on:
'formSubmitted.myFormHandler': true
handler: %$MyProject\Handlers\MyHandler
```
public function getActionMessage($identifier): ?string
{
error_log($identifier);
```

When the logging is in place you just go to the CMS and perform the action you are interested in.
This should narrow the list of identifier down to a much smaller subset.

#### Runtime overrides

In case static configuration in not enough, runtime overrides are available. This module comes with following types of listeners:

* Form submissions - actions that comes via form submissions (for example page edit form)
* GraphQL general - actions executed via GraphQL CRUD (for example standard model mutation)
* GraphQL custom - actions executed via GraphQL API (for example custom mutation)
* GridField alter - actions which are implemented via `GridField_ActionProvider` (for example delete item via GridField)
* GridField URL handler - actions which are implemented via `GridField_URLHandler`
* Page `CMSMain` actions - this covers page actions which are now handled by form submissions

Each type of listener provides an extension point which allows the override of the default module behaviour.

To apply your override you need to first know which listener is handling your action.
Sometimes you can guess based on the action category but using logging may help you determine the listener type more easily.

Form submissions - `Form\Submission::processAction`

GraphQL custom - `GraphQL\CustomAction::onAfterCallMiddleware`
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.

GraphQL general - `GraphQL\GenericAction::afterMutation`
### Removing snapshot creators

GridField alter - `GridField\AlterAction::afterCallActionHandler`
To remove an event from a handler, simply set its value to `false`.

GridField URL handler - `GridField\UrlHandlerAction::afterCallActionURLHandler`

Page `CMSMain` actions - `Page\CMSMainAction::afterCallActionHandler`

Once you know listener type and the action identifier you need to create an extension which is a subclass of one of the abstract listener handlers.
Abstract listener depends on your listener type.

Form submissions - `Form\SubmissionListenerAbstract`

GraphQL custom - `GraphQL\CustomActionListenerAbstract`

GraphQL general - `GraphQL\GenericActionListenerAbstract`

GridField alter - `GridField\AlterActionListenerAbstract`

GridField URL handler - `GridField\UrlHandlerActionListenerAbstract`

Page `CMSMain` actions - `Page\CMSMainListenerAbstract`

**Example implementation**

config

```
SilverStripe\Snapshots\Snapshot:
extensions:
- App\Snapshots\Listener\MutationUpdateLayoutBlockGroup
```yaml
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Dispatch\Dispatcher:
properties:
handlers:
myForm:
on:
'formSubmitted.myFormHandler': false
```

extension
### Procedurally adding event handlers

You can register a `EventHandlerLoader` implementation with `Dispatcher` to procedurally register and unregister
events.

```yaml
SilverStripe\Core\Injector\Injector:
SilverStripe\Snapshots\Dispatch\Dispatcher:
properties:
loaders:
myLoader: %$MyProject\MyEventLoader
```
<?php

namespace App\Snapshots\Listener;
```php
use SilverStripe\Snapshots\Dispatch\EventHandlerLoader;
use SilverStripe\Snapshots\Dispatch\Dispatcher;
use SilverStripe\Snapshots\Handler\Form\SaveHandler;

use App\Models\Blocks\LayoutBlock;
use GraphQL\Type\Schema;
use Page;
use SilverStripe\ORM\ValidationException;
use SilverStripe\Snapshots\Snapshot;
use SilverStripe\Snapshots\Listener\GraphQL\CustomActionListenerAbstract;

/**
* Class MutationUpdateLayoutBlockGroup
*
* @property Snapshot|$this $owner
* @package App\Snapshots\Listener
*/
class MutationUpdateLayoutBlockGroup extends CustomActionListenerAbstract
class MyEventLoader implements EventHandlerLoader
{
protected function getActionName(): string
public function addToDispatcher(Dispatcher $dispatcher): void
{
return 'mutation_updateLayoutBlockGroup';
}

/**
* @param Page $page
* @param string $action
* @param string $message
* @param Schema $schema
* @param string $query
* @param array $context
* @param array $params
* @return bool
* @throws ValidationException
*/
protected function processAction(
Page $page,
string $action,
string $message,
Schema $schema,
string $query,
array $context,
array $params
): bool {
if (!array_key_exists('block', $params)) {
return false;
}

$data = $params['block'];

if (!array_key_exists('ID', $data)) {
return false;
}

$blockId = (int) $data['ID'];

if (!$blockId) {
return false;
}

$block = LayoutBlock::get_by_id($blockId);

if ($block === null || !$block->exists()) {
return false;
}

Snapshot::singleton()->createSnapshotFromAction($page, $block, $message);

return true;
$dispatcher->removeListenerByClassName('formSubmitted.save', SaveHandler::class);
}
}

```

`getActionName` is the action identifier

`CustomActionListenerAbstract` is the parent class because this action is a custom mutation

Returning `false` inside `processAction` makes the module fallback to default behaviour.

Returning `true` inside `processAction` makes the module skip the default behaviour.

If you return `true` it's up to you to create the snapshot.
This covers the case where the action uses custom data and it's impossible for the module to figure out the origin object.
Use this approach when you are unhappy with the default behaviour and you know the way how to find the origin object from the data.
Note that the context data available is different for each listener type as the context is different.

#### Snapshot creation API
### Snapshot creation API

To cover all cases, this module allows you to invoke snapshot creation in any part of your code outside of normal action flow.

Expand Down Expand Up @@ -384,27 +247,6 @@ Passing the layout block through allows the layout block to display it's own ver
This feature may have marginal use and it's ok to skip it.


### Model work flow

When a dataobject is written, an `onAfterWrite` handler opens a snapshot by writing
a new `VersionedSnapshot` record. As long as this snapshot is open, any successive dataobject
writes will add themselves to the open snapshot, on the `VersionedSnapshotItem` table. The dataobject
that opens the snapshot is stored as the `Origin` on the `VersionedSnapshot` table (a polymorphic `has_one`).
It then looks up the ownership chain using `findOwners()` and puts each of its owners into the snapshot.

Each snapshot item contains its version, class, and ID at the time of the snapshot. This
provides enough information to query what snapshots a given dataobject was involved in since
a given version or date.

For the most part, the snapshot tables are considered immutable historical records, but there
are a few cases when snapshots are retroactively updated

* When changes are reverted to live, any snapshots those changes made are deleted.
* When the ownership structure is changed, the previous owners are surgically removed
from the graph and the new ones stitched in.



## Versioning

This library follows [Semver](http://semver.org). According to Semver,
Expand Down
Loading