From: Matthias Schmidt Date: Sat, 30 Jun 2018 07:48:37 +0000 (+0200) Subject: Add `MultipleSelectionFormField` and add selection nesting support X-Git-Tag: 5.2.0_Alpha_1~680^2~28 X-Git-Url: https://git.stricted.de/?a=commitdiff_plain;h=89f146f98c612e7924de77816920cf9553ef626a;p=GitHub%2FWoltLab%2FWCF.git Add `MultipleSelectionFormField` and add selection nesting support See #2509 --- diff --git a/wcfsetup/install/files/acp/templates/__multipleSelectionFormField.tpl b/wcfsetup/install/files/acp/templates/__multipleSelectionFormField.tpl new file mode 100644 index 0000000000..ef57341697 --- /dev/null +++ b/wcfsetup/install/files/acp/templates/__multipleSelectionFormField.tpl @@ -0,0 +1,33 @@ +{include file='__formFieldHeader'} + +{if $field->isFilterable()} + + + +{else} + +{/if} + +{include file='__formFieldFooter'} diff --git a/wcfsetup/install/files/acp/templates/__singleSelectionFormField.tpl b/wcfsetup/install/files/acp/templates/__singleSelectionFormField.tpl index 99069fa392..e994be8a68 100644 --- a/wcfsetup/install/files/acp/templates/__singleSelectionFormField.tpl +++ b/wcfsetup/install/files/acp/templates/__singleSelectionFormField.tpl @@ -18,9 +18,9 @@ diff --git a/wcfsetup/install/files/lib/system/form/builder/field/ISelectionFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/ISelectionFormField.class.php index 6f2ea4692d..ba561657f0 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/ISelectionFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/ISelectionFormField.class.php @@ -13,6 +13,16 @@ use wcf\data\DatabaseObjectList; * @since 3.2 */ interface ISelectionFormField { + /** + * Returns a structured array that can be used to generate the form field output. + * + * Array elements are `value`, `label`, and `depth`. + * + * @return array + * @throws \BadMethodCallException if nested options are not supported + */ + public function getNestedOptions(): array; + /** * Returns the possible options of this field. * @@ -35,12 +45,25 @@ interface ISelectionFormField { * If a `DatabaseObjectList` object is passed and `$options->objectIDs === null`, * `$options->readObjects()` is called so that the `readObjects()` does not have * to be called by the API user. + * + * If nested options are passed, the given options must be a array or a + * callable returning an array. Each array value must be an array with the + * following entries: `depth`, `label`, and `value`. * * @param array|callable|DatabaseObjectList $options selectable options or callable returning the options + * @param bool $nestedOptions is `true` if the passed options are nested options + * * @return static this field * * @throws \InvalidArgumentException if given options are no array or callable or otherwise invalid * @throws \UnexpectedValueException if callable does not return an array */ - public function options($options): ISelectionFormField; + public function options($options, bool $nestedOptions = false): ISelectionFormField; + + /** + * Returns `true` if the field class supports nested options and `false` otherwise. + * + * @return bool + */ + public function supportsNestedOptions(): bool; } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/MultipleSelectionFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/MultipleSelectionFormField.class.php new file mode 100644 index 0000000000..2a2a0788be --- /dev/null +++ b/wcfsetup/install/files/lib/system/form/builder/field/MultipleSelectionFormField.class.php @@ -0,0 +1,76 @@ + + * @package WoltLabSuite\Core\System\Form\Builder\Field + * @since 3.2 + */ +class MultipleSelectionFormField extends AbstractFormField implements INullableFormField, ISelectionFormField { + use TNullableFormField; + use TSelectionFormField; + + /** + * @inheritDoc + */ + protected $templateName = '__multipleSelectionFormField'; + + /** + * @inheritDoc + */ + public function readValue(): IFormField { + if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { + $value = $this->getDocument()->getRequestData($this->getPrefixedId()); + + if (is_array($value)) { + $this->__value = $value; + } + else if (!$this->isNullable()) { + $this->__value = []; + } + } + + return $this; + } + + /** + * @inheritDoc + */ + public function validate() { + if ($this->getValue() !== null && !empty(array_diff($this->getValue(), array_keys($this->getOptions())))) { + $this->addValidationError(new FormFieldValidationError( + 'invalidValue', + 'wcf.global.form.error.noValidSelection' + )); + } + + parent::validate(); + } + + /** + * @inheritDoc + */ + public function value($value): IFormField { + // ignore `null` as value which can be passed either for nullable + // fields or as value if no options are available + if ($value === null) { + return $this; + } + + if (!is_array($value)) { + throw new \InvalidArgumentException("Given value is no array, " . gettype($value) . " given."); + } + + $unknownValues = array_diff($this->getValue(), array_keys($this->getOptions())); + if (!empty($unknownValues)) { + throw new \InvalidArgumentException("Unknown values '" . implode("', '", $unknownValues) . '"'); + } + + return parent::value($value); + } +} diff --git a/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php index 76c0ce898a..8690a66502 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php @@ -1,6 +1,7 @@ getValue()) && isset($this->getOptions()[$this->getValue()]) && $this instanceof INullableFormField && $this->isNullable()) { + return null; + } + + return parent::getSaveValue(); + } /** - * Sets if the selection options can be filtered by the user so that they - * are able to quickly find the desired option out of a larger list of - * available options. - * - * @param bool $filterable determines if field's options are filterable by user - * @return static this node + * @inheritDoc */ - public function filterable($filterable = true): SingleSelectionFormField { - $this->__filterable = $filterable; + public function readValue(): IFormField { + if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { + $value = $this->getDocument()->getRequestData($this->getPrefixedId()); + + if (is_string($value)) { + $this->__value = $value; + } + } return $this; } /** - * Returns `true` if the selection options can be filtered by the user so - * that they are able to quickly find the desired option out of a larger - * list of available options and returns `false` otherwise. - * - * Per default, fields are not filterable. - * - * @return bool + * @inheritDoc */ - public function isFilterable(): bool { - return $this->__filterable; + public function validate() { + if (!isset($this->getOptions()[$this->getValue()])) { + $this->addValidationError(new FormFieldValidationError( + 'invalidValue', + 'wcf.global.form.error.noValidSelection' + )); + } + + parent::validate(); + } + + /** + * @inheritDoc + */ + public function value($value): IFormField { + // ignore `null` as value which can be passed either for nullable + // fields or as value if no options are available + if ($value === null) { + return $this; + } + + if (!isset($this->getOptions()[$this->getValue()])) { + throw new \InvalidArgumentException("Unknown value '{$value}'"); + } + + return parent::value($value); } } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/TSelectionFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/TSelectionFormField.class.php index 8b3cd9fec9..c52f5f186a 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/TSelectionFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/TSelectionFormField.class.php @@ -3,7 +3,6 @@ declare(strict_types=1); namespace wcf\system\form\builder\field; use wcf\data\DatabaseObjectList; use wcf\data\ITitledObject; -use wcf\system\form\builder\field\validation\FormFieldValidationError; use wcf\system\WCF; use wcf\util\ClassUtil; @@ -17,6 +16,17 @@ use wcf\util\ClassUtil; * @since 3.2 */ trait TSelectionFormField { + /** + * `true` if this field's options are filterable by the user + * @var bool + */ + protected $__filterable = false; + + /** + * @var null|array + */ + protected $__nestedOptions; + /** * possible options to select * @var null|array @@ -24,10 +34,26 @@ trait TSelectionFormField { protected $__options; /** - * possible values of the selection - * @var array + * Sets if the selection options can be filtered by the user so that they + * are able to quickly find the desired option out of a larger list of + * available options. + * + * @param bool $filterable determines if field's options are filterable by user + * @return static this node */ - protected $possibleValues = []; + public function filterable($filterable = true): ISelectionFormField { + $this->__filterable = $filterable; + + return $this; + } + + public function getNestedOptions(): array { + if (!$this->supportsNestedOptions()) { + throw new \BadMethodCallException("Nested options are not supported."); + } + + return $this->__nestedOptions; + } /** * Returns the possible options of this field. @@ -40,25 +66,6 @@ trait TSelectionFormField { return $this->__options; } - /** - * Returns the field value saved in the database. - * - * This method is useful if the actual returned by `getValue()` - * cannot be stored in the database as-is. If the return value of - * `getValue()` is, however, the actual value that should be stored - * in the database, this method is expected to call `getValue()` - * internally. - * - * @return mixed - */ - public function getSaveValue() { - if (empty($this->getValue()) && array_search($this->getValue(), $this->possibleValues) === 0 && $this instanceof INullableFormField && $this->isNullable()) { - return null; - } - - return parent::getSaveValue(); - } - /** * Returns `true` if this node is available and returns `false` otherwise. * @@ -70,7 +77,20 @@ trait TSelectionFormField { */ public function isAvailable(): bool { // selections without any possible values are not available - return !empty($this->possibleValues) && parent::isAvailable(); + return !empty($this->__options) && parent::isAvailable(); + } + + /** + * Returns `true` if the selection options can be filtered by the user so + * that they are able to quickly find the desired option out of a larger + * list of available options and returns `false` otherwise. + * + * Per default, fields are not filterable. + * + * @return bool + */ + public function isFilterable(): bool { + return $this->__filterable; } /** @@ -81,24 +101,35 @@ trait TSelectionFormField { * instead of the given empty value. * * @param array|callable $options selectable options or callable returning the options + * @param bool $nestedOptions * @return static this field * * @throws \InvalidArgumentException if given options are no array or callable or otherwise invalid * @throws \UnexpectedValueException if callable does not return an array */ - public function options($options): ISelectionFormField { - if (!is_array($options) && !is_callable($options) && !($options instanceof DatabaseObjectList)) { + public function options($options, bool $nestedOptions = false): ISelectionFormField { + if ($nestedOptions) { + if (!is_array($options) && !is_callable($options)) { + throw new \InvalidArgumentException("The given nested options are neither an array nor a callable, " . gettype($options) . " given."); + } + } + else if (!is_array($options) && !is_callable($options) && !($options instanceof DatabaseObjectList)) { throw new \InvalidArgumentException("The given options are neither an array, a callable nor an instance of '" . DatabaseObjectList::class . "', " . gettype($options) . " given."); } if (is_callable($options)) { $options = $options(); - if (!is_array($options) && !($options instanceof DatabaseObjectList)) { + if ($nestedOptions) { + if (!is_array($options) && !($options instanceof DatabaseObjectList)) { + throw new \UnexpectedValueException("The nested options callable is expected to return an array, " . gettype($options) . " returned."); + } + } + else if (!is_array($options) && !($options instanceof DatabaseObjectList)) { throw new \UnexpectedValueException("The options callable is expected to return an array or an instance of '" . DatabaseObjectList::class . "', " . gettype($options) . " returned."); } - return $this->options($options); + return $this->options($options, $nestedOptions); } else if ($options instanceof DatabaseObjectList) { // automatically read objects @@ -121,92 +152,107 @@ trait TSelectionFormField { $options = $dboOptions; } - // validate options and read possible values - $validateOptions = function(array &$array) use (&$validateOptions) { - foreach ($array as $key => $value) { - if (is_array($value)) { - if (static::supportsNestedOptions()) { - $validateOptions($value); - } - else { - throw new \InvalidArgumentException("Option '{$key}' must not be an array."); - } + if ($nestedOptions) { + foreach ($options as $key => &$option) { + if (!is_array($option)) { + throw new \InvalidArgumentException("Nested option with key '{$key}' has is no array."); } - else { - if (!is_string($value) && !is_numeric($value)) { - throw new \InvalidArgumentException("Options contain invalid label of type " . gettype($value) . "."); - } - - if (in_array($key, $this->possibleValues)) { - throw new \InvalidArgumentException("Options values must be unique, but '" . $key . "' appears at least twice as value."); - } - - $this->possibleValues[] = $key; - - // fetch language item value - if (preg_match('~^([a-zA-Z0-9-_]+\.){2,}[a-zA-Z0-9-_]+$~', (string) $value)) { - $array[$key] = WCF::getLanguage()->getDynamicVariable($value); + if (count($option) !== 3) { + throw new \InvalidArgumentException("Nested option with key '{$key}' does not contain three elements."); + } + + // check if all required elements exist + foreach (['label', 'value', 'depth'] as $entry) { + if (!isset($option[$entry])) { + throw new \InvalidArgumentException("Nested option with key '{$key}' has no {$entry} entry."); } } + + // validate label + if (is_object($option['label']) && method_exists($option['label'], '__toString')) { + $option['label'] = (string) $option['label']; + } + else if (!is_string($option['label']) && !is_numeric($option['label'])) { + throw new \InvalidArgumentException("Nested option with key '{$key}' contain invalid label of type " . gettype($option['label']) . "."); + } + + // resolve language item for label + if (preg_match('~^([a-zA-Z0-9-_]+\.){2,}[a-zA-Z0-9-_]+$~', (string) $option['label'])) { + $option['label'] = WCF::getLanguage()->getDynamicVariable($option['label']); + } + + // validate value + if (!is_string($option['value']) && !is_numeric($option['value'])) { + throw new \InvalidArgumentException("Nested option with key '{$key}' contain invalid value of type " . gettype($option['label']) . "."); + } + else if (isset($this->__options[$option['value']])) { + throw new \InvalidArgumentException("Options values must be unique, but '{$option['value']}' appears at least twice as value."); + } + + // save value + $this->__options[$option['value']] = $option['label']; + + // validate depth + if (!is_int($option['depth'])) { + throw new \InvalidArgumentException("Depth of nested option with key '{$key}' is no integer, " . gettype($options) . " given."); + } + if ($option['depth'] < 0) { + throw new \InvalidArgumentException("Depth of nested option with key '{$key}' is negative."); + } + } + unset($option); + + $this->__nestedOptions = $options; + } + else { + foreach ($options as $value => $label) { + if (is_array($label)) { + throw new \InvalidArgumentException("Non-nested options must not contain any array. Array given for value '{$value}'."); + } + + if (is_object($label) && method_exists($label, '__toString')) { + $label = (string) $label; + } + else if (!is_string($label) && !is_numeric($label)) { + throw new \InvalidArgumentException("Options contain invalid label of type " . gettype($label) . "."); + } + + if (isset($this->__options[$value])) { + throw new \InvalidArgumentException("Options values must be unique, but '{$value}' appears at least twice as value."); + } + + // resolve language item for label + if (preg_match('~^([a-zA-Z0-9-_]+\.){2,}[a-zA-Z0-9-_]+$~', (string) $label)) { + $label = WCF::getLanguage()->getDynamicVariable($label); + } + + $this->__options[$value] = $label; } - }; - - $validateOptions($options); - - $this->__options = $options; - - return $this; - } - - /** - * @inheritDoc - */ - public function readValue(): IFormField { - if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { - $value = $this->getDocument()->getRequestData($this->getPrefixedId()); - if (is_string($value)) { - $this->__value = $value; + // ensure that `$this->__nestedOptions` is always populated + // for form field that support nested options + if ($this->supportsNestedOptions()) { + $this->__nestedOptions = []; + + foreach ($this->__options as $value => $label) { + $this->__nestedOptions[] = [ + 'depth' => 0, + 'label' => $label, + 'value' => $value + ]; + } } } return $this; } - /** - * @inheritDoc - */ - public function validate() { - if (!in_array($this->getValue(), $this->possibleValues)) { - $this->addValidationError(new FormFieldValidationError('invalidValue', 'wcf.global.form.error.noValidSelection')); - } - - parent::validate(); - } - - /** - * @inheritDoc - */ - public function value($value): IFormField { - // ignore `null` as value which can be passed either for nullable - // fields or as value if no options are available - if ($value === null) { - return $this; - } - - if (!in_array($value, $this->possibleValues)) { - throw new \InvalidArgumentException("Unknown value '{$value}'"); - } - - return parent::value($value); - } - /** * Returns `true` if the field class supports nested options and `false` otherwise. - * + * * @return bool */ - protected static function supportsNestedOptions(): bool { + public function supportsNestedOptions(): bool { return true; } }