Add rating form field
authorMatthias Schmidt <gravatronics@live.com>
Sun, 24 Mar 2019 12:17:53 +0000 (13:17 +0100)
committerMatthias Schmidt <gravatronics@live.com>
Sun, 24 Mar 2019 12:17:53 +0000 (13:17 +0100)
See #2509

com.woltlab.wcf/templates/__ratingFormField.tpl [new file with mode: 0644]
syncTemplates.json
wcfsetup/install/files/acp/templates/__ratingFormField.tpl [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Rating.js [new file with mode: 0644]
wcfsetup/install/files/lib/system/form/builder/field/RatingFormField.class.php [new file with mode: 0644]
wcfsetup/install/files/style/ui/rating.scss [new file with mode: 0644]
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml

diff --git a/com.woltlab.wcf/templates/__ratingFormField.tpl b/com.woltlab.wcf/templates/__ratingFormField.tpl
new file mode 100644 (file)
index 0000000..0dc9978
--- /dev/null
@@ -0,0 +1,30 @@
+{include file='__formFieldHeader'}
+
+<ul class="ratingList jsOnly">
+       {foreach from=$field->getRatings() item=rating}
+               <li data-rating="{@$rating}"><span class="icon icon24 {if $rating <= $field->getValue()}{implode from=$field->getActiveCssClasses() item=cssClass glue=' '}{@$cssClass}{/implode}{else}{implode from=$field->getDefaultCssClasses() item=cssClass glue=' '}{@$cssClass}{/implode}{/if} pointer jsTooltip" title="{lang maximumRating=$field->getMaximum()}wcf.form.field.rating.ratingTitle{/lang}"></span></li>
+       {/foreach}
+       {if $field->isNullable()}
+               <li class="ratingMetaButton" data-action="removeRating"><span class="icon icon24 fa-times pointer jsTooltip" title="{lang}wcf.form.field.rating.removeRating{/lang}"></span></li>
+       {/if}
+</ul>
+<noscript>
+       <select name="{@$field->getPrefixedId()}" {if $field->isImmutable()} disabled{/if}>
+               {foreach from=$field->getRatings() item=rating}
+                       <option value="{@$rating}">{@$rating}</option>
+               {/foreach}
+       </select>
+</noscript>
+
+<script data-relocate="true">
+       require(['WoltLabSuite/Core/Form/Builder/Field/Rating'], function(FormBuilderFieldRating) {
+               new FormBuilderFieldRating(
+                       '{@$field->getPrefixedId()}',
+                       {if $field->getValue() !== null}{@$field->getValue()}{else}''{/if},
+                       [ {implode from=$field->getActiveCssClasses() item=cssClass}'{@$cssClass}'{/implode} ],
+                       [ {implode from=$field->getDefaultCssClasses() item=cssClass}'{@$cssClass}'{/implode} ]
+               );
+       });
+</script>
+
+{include file='__formFieldFooter'}
index 62a00302cd876ee69a6ff14eb4639c262bee3ab6..e82e4f6871588e8eeb38161a65a7e53829749be3 100644 (file)
@@ -29,6 +29,7 @@
     "__numericFormField",
     "__pollOptionsFormField",
     "__radioButtonFormField",
+    "__ratingFormField",
     "__singleSelectionFormField",
     "__tabFormContainer",
     "__tabMenuFormContainer",
diff --git a/wcfsetup/install/files/acp/templates/__ratingFormField.tpl b/wcfsetup/install/files/acp/templates/__ratingFormField.tpl
new file mode 100644 (file)
index 0000000..0dc9978
--- /dev/null
@@ -0,0 +1,30 @@
+{include file='__formFieldHeader'}
+
+<ul class="ratingList jsOnly">
+       {foreach from=$field->getRatings() item=rating}
+               <li data-rating="{@$rating}"><span class="icon icon24 {if $rating <= $field->getValue()}{implode from=$field->getActiveCssClasses() item=cssClass glue=' '}{@$cssClass}{/implode}{else}{implode from=$field->getDefaultCssClasses() item=cssClass glue=' '}{@$cssClass}{/implode}{/if} pointer jsTooltip" title="{lang maximumRating=$field->getMaximum()}wcf.form.field.rating.ratingTitle{/lang}"></span></li>
+       {/foreach}
+       {if $field->isNullable()}
+               <li class="ratingMetaButton" data-action="removeRating"><span class="icon icon24 fa-times pointer jsTooltip" title="{lang}wcf.form.field.rating.removeRating{/lang}"></span></li>
+       {/if}
+</ul>
+<noscript>
+       <select name="{@$field->getPrefixedId()}" {if $field->isImmutable()} disabled{/if}>
+               {foreach from=$field->getRatings() item=rating}
+                       <option value="{@$rating}">{@$rating}</option>
+               {/foreach}
+       </select>
+</noscript>
+
+<script data-relocate="true">
+       require(['WoltLabSuite/Core/Form/Builder/Field/Rating'], function(FormBuilderFieldRating) {
+               new FormBuilderFieldRating(
+                       '{@$field->getPrefixedId()}',
+                       {if $field->getValue() !== null}{@$field->getValue()}{else}''{/if},
+                       [ {implode from=$field->getActiveCssClasses() item=cssClass}'{@$cssClass}'{/implode} ],
+                       [ {implode from=$field->getDefaultCssClasses() item=cssClass}'{@$cssClass}'{/implode} ]
+               );
+       });
+</script>
+
+{include file='__formFieldFooter'}
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Rating.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Rating.js
new file mode 100644 (file)
index 0000000..441a036
--- /dev/null
@@ -0,0 +1,158 @@
+/**
+ * Handles the JavaScript part of the rating form field.
+ * 
+ * @author     Matthias Schmidt
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module     WoltLabSuite/Core/Form/Builder/Field/Rating
+ * @since      5.2
+ */
+define(['Dictionary'], function(Dictionary) {
+       "use strict";
+       
+       /**
+        * @constructor
+        */
+       function FormBuilderFieldRating(fieldId, value, activeCssClasses, defaultCssClasses) {
+               this.init(fieldId, value, activeCssClasses, defaultCssClasses);
+       };
+       FormBuilderFieldRating.prototype = {
+               /**
+                * Initializes the rating form field.
+                * 
+                * @param       {string}        fieldId                 id of the relevant form builder field
+                * @param       {integer}       value                   current value of the field
+                * @param       {string[]}      activeCssClasses        CSS classes for the active state of rating elements
+                * @param       {string[]}      defaultCssClasses       CSS classes for the default state of rating elements
+                */
+               init: function(fieldId, value, activeCssClasses, defaultCssClasses) {
+                       this._field = elBySel('#' + fieldId + 'Container');
+                       if (this._field === null) {
+                               throw new Error("Unknown field with id '" + fieldId + "'");
+                       }
+                       
+                       this._input = elCreate('input');
+                       this._input.name = fieldId;
+                       this._input.type = 'hidden';
+                       this._input.value = value;
+                       this._field.appendChild(this._input);
+                       
+                       this._activeCssClasses = activeCssClasses;
+                       this._defaultCssClasses = defaultCssClasses;
+                       
+                       this._ratingElements = new Dictionary();
+                       
+                       var ratingList = elBySel('.ratingList', this._field);
+                       ratingList.addEventListener('mouseleave', this._restoreRating.bind(this));
+                       
+                       elBySelAll('li', ratingList, function(listItem) {
+                               if (listItem.classList.contains('ratingMetaButton')) {
+                                       listItem.addEventListener('click', this._metaButtonClick.bind(this));
+                                       listItem.addEventListener('mouseenter', this._restoreRating.bind(this));
+                               }
+                               else {
+                                       this._ratingElements.set(~~elData(listItem, 'rating'), listItem);
+                                       
+                                       listItem.addEventListener('click', this._listItemClick.bind(this));
+                                       listItem.addEventListener('mouseenter', this._listItemMouseEnter.bind(this));
+                                       listItem.addEventListener('mouseleave', this._listItemMouseLeave.bind(this));
+                               }
+                       }.bind(this));
+               },
+               
+               /**
+                * Saves the rating associated with the clicked rating element.
+                * 
+                * @param       {Event}         event   rating element `click` event
+                */
+               _listItemClick: function(event) {
+                       this._input.value = ~~elData(event.currentTarget, 'rating');
+                       
+                       if (Environment.platform() !== 'desktop') {
+                               this._restoreRating();
+                       }
+               },
+               
+               /**
+                * Updates the rating UI when hovering over a rating element.
+                * 
+                * @param       {Event}         event   rating element `mouseenter` event
+                */
+               _listItemMouseEnter: function(event) {
+                       var currentRating = elData(event.currentTarget, 'rating');
+                       
+                       this._ratingElements.forEach(function(ratingElement, rating) {
+                               var icon = elByClass('icon', ratingElement)[0];
+                               
+                               this._toggleIcon(icon, rating <= currentRating);
+                       }.bind(this));
+               },
+               
+               /**
+                * Updates the rating UI when leaving a rating element by changing all rating elements
+                * to their default state.
+                */
+               _listItemMouseLeave: function() {
+                       this._ratingElements.forEach(function(ratingElement) {
+                               var icon = elByClass('icon', ratingElement)[0];
+                               
+                               this._toggleIcon(icon, false);
+                       }.bind(this));
+               },
+               
+               /**
+                * Handles clicks on meta buttons.
+                * 
+                * @param       {Event}         event   meta button `click` event
+                */
+               _metaButtonClick: function(event) {
+                       if (elData(event.currentTarget, 'action') === 'removeRating') {
+                               this._input.value = '';
+                               
+                               this._listItemMouseLeave();
+                       }
+               },
+               
+               /**
+                * Updates the rating UI by changing the rating elements to the stored rating state.
+                */
+               _restoreRating: function() {
+                       this._ratingElements.forEach(function(ratingElement, rating) {
+                               var icon = elByClass('icon', ratingElement)[0];
+                               
+                               this._toggleIcon(icon, rating <= this._input.value);
+                       }.bind(this));
+               },
+               
+               /**
+                * Toggles the state of the given icon based on the given state parameter.
+                * 
+                * @param       {HTMLElement}   icon            toggled icon
+                * @param       {boolean}       active          is `true` if icon will be changed to `active` state, otherwise changed to `default` state
+                */
+               _toggleIcon: function(icon, active) {
+                       active = active || false;
+                       
+                       if (active) {
+                               for (var i = 0; i < this._defaultCssClasses.length; i++) {
+                                       icon.classList.remove(this._defaultCssClasses[i]);
+                               }
+                               
+                               for (var i = 0; i < this._activeCssClasses.length; i++) {
+                                       icon.classList.add(this._activeCssClasses[i]);
+                               }
+                       }
+                       else {
+                               for (var i = 0; i < this._activeCssClasses.length; i++) {
+                                       icon.classList.remove(this._activeCssClasses[i]);
+                               }
+                               
+                               for (var i = 0; i < this._defaultCssClasses.length; i++) {
+                                       icon.classList.add(this._defaultCssClasses[i]);
+                               }
+                       }
+               }
+       };
+       
+       return FormBuilderFieldRating;
+});
diff --git a/wcfsetup/install/files/lib/system/form/builder/field/RatingFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/RatingFormField.class.php
new file mode 100644 (file)
index 0000000..f254f8c
--- /dev/null
@@ -0,0 +1,207 @@
+<?php
+namespace wcf\system\form\builder\field;
+use wcf\system\form\builder\field\validation\FormFieldValidationError;
+use wcf\system\WCF;
+
+/**
+ * Implementation of a form field to set the rating of an object.
+ *
+ * The minimum and maximum rating are handled via `minimum()` and `maximum()`. Fields of this type
+ * must have a minimum value and a maximum value. If no value has been set for a field of this class
+ * the the field is not nullable, the minimum value will be automatically set when the field's value
+ * is requested the first time.
+ * 
+ * By default, the active rating state is represented by orange stars and the default state by white
+ * stars with a black border.
+ * 
+ * This field uses the `wcf.form.field.rating` language item as the default form field label and has
+ * a minimum rating of `1` and a maximum rating of `5`.
+ * 
+ * @author     Matthias Schmidt
+ * @copyright  2001-2019 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\System\Form\Builder\Field
+ * @since      5.2
+ */
+class RatingFormField extends AbstractFormField implements IImmutableFormField, IMaximumFormField, IMinimumFormField, INullableFormField {
+       use TDefaultIdFormField;
+       use TImmutableFormField;
+       use TMaximumFormField {
+               maximum as protected traitMaximum;
+       }
+       use TMinimumFormField {
+               minimum as protected traitMinimum;
+       }
+       use TNullableFormField;
+       
+       /**
+        * CSS classes for the active state of the rating elements
+        * @var string[]
+        */
+       protected $activeCssClasses = ['fa-star', 'orange'];
+       
+       /**
+        * CSS classes for the default state of the rating elements
+        * @var string[]
+        */
+       protected $defaultCssClasses = ['fa-star-o'];
+       
+       /**
+        * @inheritDoc
+        */
+       protected $templateName = '__ratingFormField';
+       
+       /**
+        * Creates a new instance of `RatingFormField`.
+        */
+       public function __construct() {
+               $this->label('wcf.form.field.rating');
+               $this->minimum(1);
+               $this->maximum(5);
+       }
+       
+       /**
+        * Sets the CSS classes for the active state of the rating elements.
+        * 
+        * @param       string[]        $cssClasses     active state CSS classes
+        * @return      static                          this form field
+        * @throws      \InvalidArgumentException       if no or invalid CSS classes are given
+        */
+       public function activeCssClasses(array $cssClasses) {
+               if (empty($cssClasses)) {
+                       throw new \InvalidArgumentException("No css classes for active state given.");
+               }
+               
+               foreach ($cssClasses as $cssClass) {
+                       static::validateClass($cssClass);
+               }
+               
+               $this->activeCssClasses = $cssClasses;
+               
+               return $this;
+       }
+       
+       /**
+        * Sets the CSS classes for the default state of the rating elements.
+        * 
+        * @param       string[]        $cssClasses     default state CSS classes
+        * @return      static                          this form field
+        * @throws      \InvalidArgumentException       if no or invalid CSS classes are given
+        */
+       public function defaultCssClasses(array $cssClasses) {
+               if (empty($cssClasses)) {
+                       throw new \InvalidArgumentException("No css classes for default state given.");
+               }
+               
+               foreach ($cssClasses as $cssClass) {
+                       static::validateClass($cssClass);
+               }
+               
+               $this->defaultCssClasses = $cssClasses;
+               
+               return $this;
+       }
+       
+       /**
+        * Returns the CSS classes for the active state of the rating elements.
+        * 
+        * @return      string[]
+        */
+       public function getActiveCssClasses() {
+               return $this->activeCssClasses;
+       }
+       
+       /**
+        * Returns the CSS classes for the default state of the rating elements.
+        * 
+        * @return      string[]
+        */
+       public function getDefaultCssClasses() {
+               return $this->defaultCssClasses;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function getValue() {
+               if ($this->value === null && !$this->isNullable()) {
+                       $this->value = $this->getMinimum();
+               }
+               
+               return parent::getValue();
+       }
+       
+       /**
+        * Returns the sorted list of possible ratings used to generate the form field's html code.
+        * 
+        * @return      integer[]
+        */
+       public function getRatings() {
+               if (WCF::getLanguage()->get('wcf.global.pageDirection') === 'rtl') {
+                       return range($this->maximum, $this->minimum, -1);
+               }
+               
+               return range($this->minimum, $this->maximum);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function maximum($maximum = null) {
+               if ($maximum === null) {
+                       throw new \InvalidArgumentException("Cannot unset maximum value.");
+               }
+               
+               return $this->traitMaximum($maximum);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function minimum($minimum = null) {
+               if ($minimum === null) {
+                       throw new \InvalidArgumentException("Cannot unset minimum value.");
+               }
+               
+               return $this->traitMinimum($minimum);
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function readValue() {
+               if ($this->getDocument()->hasRequestData($this->getPrefixedId())) {
+                       $value = $this->getDocument()->getRequestData($this->getPrefixedId());
+                       
+                       if ($this->isNullable() && $value === '') {
+                               $this->value = null;
+                       }
+                       else {
+                               $this->value = intval($value);
+                       }
+               }
+               
+               return $this;
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       protected static function getDefaultId() {
+               return 'rating';
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function validate() {
+               if ($this->getValue() !== null) {
+                       if ($this->getValue() < $this->getMinimum() || $this->getValue() > $this->getMaximum()) {
+                               $this->addValidationError(new FormFieldValidationError(
+                                       'invalid',
+                                       'wcf.global.form.error.noValidSelection'
+                               ));
+                       }
+               }
+       }
+}
diff --git a/wcfsetup/install/files/style/ui/rating.scss b/wcfsetup/install/files/style/ui/rating.scss
new file mode 100644 (file)
index 0000000..f4d943a
--- /dev/null
@@ -0,0 +1,7 @@
+.ratingList {
+       font-size: 0;
+       
+       > li {
+               display: inline-block;
+       }
+}
index 0746260271aceeb884b10e7a993771cda480408c..3d76b58b0c85430ffd913fbb59dcc99192c99407 100644 (file)
@@ -3874,6 +3874,9 @@ Dateianhänge:
                <item name="wcf.form.field.url.error.invalid"><![CDATA[Der angegebene Link ist ungültig.]]></item>
                <item name="wcf.form.field.className.description.parentClass"><![CDATA[Die angegebene Klasse (ohne führenden Backslash) muss von der Klasse <kbd>{$parentClass}</kbd> erben.]]></item>
                <item name="wcf.form.fieldValidator.dotSeparatedString.error.invalidSegments"><![CDATA[Die folgenden Abschnitte sind ungültig: {implode from=$invalidSegments key=segmentNumber item=segment}{if $segment !== ''}<kbd>{$segment}</kbd>{else}(empty){/if} (segment {#$segmentNumber + 1}){/implode}.]]></item>
+               <item name="wcf.form.field.rating"><![CDATA[Bewertung]]></item>
+               <item name="wcf.form.field.rating.ratingTitle"><![CDATA[{#$rating}/{#$maximumRating}]]></item>
+               <item name="wcf.form.field.rating.removeRating"><![CDATA[Bewertung zurücksetzen]]></item>
        </category>
        <category name="wcf.imageViewer">
                <item name="wcf.imageViewer.button.enlarge"><![CDATA[Vollbild-Modus]]></item>
index 7e69b33b046bd9582854fecb0cbe71142c256651..6eb4788ebd196d587c8ac554b689bbbbf51b80ee 100644 (file)
@@ -3820,6 +3820,9 @@ Attachments:
                <item name="wcf.form.field.username.error.notUnique"><![CDATA[The entered username is already in use.]]></item>
                <item name="wcf.form.fieldValidator.dotSeparatedString.error.invalidSegments"><![CDATA[The following segments are invalid: {implode from=$invalidSegments key=segmentNumber item=segment}{if $segment !== ''}<kbd>{$segment}</kbd>{else}(empty){/if} (segment {#$segmentNumber + 1}){/implode}.]]></item>
                <item name="wcf.form.field.url.error.invalid"><![CDATA[The entered link is invalid.]]></item>
+               <item name="wcf.form.field.rating"><![CDATA[Rating]]></item>
+               <item name="wcf.form.field.rating.ratingTitle"><![CDATA[{#$rating}/{#$maximumRating}]]></item>
+               <item name="wcf.form.field.rating.removeRating"><![CDATA[Reset Rating]]></item>
        </category>
        <category name="wcf.imageViewer">
                <item name="wcf.imageViewer.button.enlarge"><![CDATA[Full Screen Mode]]></item>