diff --git a/lang/en.yml b/lang/en.yml index 0fd9893a967..8c6d7171382 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -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: diff --git a/src/Forms/SearchableDropdownField.php b/src/Forms/SearchableDropdownField.php new file mode 100644 index 00000000000..eb740ed7dac --- /dev/null +++ b/src/Forms/SearchableDropdownField.php @@ -0,0 +1,29 @@ +setSource() which will call + // setSource() in this class which throw an exception if $source is not a DataList + parent::__construct($name, $title, $source, $value); + $this->setLabelField($labelField); + $this->addExtraClass('ss-searchable-dropdown-field'); + } +} diff --git a/src/Forms/SearchableDropdownTrait.php b/src/Forms/SearchableDropdownTrait.php new file mode 100644 index 00000000000..624f16b92b6 --- /dev/null +++ b/src/Forms/SearchableDropdownTrait.php @@ -0,0 +1,569 @@ +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; + } + } + 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 + { + if (!is_a($source, DataList::class)) { + throw new InvalidArgumentException('Source must be a DataList'); + } + // 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 && strpos($name, '[') === false) { + $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 + // + // Page EditForm $values for non-multi field will be + // [ + // 0 => '10', + // ]; + // 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] : []; + } + // Page EditForm $values for multi will be + // [ + // 0 => '10', + // 1 => '15' + // ]; + // 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; + } + } + try { + // This is a catch all for unexpected $values that cannot be cast to string e.g. stdClass + // We need to cast to string for the ctype_digit() operation below + (string) $value; + } catch (Error $e) { + $value = 0; + } + if (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 + return []; + } + + public function Field($properties = []): DBHTMLText + { + // The entwine class needs to be added so that this field is rendered in an entwine context + $this->addExtraClass('entwine'); + return parent::Field($properties); + } + + 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; + $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 + * @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; + } +} diff --git a/src/Forms/SearchableMultiDropdownField.php b/src/Forms/SearchableMultiDropdownField.php new file mode 100644 index 00000000000..24e7a7e86f5 --- /dev/null +++ b/src/Forms/SearchableMultiDropdownField.php @@ -0,0 +1,32 @@ +setSource() which will call + // setSource() in this class which throw an exception if $source is not a DataList + parent::__construct($name, $title, $source, $value); + $this->setLabelField($labelField); + $this->addExtraClass('ss-searchable-dropdown-field'); + $this->setIsMultiple(true); + $this->setIsClearable(true); + } +} diff --git a/tests/php/Forms/FormFieldTest.php b/tests/php/Forms/FormFieldTest.php index 70bf0c515cf..07d6c126884 100644 --- a/tests/php/Forms/FormFieldTest.php +++ b/tests/php/Forms/FormFieldTest.php @@ -36,6 +36,8 @@ 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 @@ public function testValidationExtensionHooksAreCalledOnFormFieldSubclasses() 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 // diff --git a/tests/php/Forms/SearchableDropdownTraitTest.php b/tests/php/Forms/SearchableDropdownTraitTest.php new file mode 100644 index 00000000000..1e8c677aae2 --- /dev/null +++ b/tests/php/Forms/SearchableDropdownTraitTest.php @@ -0,0 +1,219 @@ +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 === '') { + $value = Team::get(); + $ids = Team::get()->column('ID'); + $expected = [$ids[0], $ids[1], $ids[2]]; + } elseif ($value === '') { + $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' => '', + 'expected' => '', + ], + 'dataobject' => [ + 'value' => '', + 'expected' => '', + ], + '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->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']); + } +} diff --git a/tests/php/Forms/SearchableDropdownTraitTest.yml b/tests/php/Forms/SearchableDropdownTraitTest.yml new file mode 100644 index 00000000000..8ed8217342e --- /dev/null +++ b/tests/php/Forms/SearchableDropdownTraitTest.yml @@ -0,0 +1,7 @@ +SilverStripe\Forms\Tests\FormTest\Team: + team01: + Name: Team 01 + team02: + Name: Team 02 + team03: + Name: Team 03