diff --git a/src/TagField.php b/src/TagField.php index 99bd8fa..f2a715b 100644 --- a/src/TagField.php +++ b/src/TagField.php @@ -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 - */ - public function __construct($name, $title = '', $source = [], $value = null, $titleField = 'Title') - { - $this->setTitleField($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', + $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 @@ public function setTitleField($titleField) 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 @@ protected function getOptions($onlySelected = false) } $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 @@ protected function getOptions($onlySelected = false) } $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 @@ protected function getListValues($values): array } 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 @@ protected function getListValues($values): array 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 @@ public function saveInto(DataObjectInterface $record) 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 @@ protected function getOrCreateTag($value) // 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 @@ protected function getOrCreateTag($value) 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 @@ public function suggest(HTTPRequest $request) 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 @@ public function getSchemaDataType(): string 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); + } } diff --git a/tests/Stub/TagFieldTestBlogPost.php b/tests/Stub/TagFieldTestBlogPost.php index d6db3dd..4e09e65 100644 --- a/tests/Stub/TagFieldTestBlogPost.php +++ b/tests/Stub/TagFieldTestBlogPost.php @@ -4,8 +4,14 @@ 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'; diff --git a/tests/Stub/TagFieldTestBlogTag.php b/tests/Stub/TagFieldTestBlogTag.php index e01ea4b..f35a285 100644 --- a/tests/Stub/TagFieldTestBlogTag.php +++ b/tests/Stub/TagFieldTestBlogTag.php @@ -4,8 +4,12 @@ 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; + } } diff --git a/tests/TagFieldTest.php b/tests/TagFieldTest.php index 4ebfc8d..bc020a7 100755 --- a/tests/TagFieldTest.php +++ b/tests/TagFieldTest.php @@ -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 @@ public function testItSavesToHasOne() $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,145 @@ public function testItDisplaysWithSelectedValuesFromDataList() ); } + /** + * @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()); diff --git a/tests/TagFieldTest.yml b/tests/TagFieldTest.yml index 750e0a0..a39cc1d 100755 --- a/tests/TagFieldTest.yml +++ b/tests/TagFieldTest.yml @@ -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