--- /dev/null
+{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'}
"__numericFormField",
"__pollOptionsFormField",
"__radioButtonFormField",
+ "__ratingFormField",
"__singleSelectionFormField",
"__tabFormContainer",
"__tabMenuFormContainer",
--- /dev/null
+{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'}
--- /dev/null
+/**
+ * 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;
+});
--- /dev/null
+<?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'
+ ));
+ }
+ }
+ }
+}
--- /dev/null
+.ratingList {
+ font-size: 0;
+
+ > li {
+ display: inline-block;
+ }
+}
<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>
<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>