From 00bbb807cc577f13d40a9b84f8faa1124ae1c489 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Mon, 27 Nov 2023 14:53:54 +1300 Subject: [PATCH] NEW SearchableDropdownField --- src/Forms/Multi | 0 src/Forms/SearchableDropdownField.php | 18 + src/Forms/SearchableDropdownTrait.php | 501 +++++++++++++++++++++ src/Forms/SearchableMultiDropdownField.php | 20 + src/Forms/SingleSelectField.php | 1 - templates/SilverStripe/Forms/TagField.jss | 1 + 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 src/Forms/Multi create mode 100644 src/Forms/SearchableDropdownField.php create mode 100644 src/Forms/SearchableDropdownTrait.php create mode 100644 src/Forms/SearchableMultiDropdownField.php create mode 100644 templates/SilverStripe/Forms/TagField.jss diff --git a/src/Forms/Multi b/src/Forms/Multi new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/Forms/SearchableDropdownField.php b/src/Forms/SearchableDropdownField.php new file mode 100644 index 00000000000..a172e667de2 --- /dev/null +++ b/src/Forms/SearchableDropdownField.php @@ -0,0 +1,18 @@ +setLabelField($labelField); + $this->addExtraClass('ss-searchable-dropdown-field'); + $this->init(); + } + + /** + * Returns a JSON string of options for lazy loading. + */ + public function search(HTTPRequest $request): HTTPResponse + { + $term = $request->getVar('term'); + $options = $this->getOptionsForSearchRequest($term); + $response = HTTPResponse::create(); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(json_encode(['items' => $options])); // today rename items to options on front-end + 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 whehter 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; + } + } + return ''; + } + + /** + * Set the placeholder text + * + * In the case of SearchableDropField this 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 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'); + } + $this->sourceList = $source; + return $this; + } + + /** + * Get the field to use for the label of the option + */ + 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->getIsMultiple() && strpos($name, '[') === false) { + $name .= '[]'; + } + return array_merge( + parent::getAttributes(), + [ + 'name' => $name, + 'style' => 'width: 100%', + 'data-schema' => json_encode($this->getSchemaData()), + ] + ); + } + + /** + * Return a list of selected ID's + */ + public function getValueArray(): array + { + $value = $this->Value(); + if (empty($value)) { + return []; + } + if (is_array($value)) { + return $value; + } + if (is_int($value) || ctype_digit((string) $value)) { + return [(int) $value]; + } + if ($value instanceof SS_List) { + return $value->column($this->getLabelField()); + } + if ($value instanceof DataObject && $value->exists()) { + return [$value->ID]; + } + return [trim((string) $value)]; // todo: throw exception? + } + + public function Field($properties = []): DBHTMLText + { + $this->addExtraClass('entwine'); + return $this->customise($properties)->renderWith(self::class); + } + + public function saveInto(DataObjectInterface $record): void + { + if (empty($record)) { + return; + } + $name = $this->getName(); + $values = $this->getValueArray(); + if (!$values || !is_array($values)) { + $values = []; + } else { + // 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 + // [ + // 'Title' => 'MyTitle15', 'Value' => '10', 'Selected' => false + // ] + if (array_key_exists('Value', $values)) { + $values = [$values['Value']]; + } + // Page EditForm $values for multi will be + // [ + // 0 => '10', + // 1 => '15' + // ]; + // FormBuilder $values for multi will be + // [ + // 0 => ['Title' => '10', 'Value' => 'MyTitle10', 'Selected' => true], + // 1 => ['Title' => '15', 'Value' => 'MyTitle15', 'Selected' => false] + // ]; + if (array_key_exists(0, $values) && is_array($values[0]) && array_key_exists('Value', $values[0])) { + $values = array_map(fn ($arr) => $arr['Value'], $values); + } + } + $ids = array_filter(array_values($values)); + 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 */ + $relation = $record->$name(); + if (!is_a($relation, Relation::class)) { + throw new LogicException("$name is not a Relation"); + } + $relation->setByIDList($ids); + } + + /** + * @param Validator $validator + */ + public function validate($validator): bool + { + return $this->extendValidationResult(true, $validator); + } + + /** + * Prevent the default + */ + public function Type(): string + { + return ''; + } + + public function getSchemaDataType(): string + { + if ($this->getIsMultiple()) { + 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->getIsMultiple() && 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; + } + + abstract protected function init(): void; + + /** + * Get whether the field allows multiple values + */ + protected function getIsMultiple(): bool + { + return $this->isMultiple; + } + + /** + * Set whether the field allows multiple values + * This is currently only intended to be called from init(). + * To instantiate a dropdown where only a single value is allowed, use SearchableDropdownField. + * To instantiate a dropdown where multiple values are allowed, use SearchableMultiDropdownField. + * In future versions this method should be public (along with getIsMultiple()) and the above + * two classes should be removed and a single DropdownField class should be used instead + */ + 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(); + $hasLabelField = (bool) $obj->getSchema()->fieldSpec($dataClass, $labelField); + $sort = $hasLabelField ? $labelField : null; + // Use SearchContext and getGeneralSearchFieldName() to query against all fields + // defined in DataObject.searchable_fields config + $newList = $obj->getDefaultSearchContext()->getQuery( + [$obj->getGeneralSearchFieldName() => $term], + $sort, + $this->getLazyLoadLimit() + ); + // Map into a distinct list + $options = []; + foreach ($newList->map('ID', $labelField) as $id => $title) { + $options[] = [ + 'Value' => $id, + 'Title' => $title, + ]; + } + return $options; + } + + private function getOptionsForSchema(bool $onlySelected = false): ArrayList + { + $options = ArrayList::create(); + if (!$this->sourceList) { + return $options; + } + $values = $this->getValueArray(); + if (!empty($values)) { + if (array_filter($values, 'is_int')) { + $queryField = 'ID'; + } else { + $queryField = $this->getLabelField(); // todo remove? + } + $selectedValuesList = $this->sourceList->filterAny([ + $queryField => $values + ]); + } else { + $selectedValuesList = ArrayList::create(); + } + // 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, + 'Title' => $this->getPlaceholder(), + 'Selected' => $selectedValuesList->count() === 0 + ])); + } + if ($onlySelected) { + $options = $this->updateOptions($options, $selectedValuesList, $selectedValuesList); + } else { + $options = $this->updateOptions($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->getIsMultiple(); + $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 updateOptions( + 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, + 'Title' => $item->$labelField, + 'Selected' => $selected, + ])); + } + return $options; + } +} diff --git a/src/Forms/SearchableMultiDropdownField.php b/src/Forms/SearchableMultiDropdownField.php new file mode 100644 index 00000000000..46fec276db6 --- /dev/null +++ b/src/Forms/SearchableMultiDropdownField.php @@ -0,0 +1,20 @@ +setIsMultiple(true); + $this->setIsClearable(true); + } +} diff --git a/src/Forms/SingleSelectField.php b/src/Forms/SingleSelectField.php index 2230cd807c8..15a6b0fc264 100644 --- a/src/Forms/SingleSelectField.php +++ b/src/Forms/SingleSelectField.php @@ -9,7 +9,6 @@ */ abstract class SingleSelectField extends SelectField { - /** * Show the first