mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
NEW SearchableDropdownField
This commit is contained in:
parent
ff38ff1d63
commit
23eca53dfb
@ -141,6 +141,10 @@ en:
|
|||||||
IsNullLabel: 'Is Null'
|
IsNullLabel: 'Is Null'
|
||||||
SilverStripe\Forms\NumericField:
|
SilverStripe\Forms\NumericField:
|
||||||
VALIDATION: "'{value}' is not a number, only numbers can be accepted for this field"
|
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:
|
SilverStripe\Forms\TextField:
|
||||||
VALIDATEMAXLENGTH: 'The value for {name} must not exceed {maxLength} characters in length'
|
VALIDATEMAXLENGTH: 'The value for {name} must not exceed {maxLength} characters in length'
|
||||||
SilverStripe\Forms\TimeField:
|
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\Permission;
|
||||||
use SilverStripe\Security\PermissionCheckboxSetField;
|
use SilverStripe\Security\PermissionCheckboxSetField;
|
||||||
use SilverStripe\Security\PermissionCheckboxSetField_Readonly;
|
use SilverStripe\Security\PermissionCheckboxSetField_Readonly;
|
||||||
|
use SilverStripe\Forms\SearchableMultiDropdownField;
|
||||||
|
use SilverStripe\Forms\SearchableDropdownField;
|
||||||
|
|
||||||
class FormFieldTest extends SapphireTest
|
class FormFieldTest extends SapphireTest
|
||||||
{
|
{
|
||||||
@ -554,6 +556,10 @@ class FormFieldTest extends SapphireTest
|
|||||||
case GridState::class:
|
case GridState::class:
|
||||||
$args = [GridField::create('GF')];
|
$args = [GridField::create('GF')];
|
||||||
break;
|
break;
|
||||||
|
case SearchableDropdownField::class:
|
||||||
|
case SearchableMultiDropdownField::class:
|
||||||
|
$args = ['Test', 'Test', Group::get()];
|
||||||
|
break;
|
||||||
//
|
//
|
||||||
// Fields from other modules included in the kitchensink recipe
|
// 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