Merge branch '4.3' into 4

This commit is contained in:
Robbie Averill 2018-12-06 09:40:40 +00:00
commit 5d7c5ffb07
12 changed files with 486 additions and 29 deletions

6
_config/gridfield.yml Normal file
View File

@ -0,0 +1,6 @@
---
Name: gridfieldconfig
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Forms\GridField\FormAction\StateStore:
class: SilverStripe\Forms\GridField\FormAction\SessionStore

View File

@ -472,11 +472,29 @@ functionality. See [How to Create a GridFieldComponent](../how_tos/create_a_grid
## Saving the GridField State ## Saving the GridField State
`GridState` is a class that is used to contain the current state and actions on the `GridField`. It's transfered `GridState` is a class that is used to contain the current state and actions on the `GridField`. It's transferred
between page requests by being inserted as a hidden field in the form. between page requests by being inserted as a hidden field in the form.
The `GridState_Component` sets and gets data from the `GridState`. The `GridState_Component` sets and gets data from the `GridState`.
## Saving GridField_FormAction state
By default state used for performing form actions is saved in the session and tagged with a key like `gf_abcd1234`. In
some cases session may not be an appropriate storage method. The storage method can be configured:
```yaml
Name: mysitegridfieldconfig
After: gridfieldconfig
---
SilverStripe\Core\Injector\Injector:
SilverStripe\Forms\GridField\FormAction\StateStore:
class: SilverStripe\Forms\GridField\FormAction\AttributeStore
```
The `AttributeStore` class configures action state to be stored in the DOM and sent back on the request that performs
the action. Custom storage methods can be created and used by implementing the `StateStore` interface and configuring
`Injector` in a similar fashion.
## API Documentation ## API Documentation
* [GridField](api:SilverStripe\Forms\GridField\GridField) * [GridField](api:SilverStripe\Forms\GridField\GridField)

View File

@ -8,6 +8,7 @@
- New React-based search UI for the CMS, Asset-Admin, GridFields and ModelAdmins. - New React-based search UI for the CMS, Asset-Admin, GridFields and ModelAdmins.
- A new `GridFieldLazyLoader` component can be added to `GridField`. This will delay the fetching of data until the user access the container Tab of the GridField. - A new `GridFieldLazyLoader` component can be added to `GridField`. This will delay the fetching of data until the user access the container Tab of the GridField.
- `SilverStripe\VersionedAdmin\Controllers\CMSPageHistoryViewerController` is now the default CMS history controller and `SilverStripe\CMS\Controllers\CMSPageHistoryController` has been deprecated. - `SilverStripe\VersionedAdmin\Controllers\CMSPageHistoryViewerController` is now the default CMS history controller and `SilverStripe\CMS\Controllers\CMSPageHistoryController` has been deprecated.
- It's now possible to avoid storing GridField data in session. See [the documentation on GridField](../developer_guides/forms/field_types/gridfield/#saving-the-gridfield-state).
## Upgrading {#upgrading} ## Upgrading {#upgrading}

View File

@ -0,0 +1,17 @@
<?php
namespace SilverStripe\Forms\GridField\FormAction;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HTTPRequest;
abstract class AbstractRequestAwareStore implements StateStore
{
/**
* @return HTTPRequest
*/
public function getRequest()
{
// Replicating existing functionality from GridField_FormAction
return Controller::curr()->getRequest();
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace SilverStripe\Forms\GridField\FormAction;
/**
* Stores GridField action state on an attribute on the action and then analyses request parameters to load it back
*/
class AttributeStore extends AbstractRequestAwareStore
{
/**
* Save the given state against the given ID returning an associative array to be added as attributes on the form
* action
*
* @param string $id
* @param array $state
* @return array
*/
public function save($id, array $state)
{
// Just save the state in the attributes of the action
return [
'data-action-state' => json_encode($state),
];
}
/**
* Load state for a given ID
*
* @param string $id
* @return array
*/
public function load($id)
{
// Check the request
return (array) json_decode((string) $this->getRequest()->requestVar('ActionState'), true);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace SilverStripe\Forms\GridField\FormAction;
use SilverStripe\Control\HTTPRequest;
/**
* Stores GridField action state in the session in exactly the same way it has in the past
*/
class SessionStore extends AbstractRequestAwareStore implements StateStore
{
/**
* Save the given state against the given ID returning an associative array to be added as attributes on the form
* action
*
* @param string $id
* @param array $state
* @return array
*/
public function save($id, array $state)
{
$this->getRequest()->getSession()->set($id, $state);
// This adapter does not require any additional attributes...
return [];
}
/**
* Load state for a given ID
*
* @param string $id
* @return array
*/
public function load($id)
{
return (array) $this->getRequest()->getSession()->get($id);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace SilverStripe\Forms\GridField\FormAction;
interface StateStore
{
/**
* Save the given state against the given ID returning an associative array to be added as attributes on the form
* action
*
* @param string $id
* @param array $state
* @return array
*/
public function save($id, array $state);
/**
* Load state for a given ID
*
* @param string $id
* @return array
*/
public function load($id);
}

View File

@ -9,8 +9,11 @@ use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse; use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\HTTPResponse_Exception; use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Control\RequestHandler; use SilverStripe\Control\RequestHandler;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\Form; use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormField; use SilverStripe\Forms\FormField;
use SilverStripe\Forms\GridField\FormAction\SessionStore;
use SilverStripe\Forms\GridField\FormAction\StateStore;
use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
@ -1009,9 +1012,14 @@ class GridField extends FormField
$state->setValue($fieldData['GridState']); $state->setValue($fieldData['GridState']);
} }
// Fetch the store for the "state" of actions (not the GridField)
/** @var StateStore $store */
$store = Injector::inst()->create(StateStore::class . '.' . $this->getName());
foreach ($data as $dataKey => $dataValue) { foreach ($data as $dataKey => $dataValue) {
if (preg_match('/^action_gridFieldAlterAction\?StateID=(.*)/', $dataKey, $matches)) { if (preg_match('/^action_gridFieldAlterAction\?StateID=(.*)/', $dataKey, $matches)) {
$stateChange = $request->getSession()->get($matches[1]); $stateChange = $store->load($matches[1]);
$actionName = $stateChange['actionName']; $actionName = $stateChange['actionName'];
$arguments = array(); $arguments = array();

View File

@ -289,14 +289,18 @@ class GridFieldFilterHeader implements GridField_URLHandler, GridField_HTMLProvi
}, array_keys($filters)), $filters); }, array_keys($filters)), $filters);
} }
$searchAction = GridField_FormAction::create($gridField, 'filter', false, 'filter', null);
$clearAction = GridField_FormAction::create($gridField, 'reset', false, 'reset', null);
$schema = [ $schema = [
'formSchemaUrl' => $schemaUrl, 'formSchemaUrl' => $schemaUrl,
'name' => $searchField, 'name' => $searchField,
'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $name]), 'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $name]),
'filters' => $filters ?: new \stdClass, // stdClass maps to empty json object '{}' 'filters' => $filters ?: new \stdClass, // stdClass maps to empty json object '{}'
'gridfield' => $gridField->getName(), 'gridfield' => $gridField->getName(),
'searchAction' => GridField_FormAction::create($gridField, 'filter', false, 'filter', null)->getAttribute('name'), 'searchAction' => $searchAction->getAttribute('name'),
'clearAction' => GridField_FormAction::create($gridField, 'reset', false, 'reset', null)->getAttribute('name') 'searchActionState' => $searchAction->getAttribute('data-action-state'),
'clearAction' => $clearAction->getAttribute('name'),
'clearActionState' => $clearAction->getAttribute('data-action-state'),
]; ];
return json_encode($schema); return json_encode($schema);

