ENH: Configuration enhancement (search, value sort fields, allow raw data storage).

This commit is contained in:
Mojmir Fendek 2023-01-11 13:13:48 +13:00
parent 176da2d434
commit 900938afbf
5 changed files with 537 additions and 63 deletions

View File

@ -49,10 +49,48 @@ class TagField extends MultiSelectField
protected $canCreate = true;
/**
* This is the field that populates the label displayed in the UI
* It can be either a DB field or a model method name
*
* @var string
*/
protected $titleField = 'Title';
/**
* This is the field that is used to store selected values
* It has to be a DB field or null
* Use null for the auto-detection
*
* @var string
*/
protected $valueField = 'Title';
/**
* This is the field which drives the "suggest" action via text-based search
* It has to be a DB field
*
* @var string
*/
protected $searchField = 'Title';
/**
* This is the field which drives the order of results that appear in the "suggest" action via text-based search
* It has to be a DB field or empty string
* Use empty string to skip order customisation which will result in whatever order the source list is in
*
* @var string
*/
protected $sortField = 'Title';
/**
* Allow Raw data to be stored on the matching DB field of the model
* Use this to cover cases which don't require form level data serialisation
* such as MultiValueField (symbiote/silverstripe-multivaluefield)
*
* @var bool
*/
protected $allowRawValue = false;
/**
* @var DataList
*/
@ -71,10 +109,29 @@ class TagField extends MultiSelectField
* @param null|DataList|array $source
* @param null|DataList $value
* @param string $titleField
* @param string|null $valueField
* @param string|null $searchField
* @param string|null $sortField
* @param bool $allowRawValue
*/
public function __construct($name, $title = '', $source = [], $value = null, $titleField = 'Title')
{
$this->setTitleField($titleField);
public function __construct(
$name,
$title = '',
$source = [],
$value = null,
$titleField = 'Title',
$valueField = null,
$searchField = null,
$sortField = null,
$allowRawValue = false
) {
$this
->setTitleField($titleField)
->initValueField($valueField)
->initSearchField($searchField)
->initSortField($sortField)
->setAllowRawValue($allowRawValue);
parent::__construct($name, $title, $source, $value);
$this->addExtraClass('ss-tag-field');
@ -180,6 +237,82 @@ class TagField extends MultiSelectField
return $this;
}
/**
* @param string $valueField
* @return $this
*/
public function setValueField($valueField)
{
$this->valueField = $valueField;
return $this;
}
/**
* @return string
*/
public function getValueField()
{
return $this->valueField;
}
/**
* @param $searchField
* @return $this
*/
public function setSearchField($searchField)
{
$this->searchField = $searchField;
return $this;
}
/**
* @return string
*/
public function getSearchField()
{
return $this->searchField;
}
/**
* @param string $fieldName
* @return $this
*/
public function setSortField($fieldName)
{
$this->sortField = $fieldName;
return $this;
}
/**
* @return string
*/
public function getSortField()
{
return $this->sortField;
}
/**
* @param bool $allowRawValue
* @return $this
*/
public function setAllowRawValue($allowRawValue)
{
$this->allowRawValue = $allowRawValue;
return $this;
}
/**
* @return bool
*/
public function getAllowRawValue()
{
return $this->allowRawValue;
}
/**
* Get the DataList source. The 4.x upgrade for SelectField::setSource starts to convert this to an array.
* If empty use getSource() for array version
@ -270,7 +403,6 @@ class TagField extends MultiSelectField
}
$dataClass = $source->dataClass();
$values = $this->getValueArray();
// If we have no values and we only want selected options we can bail here
@ -279,41 +411,33 @@ class TagField extends MultiSelectField
}
$titleField = $this->getTitleField();
$valueField = $this->getValueField();
// Convert an array of values into a datalist of options
if (!$values instanceof SS_List) {
if (is_array($values) && !empty($values)) {
// if values is an array of Ids then we should look up via
// ID.
if (array_filter($values, 'is_int')) {
$queryField = 'ID';
} else {
$queryField = $titleField;
}
if (is_a($source, DataList::class)) {
$values = $source->filterAny([
$queryField => $values
]);
} else {
$values = DataList::create($dataClass)
$values = is_a($source, DataList::class)
? $source->filterAny([
$valueField => $values,
])
: DataList::create($dataClass)
->filterAny([
$queryField => $values
$valueField => $values,
]);
}
} else {
$values = ArrayList::create();
}
}
// Prep a function to parse a dataobject into an option
$addOption = function (DataObject $item) use ($options, $values, $titleField) {
$option = $item->$titleField;
$addOption = function (DataObject $item) use ($options, $values, $titleField, $valueField) {
$title = $item->{$titleField};
$value = $item->{$valueField};
$options->push(ArrayData::create([
'Title' => $option,
'Value' => $option,
'Selected' => (bool) $values->find($titleField, $option)
'Title' => $title,
'Value' => $value,
'Selected' => (bool) $values->find($valueField, $value)
]));
};
@ -398,11 +522,11 @@ class TagField extends MultiSelectField
}
if ($values instanceof SS_List) {
return $values->column($this->getTitleField());
return $values->column($this->getValueField());
}
if ($values instanceof DataObject && $values->exists()) {
return [$values->{$this->getTitleField()} ?? $values->ID];
return [$values->{$this->getValueField()}];
}
if (is_int($values)) {
@ -412,15 +536,37 @@ class TagField extends MultiSelectField
return [trim((string) $values)];
}
/**
* @param DataObjectInterface $record
* @return void
*/
public function loadFrom(DataObjectInterface $record): void
{
$fieldName = $this->getName();
if (!$fieldName) {
return;
}
if ($this->getAllowRawValue()) {
// Load raw value without de-serialisation
$this->value = $record->{$fieldName};
return;
}
parent::loadFrom($record);
}
/**
* {@inheritdoc}
*/
public function saveInto(DataObjectInterface $record)
{
$name = $this->getName();
$fieldName = $this->getName();
$values = $this->getValueArray();
// We need to extract IDs as in some cases (Relation) we are unable to use the value field
$ids = [];
if (!$values) {
@ -431,37 +577,58 @@ class TagField extends MultiSelectField
return;
}
/** @var Relation $relation */
$relation = $record->hasMethod($name) ? $record->$name() : null;
$valueField = $this->getValueField();
$tag = null;
$cleanValues = [];
foreach ($values as $key => $value) {
foreach ($values as $value) {
$tag = $this->getOrCreateTag($value);
if ($tag) {
$ids[] = $tag->ID;
$values[$key] = $tag->Title;
if (!$tag) {
continue;
}
$ids[] = $tag->ID;
$cleanValues[] = $tag->{$valueField};
}
/** @var Relation $relation */
$relation = $record->hasMethod($fieldName)
? $record->$fieldName()
: null;
if ($relation instanceof Relation) {
// Save ids into relation
// Save values into relation
$relation->setByIDList(array_filter($ids ?? []));
} elseif ($record->hasField($name)) {
} elseif ($this->getAllowRawValue()) {
// Store raw data without serialisation
$record->{$fieldName} = $cleanValues;
} elseif ($record->hasField($fieldName)) {
if ($this->getIsMultiple()) {
if ($record->obj($name) instanceof DBMultiEnum) {
$record->{$fieldName} = $record->obj($fieldName) instanceof DBMultiEnum
// Save dataValue into field... a CSV for DBMultiEnum
$record->$name = $this->csvEncode(array_filter(array_values($values)));
} else {
? $this->csvEncode(array_filter(array_values($cleanValues)))
// ... JSON-encoded string for other fields
$record->$name = $this->stringEncode(array_filter(array_values($values)));
}
: $this->stringEncode(array_filter(array_values($cleanValues)));
} else {
if (isset($tag) && $tag->ID) {
$record->$name = $tag->ID;
} else {
$record->$name = null;
// Detect has one as this case needs ID as opposed to custom value
$relations = $record->hasOne();
$hasOneDetected = false;
foreach ($relations as $relationName => $relationTarget) {
$foreignKey = $relationName . 'ID';
if ($foreignKey === $fieldName) {
$hasOneDetected = true;
break;
}
}
$targetField = $hasOneDetected ? 'ID' : $valueField;
$record->{$fieldName} = $tag && $tag->{$targetField}
? $tag->{$targetField}
: null;
}
}
}
@ -481,14 +648,14 @@ class TagField extends MultiSelectField
// Check if existing record can be found
$source = $this->getSourceList();
$titleField = $this->getTitleField();
$valueField = $this->getValueField();
if (!$source) {
return false;
}
$record = $source
->filter($titleField, $value)
->filter($valueField, $value)
->first();
if ($record) {
@ -499,7 +666,7 @@ class TagField extends MultiSelectField
if ($this->getCanCreate() && $value) {
$dataClass = $source->dataClass();
$record = Injector::inst()->create($dataClass);
$record->{$titleField} = $value;
$record->{$valueField} = $value;
$record->write();
if ($source instanceof SS_List) {
@ -538,25 +705,33 @@ class TagField extends MultiSelectField
protected function getTags($term)
{
$source = $this->getSourceList();
if (!$source) {
return [];
}
$titleField = $this->getTitleField();
$valueField = $this->getValueField();
$searchField = $this->getSearchField();
$sortField = $this->getSortField();
$query = $source
->filter($titleField . ':PartialMatch:nocase', $term)
->sort($titleField)
$list = $source
->filter($searchField . ':PartialMatch:nocase', $term)
->limit($this->getLazyLoadItemLimit());
// Optionally apply sort
if ($sortField) {
$list = $list->sort($searchField);
}
// Map into a distinct list
$items = [];
$titleField = $this->getTitleField();
foreach ($query->map('ID', $titleField)->values() as $title) {
$items[$title] = [
'Title' => $title,
'Value' => $title,
foreach ($list as $record) {
$value = $record->{$valueField};
$items[$value] = [
'Title' => $record->{$titleField},
'Value' => $value,
];
}
@ -624,4 +799,43 @@ class TagField extends MultiSelectField
return self::SCHEMA_DATA_TYPE_SINGLESELECT;
}
/**
* Provide a good default for value field
*
* @param string|null $value
* @return $this
*/
protected function initValueField($value)
{
$value = $value ?? $this->getTitleField();
return $this->setValueField($value);
}
/**
* Provide a good default for search field
*
* @param string|null $value
* @return $this
*/
protected function initSearchField($value)
{
$value = $value ?? $this->getTitleField();
return $this->setSearchField($value);
}
/**
* Provide a good default for sort field
*
* @param string|null $value
* @return $this
*/
protected function initSortField($value)
{
$value = $value ?? $this->getSearchField();
return $this->setSortField($value);
}
}

View File

@ -4,8 +4,14 @@ namespace SilverStripe\TagField\Tests\Stub;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\TagField\Tests\Stub\TagFieldTestBlogTag;
use SilverStripe\ORM\ManyManyList;
/**
* @property string $Content
* @property int $PrimaryTagID
* @method TagFieldTestBlogTag PrimaryTag()
* @method ManyManyList|TagFieldTestBlogTag[] Tags()
*/
class TagFieldTestBlogPost extends DataObject implements TestOnly
{
private static $table_name = 'TagFieldTestBlogPost';

View File

@ -4,8 +4,12 @@ namespace SilverStripe\TagField\Tests\Stub;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\TagField\Tests\Stub\TagFieldTestBlogPost;
use SilverStripe\ORM\ManyManyList;
/**
* @property int Sort
* @method ManyManyList|TagFieldTestBlogPost[] BlogPosts()
*/
class TagFieldTestBlogTag extends DataObject implements TestOnly
{
private static $table_name = 'TagFieldTestBlogTag';
@ -13,10 +17,16 @@ class TagFieldTestBlogTag extends DataObject implements TestOnly
private static $default_sort = '"TagFieldTestBlogTag"."ID" ASC';
private static $db = [
'Title' => 'Varchar(200)'
'Title' => 'Varchar(200)',
'Sort' => 'Int',
];
private static $belongs_many_many = [
'BlogPosts' => TagFieldTestBlogPost::class
];
public function getLabel(): string
{
return 'Label: ' . $this->Title;
}
}

View File

@ -2,10 +2,12 @@
namespace SilverStripe\TagField\Tests;
use ReflectionMethod;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\TagField\ReadonlyTagField;
@ -70,6 +72,104 @@ class TagFieldTest extends SapphireTest
$this->assertEquals($tag->ID, $record->PrimaryTagID, 'The tag is saved to a has_one');
}
/**
* @dataProvider rawValueStoreCasesProvider
*/
public function testItSavesToRawValue(bool $rawValueEnabled, mixed $values, mixed $expected): void
{
$record = $this->getNewTagFieldTestBlogPost('BlogPost1');
$tag = new TagFieldTestBlogTag();
$tag->Title = 'RawValueTest';
$tag->write();
$field = new TagField('InMemoryOnlyProperty', '', new DataList(TagFieldTestBlogTag::class));
$field->setAllowRawValue($rawValueEnabled);
$field->setValue($values);
$field->saveInto($record);
$this->assertEquals($expected, $record->InMemoryOnlyProperty, 'We expect raw value to be stored');
}
public function rawValueStoreCasesProvider(): array
{
return [
'raw value disabled' => [
false,
'SampleValue1',
null,
],
'raw value enabled (single value)' => [
true,
'SampleValue1',
[
'SampleValue1',
],
],
'raw value enabled (multiple values)' => [
true,
[
'SampleValue1',
'SampleValue2',
],
[
'SampleValue1',
'SampleValue2',
],
],
];
}
/**
* @dataProvider rawValueLoadCasesProvider
*/
public function testItLoadsFromRawValue(bool $rawValueEnabled, mixed $values, mixed $expected): void
{
$record = $this->getNewTagFieldTestBlogPost('BlogPost1');
$tag = new TagFieldTestBlogTag();
$tag->Title = 'RawValueTest';
$tag->write();
$field = new TagField('InMemoryOnlyProperty', '', new DataList(TagFieldTestBlogTag::class));
$field->setAllowRawValue($rawValueEnabled);
$record->InMemoryOnlyProperty = $values;
$field->loadFrom($record);
$this->assertEquals($expected, $field->Value(), 'We expect raw value to be loaded');
}
public function rawValueLoadCasesProvider(): array
{
return [
'raw value disabled' => [
false,
null,
[],
],
'raw value enabled (single value)' => [
true,
[
'SampleValue1',
],
[
'SampleValue1',
],
],
'raw value enabled (multiple values)' => [
true,
[
'SampleValue1',
'SampleValue2',
],
[
'SampleValue1',
'SampleValue2',
],
],
];
}
/**
* @param string $name
*
@ -429,6 +529,148 @@ class TagFieldTest extends SapphireTest
);
}
/**
* @dataProvider optionCasesProvider
*/
public function testGetOptionsWithConfigurableFields(
?string $titleField,
?string $valueField,
array $expected
): void {
$source = TagFieldTestBlogTag::get();
$field = new TagField('TestField', null, $source);
if ($titleField) {
$field->setTitleField($titleField);
}
if ($valueField) {
$field->setValueField($valueField);
}
/** @see TagField::getOptions() */
$getOptionsMethod = new ReflectionMethod($field, 'getOptions');
/** @var ArrayList $result */
$result = $getOptionsMethod->invoke($field);
$data = $result
->map('Title', 'Value')
->toArray();
$this->assertSame($expected, $data, 'We expect specific fields to be present');
}
public function optionCasesProvider(): array
{
return [
'default fields' => [
null,
null,
[
'Tag1' => 'Tag1',
'222' => '222'
],
],
'Label > Sort' => [
'Label',
'Sort',
[
'Label: Tag1' => 2,
'Label: 222' => 1
],
],
'Sort > Title' => [
'Sort',
'Title',
[
2 => 'Tag1',
1 => '222'
],
],
];
}
/**
* @dataProvider getTagsCasesProvider
*/
public function testGetTagsWithConfigurableFields(
?string $titleField,
?string $valueField,
?string $searchField,
?string $sortField,
string $searchSubject,
array $expected
): void {
$tag3 = TagFieldTestBlogTag::create();
$tag3->Title = 'Tag3';
$tag3->Sort = 3;
$tag3->write();
$source = TagFieldTestBlogTag::get();
$field = new TagField('TestField', null, $source);
if ($titleField) {
$field->setTitleField($titleField);
}
if ($valueField) {
$field->setValueField($valueField);
}
if ($searchField) {
$field->setSearchField($searchField);
}
if ($sortField) {
$field->setSortField($sortField);
}
/** @see TagField::getTags() */
$getTagsMethod = new ReflectionMethod($field, 'getTags');
/** @var ArrayList $result */
$result = $getTagsMethod->invoke($field, $searchSubject);
$data = [];
foreach ($result as $item) {
$title = $item['Title'];
$value = $item['Value'];
$data[$title] = $value;
}
$this->assertSame($expected, $data, 'We expect specific fields to be present');
}
public function getTagsCasesProvider(): array
{
return [
'default fields' => [
null,
null,
null,
null,
'Tag',
[
'Tag1' => 'Tag1',
'Tag3' => 'Tag3',
],
],
'custom fields' => [
'Label',
'Sort',
'Title',
'Sort',
'Tag',
[
'Label: Tag1' => 2,
'Label: Tag3' => 3,
],
],
];
}
public function testGetSchemaDataDefaults()
{
$form = new Form(null, 'Form', new FieldList(), new FieldList());

View File

@ -1,11 +1,13 @@
SilverStripe\TagField\Tests\Stub\TagFieldTestBlogTag:
Tag1:
Title: Tag1
Title: 'Tag1'
Sort: 2
Tag2:
Title: 222
Title: '222'
Sort: 1
SilverStripe\TagField\Tests\Stub\TagFieldTestBlogPost:
BlogPost1:
Title: BlogPost1
Title: 'BlogPost1'
BlogPost2:
Title: BlogPost2
Title: 'BlogPost2'
Tags: =>SilverStripe\TagField\Tests\Stub\TagFieldTestBlogTag.Tag1,=>SilverStripe\TagField\Tests\Stub\TagFieldTestBlogTag.Tag2