Add `MultipleSelectionFormField` and add selection nesting support
authorMatthias Schmidt <gravatronics@live.com>
Sat, 30 Jun 2018 07:48:37 +0000 (09:48 +0200)
committerMatthias Schmidt <gravatronics@live.com>
Sat, 30 Jun 2018 07:48:37 +0000 (09:48 +0200)
See #2509

wcfsetup/install/files/acp/templates/__multipleSelectionFormField.tpl [new file with mode: 0644]
wcfsetup/install/files/acp/templates/__singleSelectionFormField.tpl
wcfsetup/install/files/lib/system/form/builder/field/ISelectionFormField.class.php
wcfsetup/install/files/lib/system/form/builder/field/MultipleSelectionFormField.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/system/form/builder/field/SingleSelectionFormField.class.php
wcfsetup/install/files/lib/system/form/builder/field/TSelectionFormField.class.php

diff --git a/wcfsetup/install/files/acp/templates/__multipleSelectionFormField.tpl b/wcfsetup/install/files/acp/templates/__multipleSelectionFormField.tpl
new file mode 100644 (file)
index 0000000..ef57341
--- /dev/null
@@ -0,0 +1,33 @@
+{include file='__formFieldHeader'}
+
+{if $field->isFilterable()}
+       <script data-relocate="true">
+               require(['Language', 'WoltLabSuite/Core/Ui/ItemList/Filter'], function(Language, UiItemListFilter) {
+                       Language.addObject({
+                               'wcf.global.filter.button.visibility': '{lang}wcf.global.filter.button.visibility{/lang}',
+                               'wcf.global.filter.button.clear': '{lang}wcf.global.filter.button.clear{/lang}',
+                               'wcf.global.filter.error.noMatches': '{lang}wcf.global.filter.error.noMatches{/lang}',
+                               'wcf.global.filter.placeholder': '{lang}wcf.global.filter.placeholder{/lang}',
+                               'wcf.global.filter.visibility.activeOnly': '{lang}wcf.global.filter.visibility.activeOnly{/lang}',
+                               'wcf.global.filter.visibility.highlightActive': '{lang}wcf.global.filter.visibility.highlightActive{/lang}',
+                               'wcf.global.filter.visibility.showAll': '{lang}wcf.global.filter.visibility.showAll{/lang}'
+                       });
+                       
+                       new UiItemListFilter('{@$field->getPrefixedId()}_list');
+               });
+       </script>
+       
+       <ul class="scrollableCheckboxList" id="{@$field->getPrefixedId()}_list">
+               {foreach from=$field->getNestedOptions() item=__fieldNestedOption}
+                       <li{if $__fieldNestedOption[depth] > 0} style="padding-left: {$__fieldNestedOption[depth]*20}px"{/if}>
+                               <label><input type="checkbox" name="{@$field->getPrefixedId()}[]" value="{$__fieldNestedOption[value]}"{if $field->getValue() !== null && $__fieldNestedOption[value]|in_array:$field->getValue()} checked{/if}> {@$__fieldNestedOption[label]}</label>
+                       </li>
+               {/foreach}
+       </ul>
+{else}
+       <select id="{@$field->getPrefixedId()}" name="{@$field->getPrefixedId()}">
+               {htmlOptions options=$field->getOptions() selected=$field->getValue() disableEncoding=true}
+       </select>
+{/if}
+
+{include file='__formFieldFooter'}
index 99069fa3929ef7ce2707a8ba2611a798cb1a3cf6..e994be8a68fffab611a8d131abd25609306b94d2 100644 (file)
@@ -18,9 +18,9 @@
        </script>
        
        <ul class="scrollableCheckboxList" id="{@$field->getPrefixedId()}_list">
-               {foreach from=$field->getOptions() key=$__fieldValue item=__fieldLabel}
-                       <li>
-                               <label><input type="radio" name="{@$field->getPrefixedId()}" value="{$__fieldValue}"{if $field->getValue() === $__fieldValue} checked{/if}> {@$__fieldLabel}</label>
+               {foreach from=$field->getNestedOptions() item=__fieldNestedOption}
+                       <li{if $__fieldNestedOption[depth] > 0} style="padding-left: {$__fieldNestedOption[depth]*20}px"{/if}>
+                               <label><input type="radio" name="{@$field->getPrefixedId()}" value="{$__fieldNestedOption[value]}"{if $field->getValue() === $__fieldNestedOption[value]} checked{/if}> {@$__fieldNestedOption[label]}</label>
                        </li>
                {/foreach}
        </ul>
index 6f2ea4692d8a49b2560d9518c592e85c24032c51..ba561657f0c501ea234f144cdddb0299d1c55412 100644 (file)
@@ -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 (file)
index 0000000..2a2a078
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+namespace wcf\system\form\builder\field;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+
+/**
+ * Implementation of a form field for selecting multiple values.
+ * 
+ * @author     Matthias Schmidt
+ * @copyright  2001-2018 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @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);
+       }
+}
index 76c0ce898a32cac2815f004ac21d3f1a4f9c4b15..8690a66502c890490b8c3b65f032c6f8747811c1 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 declare(strict_types=1);
 namespace wcf\system\form\builder\field;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
 
 /**
  * Implementation of a form field for selecting a single value.
@@ -16,40 +17,64 @@ class SingleSelectionFormField extends AbstractFormField implements INullableFor
        use TSelectionFormField;
        
        /**
-        * `true` if this field's options are filterable by the user
-        * @var bool
+        * @inheritDoc
         */
-       protected $__filterable = false;
+       protected $templateName = '__singleSelectionFormField';
        
        /**
         * @inheritDoc
         */
-       protected $templateName = '__singleSelectionFormField';
+       public function getSaveValue() {
+               if (empty($this->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);
        }
 }
index 8b3cd9fec9bfce5ccca73a269aa10185b67f734c..c52f5f186a0b5049e8a7e82a8781ad5770d0ad59 100644 (file)
@@ -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;
        }
 }