View File

@ -3,8 +3,10 @@
namespace SilverStripe\Forms\GridField; namespace SilverStripe\Forms\GridField;
use SilverStripe\Control\Controller; use SilverStripe\Control\Controller;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\Form; use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormAction; use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\GridField\FormAction\StateStore;
/** /**
* This class is the base class when you want to have an action that alters the state of the * This class is the base class when you want to have an action that alters the state of the
@ -12,6 +14,11 @@ use SilverStripe\Forms\FormAction;
*/ */
class GridField_FormAction extends FormAction class GridField_FormAction extends FormAction
{ {
/**
* A common string prefix for keys generated to store form action "state" against
*/
const STATE_KEY_PREFIX = 'gf_';
/** /**
* @var GridField * @var GridField
*/ */
@ -80,29 +87,35 @@ class GridField_FormAction extends FormAction
*/ */
public function getAttributes() public function getAttributes()
{ {
// Store state in session, and pass ID to client side. // Determine the state that goes with this action
$state = array( $state = array(
'grid' => $this->getNameFromParent(), 'grid' => $this->getNameFromParent(),
'actionName' => $this->actionName, 'actionName' => $this->actionName,
'args' => $this->args, 'args' => $this->args,
); );
// Ensure $id doesn't contain only numeric characters // Generate a key and attach it to the action name
$id = 'gf_' . substr(md5(serialize($state)), 0, 8); $key = static::STATE_KEY_PREFIX . substr(md5(serialize($state)), 0, 8);
// Note: This field needs to be less than 65 chars, otherwise Suhosin security patch will strip it
$name = 'action_gridFieldAlterAction?StateID=' . $key;
$session = Controller::curr()->getRequest()->getSession(); // Define attributes
$session->set($id, $state); $attributes = array(
$actionData['StateID'] = $id; 'name' => $name,
return array_merge(
parent::getAttributes(),
array(
// Note: This field needs to be less than 65 chars, otherwise Suhosin security patch
// will strip it from the requests
'name' => 'action_gridFieldAlterAction' . '?' . http_build_query($actionData),
'data-url' => $this->gridField->Link(), 'data-url' => $this->gridField->Link(),
'type' => "button", 'type' => "button",
) );
// Create a "store" for the "state" of this action
/** @var StateStore $store */
$store = Injector::inst()->create(StateStore::class . '.' . $this->gridField->getName());
// Store the state and update attributes as required
$attributes += $store->save($key, $state);
// Return attributes
return array_merge(
parent::getAttributes(),
$attributes
); );
} }

View File

@ -267,4 +267,34 @@ class TreeMultiselectField extends TreeDropdownField
$copy->setTitleField($this->getTitleField()); $copy->setTitleField($this->getTitleField());
return $copy; return $copy;
} }
/**
* {@inheritdoc}
*
* @internal To be removed in 5.0
*/
protected function objectForKey($key)
{
/**
* Fixes https://github.com/silverstripe/silverstripe-framework/issues/8332
*
* Due to historic reasons, the default (empty) value for this field is 'unchanged', even though
* the field is usually integer on the database side.
* MySQL handles that gracefully and returns an empty result in that case,
* whereas some other databases (e.g. PostgreSQL) do not support comparison
* of numeric types with string values, issuing a database error.
*
* This fix is not ideal, but supposed to keep backward compatibility for SS4.
*
* In 5.0 this method to be removed and NULL should be used instead of 'unchanged' (or an empty array. to be decided).
* In 5.0 this class to be refactored so that $this->value is always an array of values (or null)
*/
if ($this->getKeyField() === 'ID' && $key === 'unchanged') {
$key = null;
} elseif (is_string($key)) {
$key = preg_split('/\s*,\s*/', trim($key));
}
return parent::objectForKey($key);
}
} }

