mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge pull request #11057 from creative-commoners/pulls/5/selectorfield
NEW SearchableDropdownField
This commit is contained in:
commit
c90320712a
@ -141,6 +141,10 @@ en:
|
||||
IsNullLabel: 'Is Null'
|
||||
SilverStripe\Forms\NumericField:
|
||||
VALIDATION: "'{value}' is not a number, only numbers can be accepted for this field"
|
||||
SilverStripe\Forms\SearchableDropdownTrait:
|
||||
SELECT: 'Select...'
|
||||
TYPE_TO_SEARCH: 'Type to search...'
|
||||
SELECT_OR_TYPE_TO_SEARCH: 'Select or type to search...'
|
||||
SilverStripe\Forms\TextField:
|
||||
VALIDATEMAXLENGTH: 'The value for {name} must not exceed {maxLength} characters in length'
|
||||
SilverStripe\Forms\TimeField:
|
||||
|
40
src/Forms/SearchableDropdownField.php
Normal file
40
src/Forms/SearchableDropdownField.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Forms;
|
||||
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
use SilverStripe\Forms\DropdownField;
|
||||
use SilverStripe\ORM\DataList;
|
||||
|
||||
class SearchableDropdownField extends DropdownField
|
||||
{
|
||||
use SearchableDropdownTrait;
|
||||
|
||||
// This needs to be defined on the class, not the trait, otherwise there is a PHP error
|
||||
protected $schemaComponent = 'SearchableDropdownField';
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $title,
|
||||
DataList $source,
|
||||
mixed $value = null,
|
||||
string $labelField = 'Title'
|
||||
) {
|
||||
parent::__construct($name, $title, $source, $value);
|
||||
$this->setLabelField($labelField);
|
||||
$this->addExtraClass('ss-searchable-dropdown-field');
|
||||
$this->setHasEmptyDefault(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $string
|
||||
* @return $this
|
||||
*
|
||||
* @deprecated 5.2.0 Use setPlaceholder() instead
|
||||
*/
|
||||
public function setEmptyString($string)
|
||||
{
|
||||
Deprecation::notice('5.2.0', 'Use setPlaceholder() instead');
|
||||
return parent::setEmptyString($string);
|
||||
}
|
||||
}
|
567
src/Forms/SearchableDropdownTrait.php
Normal file
567
src/Forms/SearchableDropdownTrait.php
Normal file
@ -0,0 +1,567 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Forms;
|
||||
|
||||
use Error;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use SilverStripe\Control\Controller;
|
||||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Control\HTTPResponse;
|
||||
use SilverStripe\ORM\ArrayList;
|
||||
use SilverStripe\ORM\DataList;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\DataObjectInterface;
|
||||
use SilverStripe\ORM\Relation;
|
||||
use SilverStripe\ORM\SS_List;
|
||||
use SilverStripe\ORM\FieldType\DBHTMLText;
|
||||
use SilverStripe\View\ArrayData;
|
||||
use SilverStripe\ORM\Search\SearchContext;
|
||||
use SilverStripe\Security\SecurityToken;
|
||||
use SilverStripe\ORM\RelationList;
|
||||
use SilverStripe\ORM\UnsavedRelationList;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
trait SearchableDropdownTrait
|
||||
{
|
||||
private static array $allowed_actions = [
|
||||
'search',
|
||||
];
|
||||
|
||||
private bool $isClearable = false;
|
||||
|
||||
private bool $isLazyLoaded = false;
|
||||
|
||||
private bool $isMultiple = false;
|
||||
|
||||
private bool $isSearchable = true;
|
||||
|
||||
private int $lazyLoadLimit = 100;
|
||||
|
||||
private string $placeholder = '';
|
||||
|
||||
private ?SearchContext $searchContext = null;
|
||||
|
||||
private bool $useDynamicPlaceholder = true;
|
||||
|
||||
private bool $useSearchContext = false;
|
||||
|
||||
private DataList $sourceList;
|
||||
|
||||
private string $labelField = 'Title';
|
||||
|
||||
/**
|
||||
* Returns a JSON string of options for lazy loading.
|
||||
*/
|
||||
public function search(HTTPRequest $request): HTTPResponse
|
||||
{
|
||||
$response = HTTPResponse::create();
|
||||
$response->addHeader('Content-Type', 'application/json');
|
||||
if (!SecurityToken::singleton()->checkRequest($request)) {
|
||||
$response->setStatusCode(400);
|
||||
$response->setBody(json_encode(['message' => 'Invalid CSRF token']));
|
||||
return $response;
|
||||
}
|
||||
$term = $request->getVar('term') ?? '';
|
||||
$options = $this->getOptionsForSearchRequest($term);
|
||||
$response->setBody(json_encode($options));
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the currently selected value(s) can be cleared
|
||||
*/
|
||||
public function getIsClearable(): bool
|
||||
{
|
||||
return $this->isClearable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the currently selected value(s) can be cleared
|
||||
*/
|
||||
public function setIsClearable(bool $isClearable): static
|
||||
{
|
||||
$this->isClearable = $isClearable;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether values are lazy loading via AJAX
|
||||
*/
|
||||
public function getIsLazyLoaded(): bool
|
||||
{
|
||||
return $this->isLazyLoaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether values are lazy loaded via AJAX
|
||||
*/
|
||||
public function setIsLazyLoaded(bool $isLazyLoaded): static
|
||||
{
|
||||
$this->isLazyLoaded = $isLazyLoaded;
|
||||
if ($isLazyLoaded) {
|
||||
$this->setIsSearchable(true);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the limit of items to lazy load
|
||||
*/
|
||||
public function getLazyLoadLimit(): int
|
||||
{
|
||||
return $this->lazyLoadLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the limit of items to lazy load
|
||||
*/
|
||||
public function setLazyLoadLimit(int $lazyLoadLimit): static
|
||||
{
|
||||
$this->lazyLoadLimit = $lazyLoadLimit;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the placeholder text
|
||||
*/
|
||||
public function getPlaceholder(): string
|
||||
{
|
||||
$placeholder = $this->placeholder;
|
||||
if ($placeholder) {
|
||||
return $placeholder;
|
||||
}
|
||||
// SearchableDropdownField will have the getEmptyString() method from SingleSelectField
|
||||
if (method_exists($this, 'getEmptyString')) {
|
||||
$emptyString = $this->getEmptyString();
|
||||
if ($emptyString) {
|
||||
return $emptyString;
|
||||
}
|
||||
}
|
||||
$name = $this->getName();
|
||||
if ($this->getUseDynamicPlaceholder()) {
|
||||
if ($this->getIsSearchable()) {
|
||||
if (!$this->getIsLazyLoaded()) {
|
||||
return _t(__TRAIT__ . '.SELECT_OR_TYPE_TO_SEARCH', 'Select or type to search...');
|
||||
}
|
||||
return _t(__TRAIT__ . '.TYPE_TO_SEARCH', 'Type to search...');
|
||||
} else {
|
||||
return _t(__TRAIT__ . '.SELECT', 'Select...');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the placeholder text
|
||||
*
|
||||
* Calling this will also call setHasEmptyDefault(true), if the method exists on the class,
|
||||
* which is required for the placeholder functionality to work on SearchableDropdownField
|
||||
*
|
||||
* In the case of SearchableDropField this method should be used instead of setEmptyString() which
|
||||
* will be remvoved in a future version
|
||||
*/
|
||||
public function setPlaceholder(string $placeholder): static
|
||||
{
|
||||
$this->placeholder = $placeholder;
|
||||
// SearchableDropdownField will have the setHasEmptyDefault() method from SingleSelectField
|
||||
if (method_exists($this, 'setHasEmptyDefault')) {
|
||||
$this->setHasEmptyDefault(true);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search context to use
|
||||
* If a search context has been set via setSearchContext() that will be used
|
||||
* Will fallback to using the dataobjects default search context if a sourceList has been set
|
||||
* Otherwise will return null
|
||||
*/
|
||||
public function getSearchContext(): ?SearchContext
|
||||
{
|
||||
if ($this->searchContext) {
|
||||
return $this->searchContext;
|
||||
}
|
||||
if ($this->sourceList) {
|
||||
$dataClass = $this->sourceList->dataClass();
|
||||
/** @var DataObject $obj */
|
||||
$obj = $dataClass::create();
|
||||
return $obj->getDefaultSearchContext();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the search context to use instead of the dataobjects default search context
|
||||
*
|
||||
* Calling this will also call setUseSearchContext(true)
|
||||
*/
|
||||
public function setSearchContext(?SearchContext $searchContext): static
|
||||
{
|
||||
$this->searchContext = $searchContext;
|
||||
$this->setUseSearchContext(true);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether to use a dynamic placeholder if a normal placeholder is not set
|
||||
*/
|
||||
public function getUseDynamicPlaceholder(): bool
|
||||
{
|
||||
return $this->useDynamicPlaceholder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to use a dynamic placeholder if a normal placeholder is not set
|
||||
*/
|
||||
public function setUseDynamicPlaceholder(bool $useDynamicPlaceholder): static
|
||||
{
|
||||
$this->useDynamicPlaceholder = $useDynamicPlaceholder;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether to use a search context instead searching on labelField
|
||||
*/
|
||||
public function getUseSearchContext(): bool
|
||||
{
|
||||
return $this->useSearchContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to use a search context instead searching on labelField
|
||||
*/
|
||||
public function setUseSearchContext(bool $useSearchContext): static
|
||||
{
|
||||
$this->useSearchContext = $useSearchContext;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether the field allows searching by typing characters into field
|
||||
*/
|
||||
public function getIsSearchable(): bool
|
||||
{
|
||||
return $this->isSearchable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the field allows searching by typing characters into field
|
||||
*/
|
||||
public function setIsSearchable(bool $isSearchable): static
|
||||
{
|
||||
$this->isSearchable = $isSearchable;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* This returns an array rather than a DataList purely to retain compatibility with ancestor getSource()
|
||||
*/
|
||||
public function getSource(): array
|
||||
{
|
||||
return $this->getListMap($this->sourceList);
|
||||
}
|
||||
|
||||
/*
|
||||
* @param mixed $source
|
||||
*/
|
||||
public function setSource($source): static
|
||||
{
|
||||
// Setting to $this->sourceList instead of $this->source because SelectField.source
|
||||
// docblock type is array|ArrayAccess i.e. does not allow DataList
|
||||
$this->sourceList = $source;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the field to use for the label of the option
|
||||
*
|
||||
* The default value of 'Title' will map to DataObject::getTitle() if a Title DB field does not exist
|
||||
*/
|
||||
public function getLabelField(): string
|
||||
{
|
||||
return $this->labelField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the field to use for the label of the option
|
||||
*/
|
||||
public function setLabelField(string $labelField): static
|
||||
{
|
||||
$this->labelField = $labelField;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttributes(): array
|
||||
{
|
||||
$name = $this->getName();
|
||||
if ($this->isMultiple) {
|
||||
$name .= '[]';
|
||||
}
|
||||
return array_merge(
|
||||
parent::getAttributes(),
|
||||
[
|
||||
'name' => $name,
|
||||
'data-schema' => json_encode($this->getSchemaData()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of selected ID's
|
||||
*/
|
||||
public function getValueArray(): array
|
||||
{
|
||||
$value = $this->Value();
|
||||
if (empty($value)) {
|
||||
return [];
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$arr = $value;
|
||||
// Normalise FormBuilder values to be like Page EditForm values
|
||||
//
|
||||
// FormBuilder $values for non-multi field will be
|
||||
// [
|
||||
// 'label' => 'MyTitle15', 'value' => '10'
|
||||
// ]
|
||||
if (array_key_exists('value', $arr)) {
|
||||
$val = (int) $arr['value'];
|
||||
return $val ? [$val] : [];
|
||||
}
|
||||
// FormBuilder $values for multi will be
|
||||
// [
|
||||
// 0 => ['label' => '10', 'value' => 'MyTitle10', 'selected' => false],
|
||||
// 1 => ['label' => '15', 'value' => 'MyTitle15', 'selected' => false]
|
||||
// ];
|
||||
$firstKey = array_key_first($arr);
|
||||
if (is_array($arr[$firstKey]) && array_key_exists('value', $arr[$firstKey])) {
|
||||
$newArr = [];
|
||||
foreach ($arr as $innerArr) {
|
||||
$val = (int) $innerArr['value'];
|
||||
if ($val) {
|
||||
$newArr[] = $val;
|
||||
}
|
||||
}
|
||||
return $newArr;
|
||||
}
|
||||
// Page EditForm $values for non-multi field will be
|
||||
// [
|
||||
// 0 => '10',
|
||||
// ];
|
||||
// Page EditForm $values for multi will be
|
||||
// [
|
||||
// 0 => '10',
|
||||
// 1 => '15'
|
||||
// ];
|
||||
return array_map('intval', $arr);
|
||||
}
|
||||
if ((is_string($value) || is_int($value)) && ctype_digit((string) $value) && $value != 0) {
|
||||
return [(int) $value];
|
||||
}
|
||||
if ($value instanceof SS_List) {
|
||||
return array_filter($value->column('ID'));
|
||||
}
|
||||
if ($value instanceof DataObject && $value->exists()) {
|
||||
return [$value->ID];
|
||||
}
|
||||
// Don't know what value is, handle gracefully. We should not raise an exception here because
|
||||
// of there is a bad data for whatever a content editor will not be able to resolve and it will
|
||||
// render part of the CMS unusable
|
||||
// /** @var LoggerInterface $logger */
|
||||
$logger = Injector::inst()->get(LoggerInterface::class);
|
||||
$logger->warning('Could not determine value in ' . __CLASS__ . '::getValueArray()');
|
||||
return [];
|
||||
}
|
||||
|
||||
public function saveInto(DataObjectInterface $record): void
|
||||
{
|
||||
$name = $this->getName();
|
||||
$ids = $this->getValueArray();
|
||||
if (substr($name, -2) === 'ID') {
|
||||
// has_one field
|
||||
$record->$name = $ids[0] ?? 0;
|
||||
// polymorphic has_one
|
||||
if (is_a($record, DataObject::class)) {
|
||||
/** @var DataObject $record */
|
||||
$classNameField = substr($name, 0, -2) . 'Class';
|
||||
if ($record->hasField($classNameField)) {
|
||||
$record->$classNameField = $ids ? $this->sourceList->dataClass() : '';
|
||||
}
|
||||
}
|
||||
$record->write();
|
||||
} else {
|
||||
// has_many / many_many field
|
||||
if (!method_exists($record, 'hasMethod')) {
|
||||
throw new LogicException('record does not have method hasMethod()');
|
||||
}
|
||||
/** @var DataObject $record */
|
||||
if (!$record->hasMethod($name)) {
|
||||
throw new LogicException("Relation $name does not exist");
|
||||
}
|
||||
/** @var Relation $relation */
|
||||
$relationList = $record->$name();
|
||||
// Use RelationList rather than Relation here since some Relation classes don't allow setting value
|
||||
// but RelationList does
|
||||
if (!is_a($relationList, RelationList::class) && !is_a($relationList, UnsavedRelationList::class)) {
|
||||
throw new LogicException("'$name()' method on {$record->ClassName} doesn't return a relation list");
|
||||
}
|
||||
$relationList->setByIDList($ids);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Validator $validator
|
||||
*/
|
||||
public function validate($validator): bool
|
||||
{
|
||||
return $this->extendValidationResult(true, $validator);
|
||||
}
|
||||
|
||||
public function getSchemaDataType(): string
|
||||
{
|
||||
if ($this->isMultiple) {
|
||||
return FormField::SCHEMA_DATA_TYPE_MULTISELECT;
|
||||
}
|
||||
return FormField::SCHEMA_DATA_TYPE_SINGLESELECT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide data to the JSON schema for the frontend component
|
||||
*/
|
||||
public function getSchemaDataDefaults(): array
|
||||
{
|
||||
$data = parent::getSchemaDataDefaults();
|
||||
$data = $this->updateDataForSchema($data);
|
||||
$name = $this->getName();
|
||||
if ($this->isMultiple && strpos($name, '[') === false) {
|
||||
$name .= '[]';
|
||||
}
|
||||
$data['name'] = $name;
|
||||
$data['disabled'] = $this->isDisabled() || $this->isReadonly();
|
||||
if ($this->getIsLazyLoaded()) {
|
||||
$data['optionUrl'] = Controller::join_links($this->Link(), 'search');
|
||||
} else {
|
||||
$data['options'] = array_values($this->getOptionsForSchema()->toNestedArray());
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function getSchemaStateDefaults(): array
|
||||
{
|
||||
$data = parent::getSchemaStateDefaults();
|
||||
$data = $this->updateDataForSchema($data);
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the field allows multiple values
|
||||
* This is only intended to be called from init() by implemented classes, and not called directly
|
||||
* To instantiate a dropdown where only a single value is allowed, use SearchableDropdownField.
|
||||
* To instantiate a dropdown where multiple values are allowed, use SearchableMultiDropdownField
|
||||
*/
|
||||
protected function setIsMultiple(bool $isMultiple): static
|
||||
{
|
||||
$this->isMultiple = $isMultiple;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getOptionsForSearchRequest(string $term): array
|
||||
{
|
||||
if (!$this->sourceList) {
|
||||
return [];
|
||||
}
|
||||
$dataClass = $this->sourceList->dataClass();
|
||||
$labelField = $this->getLabelField();
|
||||
/** @var DataObject $obj */
|
||||
$obj = $dataClass::create();
|
||||
$key = $this->getUseSearchContext() ? $obj->getGeneralSearchFieldName() : $this->getLabelField();
|
||||
$searchParams = [$key => $term];
|
||||
$hasLabelField = (bool) $obj->getSchema()->fieldSpec($dataClass, $labelField);
|
||||
$sort = $hasLabelField ? $labelField : null;
|
||||
$limit = $this->getLazyLoadLimit();
|
||||
$newList = $this->getSearchContext()->getQuery($searchParams, $sort, $limit);
|
||||
$options = [];
|
||||
foreach ($newList as $item) {
|
||||
$options[] = [
|
||||
'value' => $item->ID,
|
||||
'label' => $item->$labelField,
|
||||
];
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function getOptionsForSchema(bool $onlySelected = false): ArrayList
|
||||
{
|
||||
$options = ArrayList::create();
|
||||
if (!$this->sourceList) {
|
||||
return $options;
|
||||
}
|
||||
$values = $this->getValueArray();
|
||||
if (empty($values)) {
|
||||
$selectedValuesList = ArrayList::create();
|
||||
} else {
|
||||
$selectedValuesList = $this->sourceList->filterAny(['ID' => $values]);
|
||||
}
|
||||
// SearchableDropdownField will have the getHasEmptyDefault() method from SingleSelectField
|
||||
// Note that SingleSelectField::getSourceEmpty() will not be called for the react-select component
|
||||
if (!$onlySelected && method_exists($this, 'getHasEmptyDefault') && $this->getHasEmptyDefault()) {
|
||||
// Add an empty option to the start of the list of options
|
||||
$options->push(ArrayData::create([
|
||||
'value' => 0,
|
||||
'label' => $this->getPlaceholder(),
|
||||
'selected' => $selectedValuesList->count() === 0
|
||||
]));
|
||||
}
|
||||
if ($onlySelected) {
|
||||
$options = $this->updateOptionsForSchema($options, $selectedValuesList, $selectedValuesList);
|
||||
} else {
|
||||
$options = $this->updateOptionsForSchema($options, $this->sourceList, $selectedValuesList);
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function updateDataForSchema(array $data): array
|
||||
{
|
||||
$selectedOptions = $this->getOptionsForSchema(true);
|
||||
$value = $selectedOptions->count() ? $selectedOptions->toNestedArray() : null;
|
||||
if (is_null($value)
|
||||
&& method_exists($this, 'getHasEmptyDefault')
|
||||
&& !$this->getHasEmptyDefault()
|
||||
) {
|
||||
$allOptions = $this->getOptionsForSchema();
|
||||
$value = $allOptions->first()?->toMap();
|
||||
}
|
||||
$data['lazyLoad'] = $this->getIsLazyLoaded();
|
||||
$data['clearable'] = $this->getIsClearable();
|
||||
$data['multi'] = $this->isMultiple;
|
||||
$data['placeholder'] = $this->getPlaceholder();
|
||||
$data['searchable'] = $this->getIsSearchable();
|
||||
$data['value'] = $value;
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @var ArrayList $options The options list being updated that will become <options>
|
||||
* @var DataList|ArrayList $items The items to be turned into options
|
||||
* @var DataList|ArrayList $values The values that have been selected i.e. the value of the Field
|
||||
*/
|
||||
private function updateOptionsForSchema(
|
||||
ArrayList $options,
|
||||
DataList|ArrayList $items,
|
||||
DataList|ArrayList $selectedValuesList
|
||||
): ArrayList {
|
||||
$labelField = $this->getLabelField();
|
||||
$selectedIDs = $selectedValuesList->column('ID');
|
||||
/** @var DataObject $item */
|
||||
foreach ($items as $item) {
|
||||
$selected = in_array($item->ID, $selectedIDs);
|
||||
$options->push(ArrayData::create([
|
||||
'value' => $item->ID,
|
||||
'label' => $item->$labelField,
|
||||
'selected' => $selected,
|
||||
]));
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
}
|
29
src/Forms/SearchableMultiDropdownField.php
Normal file
29
src/Forms/SearchableMultiDropdownField.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Forms;
|
||||
|
||||
use SilverStripe\Forms\MultiSelectField;
|
||||
use SilverStripe\Forms\SearchableDropdownTrait;
|
||||
use SilverStripe\ORM\DataList;
|
||||
|
||||
class SearchableMultiDropdownField extends MultiSelectField
|
||||
{
|
||||
use SearchableDropdownTrait;
|
||||
|
||||
// This needs to be defined on the class, not the trait, or else a there is a PHP error
|
||||
protected $schemaComponent = 'SearchableDropdownField';
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $title,
|
||||
DataList $source,
|
||||
$value = null,
|
||||
$labelField = 'Title'
|
||||
) {
|
||||
parent::__construct($name, $title, $source, $value);
|
||||
$this->setLabelField($labelField);
|
||||
$this->addExtraClass('ss-searchable-dropdown-field');
|
||||
$this->setIsMultiple(true);
|
||||
$this->setIsClearable(true);
|
||||
}
|
||||
}
|
@ -36,6 +36,8 @@ use SilverStripe\Security\Group;
|
||||
use SilverStripe\Security\Permission;
|
||||
use SilverStripe\Security\PermissionCheckboxSetField;
|
||||
use SilverStripe\Security\PermissionCheckboxSetField_Readonly;
|
||||
use SilverStripe\Forms\SearchableMultiDropdownField;
|
||||
use SilverStripe\Forms\SearchableDropdownField;
|
||||
|
||||
class FormFieldTest extends SapphireTest
|
||||
{
|
||||
@ -554,6 +556,10 @@ class FormFieldTest extends SapphireTest
|
||||
case GridState::class:
|
||||
$args = [GridField::create('GF')];
|
||||
break;
|
||||
case SearchableDropdownField::class:
|
||||
case SearchableMultiDropdownField::class:
|
||||
$args = ['Test', 'Test', Group::get()];
|
||||
break;
|
||||
//
|
||||
// Fields from other modules included in the kitchensink recipe
|
||||
//
|
||||
|
220
tests/php/Forms/SearchableDropdownTraitTest.php
Normal file
220
tests/php/Forms/SearchableDropdownTraitTest.php
Normal file
@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Forms\Tests;
|
||||
|
||||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\Forms\FieldList;
|
||||
use SilverStripe\Forms\SearchableDropdownField;
|
||||
use SilverStripe\Forms\Tests\FormTest\Team;
|
||||
use SilverStripe\Forms\SearchableMultiDropdownField;
|
||||
use SilverStripe\Forms\FormField;
|
||||
use SilverStripe\ORM\Search\SearchContext;
|
||||
use SilverStripe\Security\SecurityToken;
|
||||
use SilverStripe\Forms\HiddenField;
|
||||
use stdClass;
|
||||
use SilverStripe\Forms\Form;
|
||||
|
||||
class SearchableDropdownTraitTest extends SapphireTest
|
||||
{
|
||||
protected static $fixture_file = 'SearchableDropdownTraitTest.yml';
|
||||
|
||||
protected static $extra_dataobjects = [
|
||||
Team::class,
|
||||
];
|
||||
|
||||
public function testGetSchemaDataType(): void
|
||||
{
|
||||
$singleField = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$multiField = new SearchableMultiDropdownField('MyField', 'MyField', Team::get());
|
||||
$this->assertSame($singleField->getSchemaDataType(), FormField::SCHEMA_DATA_TYPE_SINGLESELECT);
|
||||
$this->assertSame($multiField->getSchemaDataType(), FormField::SCHEMA_DATA_TYPE_MULTISELECT);
|
||||
}
|
||||
|
||||
public function testSearch(): void
|
||||
{
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$request = new HTTPRequest('GET', 'someurl', ['term' => 'Team']);
|
||||
$request->addHeader('X-SecurityID', SecurityToken::getSecurityID());
|
||||
$response = $field->search($request);
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
$actual = json_decode($response->getBody(), true);
|
||||
$ids = Team::get()->column('ID');
|
||||
$names = Team::get()->column('Name');
|
||||
$expected = [
|
||||
['value' => $ids[0], 'label' => $names[0]],
|
||||
['value' => $ids[1], 'label' => $names[1]],
|
||||
['value' => $ids[2], 'label' => $names[2]],
|
||||
];
|
||||
$this->assertSame($expected, $actual);
|
||||
}
|
||||
|
||||
public function testSearchNoCsrfToken(): void
|
||||
{
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$request = new HTTPRequest('GET', 'someurl', ['term' => 'Team']);
|
||||
$response = $field->search($request);
|
||||
$this->assertSame(400, $response->getStatusCode());
|
||||
$actual = json_decode($response->getBody(), true);
|
||||
$expected = ['message' => 'Invalid CSRF token'];
|
||||
$this->assertSame($expected, $actual);
|
||||
}
|
||||
|
||||
public function testPlaceholder(): void
|
||||
{
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$this->assertSame('Select or type to search...', $field->getPlaceholder());
|
||||
$field->setIsSearchable(false);
|
||||
$this->assertSame('Select...', $field->getPlaceholder());
|
||||
$field->setIsLazyLoaded(true);
|
||||
$this->assertSame('Type to search...', $field->getPlaceholder());
|
||||
$field->setEmptyString('My empty string');
|
||||
$this->assertSame('My empty string', $field->getPlaceholder());
|
||||
$field->setPlaceholder('My placeholder');
|
||||
$this->assertSame('My placeholder', $field->getPlaceholder());
|
||||
}
|
||||
|
||||
public function testSeachContext(): void
|
||||
{
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$team = Team::get()->first();
|
||||
// assert fallback is the default search context
|
||||
$this->assertSame(
|
||||
$team->getDefaultSearchContext()->getFields()->dataFieldNames(),
|
||||
$field->getSearchContext()->getFields()->dataFieldNames()
|
||||
);
|
||||
// assert setting a custom search context should override the default
|
||||
$searchContext = new SearchContext(Team::class, new FieldList(new HiddenField('lorem')));
|
||||
$field->setSearchContext($searchContext);
|
||||
$this->assertSame(
|
||||
$searchContext->getFields()->dataFieldNames(),
|
||||
$field->getSearchContext()->getFields()->dataFieldNames()
|
||||
);
|
||||
}
|
||||
|
||||
public function testLabelField(): void
|
||||
{
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
// will use the default value of 'Title' for label field
|
||||
$this->assertSame('Title', $field->getLabelField());
|
||||
// can override the default
|
||||
$field->setLabelField('Something');
|
||||
$this->assertSame('Something', $field->getLabelField());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideGetValueArray
|
||||
*/
|
||||
public function testGetValueArray(mixed $value, string|array $expected): void
|
||||
{
|
||||
if ($value === '<DataListValue>') {
|
||||
$value = Team::get();
|
||||
$ids = Team::get()->column('ID');
|
||||
$expected = [$ids[0], $ids[1], $ids[2]];
|
||||
} elseif ($value === '<DataObjectValue>') {
|
||||
$value = Team::get()->first();
|
||||
$expected = [$value->ID];
|
||||
}
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$field->setValue($value);
|
||||
$this->assertSame($expected, $field->getValueArray());
|
||||
}
|
||||
|
||||
public function provideGetValueArray(): array
|
||||
{
|
||||
return [
|
||||
'empty' => [
|
||||
'value' => '',
|
||||
'expected' => [],
|
||||
],
|
||||
'array single form builder' => [
|
||||
'value' => ['label' => 'MyTitle15', 'value' => '10', 'selected' => false],
|
||||
'expected' => [10],
|
||||
],
|
||||
'array multi form builder' => [
|
||||
'value' => [
|
||||
['label' => 'MyTitle10', 'value' => '10', 'selected' => true],
|
||||
['label' => 'MyTitle15', 'value' => '15', 'selected' => false],
|
||||
],
|
||||
'expected' => [10, 15],
|
||||
],
|
||||
'string int' => [
|
||||
'value' => '3',
|
||||
'expected' => [3],
|
||||
],
|
||||
'zero string' => [
|
||||
'value' => '0',
|
||||
'expected' => [],
|
||||
],
|
||||
'datalist' => [
|
||||
'value' => '<DataListValue>',
|
||||
'expected' => '<DataListExpected>',
|
||||
],
|
||||
'dataobject' => [
|
||||
'value' => '<DataObjectValue>',
|
||||
'expected' => '<DataObjectExpected>',
|
||||
],
|
||||
'something else' => [
|
||||
'value' => new stdClass(),
|
||||
'expected' => [],
|
||||
],
|
||||
'negative int' => [
|
||||
'value' => -1,
|
||||
'expected' => [],
|
||||
],
|
||||
'negative string int' => [
|
||||
'value' => '-1',
|
||||
'expected' => [],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testGetSchemaDataDefaults(): void
|
||||
{
|
||||
// setting a form is required for Link() which is called for 'optionUrl'
|
||||
$form = new Form();
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$field->setHasEmptyDefault(false);
|
||||
$field->setForm($form);
|
||||
$team = Team::get()->first();
|
||||
$schema = $field->getSchemaDataDefaults();
|
||||
$this->assertSame('MyField', $schema['name']);
|
||||
$this->assertSame(['value' => $team->ID, 'label' => $team->Name, 'selected' => false], $schema['value']);
|
||||
$this->assertFalse($schema['multi']);
|
||||
$this->assertTrue(is_array($schema['options']));
|
||||
$this->assertFalse(array_key_exists('optionUrl', $schema));
|
||||
$this->assertFalse($schema['disabled']);
|
||||
// lazyload changes options/optionUrl
|
||||
$field->setIsLazyLoaded(true);
|
||||
$schema = $field->getSchemaDataDefaults();
|
||||
$this->assertFalse(array_key_exists('options', $schema));
|
||||
$this->assertSame('field/MyField/search', $schema['optionUrl']);
|
||||
// disabled
|
||||
$field->setReadonly(true);
|
||||
$schema = $field->getSchemaDataDefaults();
|
||||
$this->assertTrue($schema['disabled']);
|
||||
// multi field name
|
||||
$field = new SearchableMultiDropdownField('MyField', 'MyField', Team::get());
|
||||
$field->setForm($form);
|
||||
$schema = $field->getSchemaDataDefaults();
|
||||
$this->assertSame('MyField[]', $schema['name']);
|
||||
$this->assertTrue($schema['multi']);
|
||||
// accessors
|
||||
$field = new SearchableDropdownField('MyField', 'MyField', Team::get());
|
||||
$field->setForm($form);
|
||||
$schema = $field->getSchemaDataDefaults();
|
||||
$this->assertFalse($schema['lazyLoad']);
|
||||
$this->assertFalse($schema['clearable']);
|
||||
$this->assertSame('Select or type to search...', $schema['placeholder']);
|
||||
$this->assertTrue($schema['searchable']);
|
||||
$field->setIsLazyLoaded(true);
|
||||
$field->setIsClearable(true);
|
||||
$field->setPlaceholder('My placeholder');
|
||||
$field->setIsSearchable(false);
|
||||
$schema = $field->getSchemaDataDefaults();
|
||||
$this->assertTrue($schema['lazyLoad']);
|
||||
$this->assertTrue($schema['clearable']);
|
||||
$this->assertSame('My placeholder', $schema['placeholder']);
|
||||
$this->assertFalse($schema['searchable']);
|
||||
}
|
||||
}
|
7
tests/php/Forms/SearchableDropdownTraitTest.yml
Normal file
7
tests/php/Forms/SearchableDropdownTraitTest.yml
Normal file
@ -0,0 +1,7 @@
|
||||
SilverStripe\Forms\Tests\FormTest\Team:
|
||||
team01:
|
||||
Name: Team 01
|
||||
team02:
|
||||
Name: Team 02
|
||||
team03:
|
||||
Name: Team 03
|
Loading…
Reference in New Issue
Block a user