View File

@ -4,28 +4,292 @@ namespace SilverStripe\Forms\Tests;
use SilverStripe\Assets\File; use SilverStripe\Assets\File;
use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\SapphireTest;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\FormTemplateHelper;
use SilverStripe\Forms\TreeMultiselectField; use SilverStripe\Forms\TreeMultiselectField;
class TreeMultiselectFieldTest extends SapphireTest class TreeMultiselectFieldTest extends SapphireTest
{ {
protected static $fixture_file = 'TreeDropdownFieldTest.yml'; protected static $fixture_file = 'TreeDropdownFieldTest.yml';
public function testReadonly() protected $formId = 'TheFormID';
protected $fieldName = 'TestTree';
/**
* Mock object of a generic form
*
* @var Form
*/
protected $form;
/**
* Instance of the TreeMultiselectField
*
* @var TreeMultiselectField
*/
protected $field;
/**
* The File objects of folders loaded from the fixture
*
* @var File[]
*/
protected $folders;
/**
* The array of folder ids
*
* @var int[]
*/
protected $folderIds;
/**
* Concatenated folder ids for use as a value for the field
*
* @var string
*/
protected $fieldValue;
protected function setUp()
{
parent::setUp();
$this->form = $this->buildFormMock();
$this->field = $this->buildField($this->form);
$this->folders = $this->loadFolders();
$this->folderIds = array_map(
static function ($f) {
return $f->ID;
},
$this->folders
);
$this->fieldValue = implode(',', $this->folderIds);
}
/**
* Build a new mock object of a Form
*
* @return Form
*/
protected function buildFormMock()
{
$form = $this->createMock(Form::class);
$form->method('getTemplateHelper')
->willReturn(FormTemplateHelper::singleton());
$form->method('getHTMLID')
->willReturn($this->formId);
return $form;
}
/**
* Build a new instance of TreeMultiselectField
*
* @param Form $form The field form
*
* @return TreeMultiselectField
*/
protected function buildField(Form $form)
{
$field = new TreeMultiselectField($this->fieldName, 'Test tree', File::class);
$field->setForm($form);
return $field;
}
/**
* Load several files from the fixtures and return them in an array
*
* @return File[]
*/
protected function loadFolders()
{ {
$field = new TreeMultiselectField('TestTree', 'Test tree', File::class);
$asdf = $this->objFromFixture(File::class, 'asdf'); $asdf = $this->objFromFixture(File::class, 'asdf');
$subfolderfile1 = $this->objFromFixture(File::class, 'subfolderfile1'); $subfolderfile1 = $this->objFromFixture(File::class, 'subfolderfile1');
$field->setValue(implode(',', [$asdf->ID, $subfolderfile1->ID]));
$readonlyField = $field->performReadonlyTransformation(); return [$asdf, $subfolderfile1];
$this->assertEquals( }
<<<"HTML"
<span id="TestTree_ReadonlyValue" class="readonly"> /**
&lt;Special &amp; characters&gt;, TestFile1InSubfolder * Test the TreeMultiselectField behaviour with no selected values
</span><input type="hidden" name="TestTree" value="{$asdf->ID},{$subfolderfile1->ID}" class="hidden" id="TestTree" /> */
HTML public function testEmpty()
, {
(string)$readonlyField->Field() $field = $this->field;
$fieldId = $field->ID();
$this->assertEquals($fieldId, sprintf('%s_%s', $this->formId, $this->fieldName));
$schemaStateDefaults = $field->getSchemaStateDefaults();
$this->assertArraySubset(
[
'id' => $fieldId,
'name' => $this->fieldName,
'value' => 'unchanged'
],
$schemaStateDefaults,
true
); );
$schemaDataDefaults = $field->getSchemaDataDefaults();
$this->assertArraySubset(
[
'id' => $fieldId,
'name' => $this->fieldName,
'type' => 'text',
'schemaType' => 'SingleSelect',
'component' => 'TreeDropdownField',
'holderId' => sprintf('%s_Holder', $fieldId),
'title' => 'Test tree',
'extraClass' => 'treemultiselect multiple searchable',
'data' => [
'urlTree' => 'field/TestTree/tree',
'showSearch' => true,
'emptyString' => '(Choose File)',
'hasEmptyDefault' => false,
'multiple' => true
]
],
$schemaDataDefaults,
true
);
$items = $field->getItems();
$this->assertCount(0, $items, 'there must be no items selected');
$html = $field->Field();
$this->assertContains($field->ID(), $html);
$this->assertContains('unchanged', $html);
}
/**
* Test the field with some values set
*/
public function testChanged()
{
$field = $this->field;
$field->setValue($this->fieldValue);
$schemaStateDefaults = $field->getSchemaStateDefaults();
$this->assertArraySubset(
[
'id' => $field->ID(),
'name' => 'TestTree',
'value' => $this->folderIds
],
$schemaStateDefaults,
true
);
$items = $field->getItems();
$this->assertCount(2, $items, 'there must be exactly 2 items selected');
$html = $field->Field();
$this->assertContains($field->ID(), $html);
$this->assertContains($this->fieldValue, $html);
}
/**
* Test empty field in readonly mode
*/
public function testEmptyReadonly()
{
$field = $this->field->performReadonlyTransformation();
$schemaStateDefaults = $field->getSchemaStateDefaults();
$this->assertArraySubset(
[
'id' => $field->ID(),
'name' => 'TestTree',
'value' => 'unchanged'
],
$schemaStateDefaults,
true
);
$schemaDataDefaults = $field->getSchemaDataDefaults();
$this->assertArraySubset(
[
'id' => $field->ID(),
'name' => $this->fieldName,
'type' => 'text',
'schemaType' => 'SingleSelect',
'component' => 'TreeDropdownField',
'holderId' => sprintf('%s_Holder', $field->ID()),
'title' => 'Test tree',
'extraClass' => 'treemultiselectfield_readonly multiple searchable',
'data' => [
'urlTree' => 'field/TestTree/tree',
'showSearch' => true,
'emptyString' => '(Choose File)',
'hasEmptyDefault' => false,
'multiple' => true
]
],
$schemaDataDefaults,
true
);
$items = $field->getItems();
$this->assertCount(0, $items, 'there must be 0 selected items');
$html = $field->Field();
$this->assertContains($field->ID(), $html);
}
/**
* Test changed field in readonly mode
*/
public function testChangedReadonly()
{
$field = $this->field;
$field->setValue($this->fieldValue);
$field = $field->performReadonlyTransformation();
$schemaStateDefaults = $field->getSchemaStateDefaults();
$this->assertArraySubset(
[
'id' => $field->ID(),
'name' => 'TestTree',
'value' => $this->folderIds
],
$schemaStateDefaults,
true
);
$schemaDataDefaults = $field->getSchemaDataDefaults();
$this->assertArraySubset(
[
'id' => $field->ID(),
'name' => $this->fieldName,
'type' => 'text',
'schemaType' => 'SingleSelect',
'component' => 'TreeDropdownField',
'holderId' => sprintf('%s_Holder', $field->ID()),
'title' => 'Test tree',
'extraClass' => 'treemultiselectfield_readonly multiple searchable',
'data' => [
'urlTree' => 'field/TestTree/tree',
'showSearch' => true,
'emptyString' => '(Choose File)',
'hasEmptyDefault' => false,
'multiple' => true
]
],
$schemaDataDefaults,
true
);
$items = $field->getItems();
$this->assertCount(2, $items, 'there must be exactly 2 selected items');
$html = $field->Field();
$this->assertContains($field->ID(), $html);
$this->assertContains($this->fieldValue, $html);
} }
} }