001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.wicket.extensions.rating;
018
019import java.util.Optional;
020
021import org.apache.wicket.AttributeModifier;
022import org.apache.wicket.Component;
023import org.apache.wicket.ajax.AjaxRequestTarget;
024import org.apache.wicket.ajax.markup.html.AjaxFallbackLink;
025import org.apache.wicket.markup.head.CssHeaderItem;
026import org.apache.wicket.markup.head.IHeaderResponse;
027import org.apache.wicket.markup.html.WebMarkupContainer;
028import org.apache.wicket.markup.html.basic.Label;
029import org.apache.wicket.markup.html.list.Loop;
030import org.apache.wicket.markup.html.list.LoopItem;
031import org.apache.wicket.markup.html.panel.Panel;
032import org.apache.wicket.model.IModel;
033import org.apache.wicket.model.Model;
034import org.apache.wicket.model.StringResourceModel;
035import org.apache.wicket.request.IRequestHandler;
036import org.apache.wicket.request.handler.resource.ResourceReferenceRequestHandler;
037import org.apache.wicket.request.resource.CssResourceReference;
038import org.apache.wicket.request.resource.PackageResourceReference;
039import org.apache.wicket.request.resource.ResourceReference;
040
041/**
042 * Rating component that generates a number of stars where a user can click on to rate something.
043 * Subclasses should implement {@link #onRated(int, Optional)} to provide the calculation
044 * of the rating, and {@link #onIsStarActive(int)} to indicate whether to render an active star or
045 * an inactive star.
046 * <p>
047 * Active stars are the stars that show the rating, inactive stars are the left overs. E.G. a rating
048 * of 3.4 on a scale of 5 stars will render 3 active stars, and 2 inactive stars (provided that the
049 * {@link #onIsStarActive(int)} returns <code>true</code> for each of the first three stars).
050 * <p>
051 * Use this component in the following way:
052 * 
053 * <pre>
054 * add(new RatingPanel(&quot;rating&quot;, new PropertyModel(rating, &quot;rating&quot;), 5)
055 * {
056 *      protected boolean onIsStarActive(int star)
057 *      {
058 *              return rating.isActive(star);
059 *      }
060 * 
061 *      protected void onRated(int rating, AjaxRequestTarget target)
062 *      {
063 *              rating1.addRating(rating);
064 *      }
065 * });
066 * </pre>
067 * 
068 * The user of this component is responsible for creating a model that supplies a Double (or Float)
069 * value for the rating message, however the rating panel doesn't necessarily have to contain a
070 * float or number rating value.
071 * <p>
072 * Though not obligatory, you could also supply a value for the number of votes cast, which allows
073 * the component to render a more complete message in the rating label.
074 * 
075 * <h2>Customizing the rating value and label</h2>
076 * To customize the rating value, one should override the
077 * {@link #newRatingLabel(String, IModel, IModel)} method and create another label instead, based on
078 * the provided models. If you do so, and use another system of rating than returning a Float or
079 * Double, then you should also customize the rating resource bundle to reflect your message. The
080 * default resource bundle assumes a numeric value for the rating.
081 * 
082 * <h2>Resource bundle</h2>
083 * This component uses two types of messages: rating.simple and rating.complete. The first message
084 * is used when no model is given for the number of cast votes. The complete message shows the text
085 * 'Rating xx.yy from zz votes'.
086 * 
087 * <pre>
088 *       rating.simple=Rated {0,number,#.#}
089 *       rating.complete=Rated {0,number,#.#} from {1,number,#} votes
090 * </pre>
091 * 
092 * <h2>Customizing the star images</h2>
093 * To customize the images shown, override the {@link #getActiveStarUrl(int)} and
094 * {@link #getInactiveStarUrl(int)} methods. Using the iteration parameter it is possible to use a
095 * different image for each star, creating a fade effect or something similar.
096 * 
097 * @author Martijn Dashorst
098 */
099public abstract class RatingPanel extends Panel
100{
101        /**
102         * Renders the stars and the links necessary for rating.
103         */
104        private final class RatingStarBar extends Loop
105        {
106                /** For serialization. */
107                private static final long serialVersionUID = 1L;
108
109                private RatingStarBar(final String id, final IModel<Integer> model)
110                {
111                        super(id, model);
112                }
113
114                @Override
115                protected void populateItem(final LoopItem item)
116                {
117                        // Use an AjaxFallbackLink for rating to make voting work even
118                        // without Ajax.
119                        AjaxFallbackLink<Void> link = new AjaxFallbackLink<Void>("link")
120                        {
121                                private static final long serialVersionUID = 1L;
122
123                                @Override
124                                public void onClick(Optional<AjaxRequestTarget> targetOptional)
125                                {
126                                        LoopItem item = (LoopItem)getParent();
127
128                                        // adjust the rating, and provide the target to the subclass
129                                        // of our rating component, so other components can also get
130                                        // updated in case of an AJAX event.
131
132                                        onRated(item.getIndex() + 1, targetOptional);
133
134                                        // if we process an AJAX event, update this panel
135                                        targetOptional.ifPresent(target -> target.add(RatingPanel.this.get("rater")));
136
137                                }
138
139                                @Override
140                                public boolean isEnabled()
141                                {
142                                        return !hasVoted.getObject();
143                                }
144                        };
145
146                        int iteration = item.getIndex();
147
148                        // add the star image, which is either active (highlighted) or
149                        // inactive (no star)
150                        link.add(new WebMarkupContainer("star").add(AttributeModifier.replace("src",
151                                (onIsStarActive(iteration) ? getActiveStarUrl(iteration)
152                                        : getInactiveStarUrl(iteration)))));
153                        item.add(link);
154                }
155        }
156
157        /** For serialization. */
158        private static final long serialVersionUID = 1L;
159
160        /**
161         * Star image for no selected star
162         */
163        public static final ResourceReference STAR0 = new PackageResourceReference(RatingPanel.class,
164                "star0.gif");
165
166        /**
167         * Star image for selected star
168         */
169        public static final ResourceReference STAR1 = new PackageResourceReference(RatingPanel.class,
170                "star1.gif");
171
172        /**
173         * The number of stars that need to be shown, should result in an Integer object.
174         */
175        private IModel<Integer> nrOfStars = new Model<>(5);
176
177        /**
178         * The number of votes that have been cast, should result in an Integer object.
179         */
180        private final IModel<Integer> nrOfVotes;
181
182        /**
183         * The flag on whether the current user has voted already.
184         */
185        private final IModel<Boolean> hasVoted;
186
187        /**
188         * Handle to the rating label to set the visibility.
189         */
190        private Component ratingLabel;
191
192        private final boolean addDefaultCssStyle;
193
194        /**
195         * Constructs a rating component with 5 stars, using a compound property model as its model to
196         * retrieve the rating.
197         * 
198         * @param id
199         *            the component id.
200         */
201        public RatingPanel(final String id)
202        {
203                this(id, null, 5, true);
204        }
205
206        /**
207         * Constructs a rating component with 5 stars, using the rating for retrieving the rating.
208         * 
209         * @param id
210         *            the component id
211         * @param rating
212         *            the model to get the rating
213         */
214        public RatingPanel(final String id, final IModel<? extends Number> rating)
215        {
216                this(id, rating, new Model<Integer>(5), null, new Model<>(Boolean.FALSE), true);
217        }
218
219        /**
220         * Constructs a rating component with nrOfStars stars, using a compound property model as its
221         * model to retrieve the rating.
222         * 
223         * @param id
224         *            the component id
225         * @param nrOfStars
226         *            the number of stars to display
227         */
228        public RatingPanel(final String id, final int nrOfStars)
229        {
230                this(id, null, nrOfStars, true);
231        }
232
233        /**
234         * Constructs a rating component with nrOfStars stars, using the rating for retrieving the
235         * rating.
236         * 
237         * @param id
238         *            the component id
239         * @param rating
240         *            the model to get the rating
241         * @param nrOfStars
242         *            the number of stars to display
243         * @param addDefaultCssStyle
244         *            should this component render its own default CSS style?
245         */
246        public RatingPanel(final String id, final IModel<? extends Number> rating, final int nrOfStars,
247                final boolean addDefaultCssStyle)
248        {
249                this(id, rating, new Model<Integer>(nrOfStars), null, new Model<Boolean>(Boolean.FALSE),
250                        addDefaultCssStyle);
251        }
252
253        /**
254         * Constructs a rating panel with nrOfStars stars, where the rating model is used to retrieve
255         * the rating, the nrOfVotes model to retrieve the number of casted votes. This panel doens't
256         * keep track of whether the user has already voted.
257         * 
258         * @param id
259         *            the component id
260         * @param rating
261         *            the model to get the rating
262         * @param nrOfStars
263         *            the number of stars to display
264         * @param nrOfVotes
265         *            the number of cast votes
266         * @param addDefaultCssStyle
267         *            should this component render its own default CSS style?
268         */
269        public RatingPanel(final String id, final IModel<? extends Number> rating, final int nrOfStars,
270                final IModel<Integer> nrOfVotes, final boolean addDefaultCssStyle)
271        {
272                this(id, rating, new Model<Integer>(nrOfStars), nrOfVotes,
273                        new Model<>(Boolean.FALSE), addDefaultCssStyle);
274        }
275
276        /**
277         * Constructs a rating panel with nrOfStars stars, where the rating model is used to retrieve
278         * the rating, the nrOfVotes model used to retrieve the number of votes cast and the hasVoted
279         * model to retrieve whether the user already had cast a vote.
280         * 
281         * @param id
282         *            the component id.
283         * @param rating
284         *            the (calculated) rating, i.e. 3.4
285         * @param nrOfStars
286         *            the number of stars to display
287         * @param nrOfVotes
288         *            the number of cast votes
289         * @param hasVoted
290         *            has the user already voted?
291         * @param addDefaultCssStyle
292         *            should this component render its own default CSS style?
293         */
294        public RatingPanel(final String id, final IModel<? extends Number> rating,
295                final IModel<Integer> nrOfStars, final IModel<Integer> nrOfVotes,
296                final IModel<Boolean> hasVoted, final boolean addDefaultCssStyle)
297        {
298                super(id, rating);
299                this.addDefaultCssStyle = addDefaultCssStyle;
300
301                this.nrOfStars = wrap(nrOfStars);
302                this.nrOfVotes = wrap(nrOfVotes);
303                this.hasVoted = wrap(hasVoted);
304
305                WebMarkupContainer rater = new WebMarkupContainer("rater");
306                rater.add(newRatingStarBar("element", this.nrOfStars));
307
308                // add the text label for the message 'Rating 4.5 out of 25 votes'
309                rater.add(ratingLabel = newRatingLabel("rating", wrap(rating), this.nrOfVotes));
310
311                // set auto generation of the markup id on, such that ajax calls work.
312                rater.setOutputMarkupId(true);
313
314                add(rater);
315
316                // don't render the outer tags in the target document, just the div that
317                // is inside the panel.
318                setRenderBodyOnly(true);
319        }
320
321        @Override
322        public void renderHead(final IHeaderResponse response)
323        {
324                super.renderHead(response);
325                if (addDefaultCssStyle)
326                {
327                        response.render(CssHeaderItem.forReference(new CssResourceReference(
328                                RatingPanel.class, "RatingPanel.css")));
329                }
330
331        }
332
333        /**
334         * Creates a new bar filled with stars to click on.
335         * 
336         * @param id
337         *            the bar id
338         * @param nrOfStars
339         *            the number of stars to generate
340         * @return the bar with rating stars
341         */
342        protected Component newRatingStarBar(final String id, final IModel<Integer> nrOfStars)
343        {
344                return new RatingStarBar(id, nrOfStars);
345        }
346
347        /**
348         * Creates a new rating label, showing a message like 'Rated 5.4 from 53 votes'.
349         * 
350         * @param id
351         *            the id of the label
352         * @param rating
353         *            the model containing the rating
354         * @param nrOfVotes
355         *            the model containing the number of votes (may be null)
356         * @return the label component showing the message.
357         */
358        protected Component newRatingLabel(final String id, final IModel<? extends Number> rating,
359                final IModel<Integer> nrOfVotes)
360        {
361                IModel<String> model;
362                if (nrOfVotes == null)
363                {
364                        Object[] parameters = new Object[] { rating };
365                        model = new StringResourceModel("rating.simple", this).setParameters(parameters);
366                }
367                else
368                {
369                        Object[] parameters = new Object[] { rating, nrOfVotes };
370                        model = new StringResourceModel("rating.complete", this).setParameters(parameters);
371                }
372                return new Label(id, model);
373        }
374
375        /**
376         * Returns the url pointing to the image of active stars, is used to set the URL for the image
377         * of an active star. Override this method to provide your own images.
378         * 
379         * @param iteration
380         *            the sequence number of the star
381         * @return the url pointing to the image for active stars.
382         */
383        protected String getActiveStarUrl(final int iteration)
384        {
385                IRequestHandler handler = new ResourceReferenceRequestHandler(STAR1);
386                return getRequestCycle().urlFor(handler).toString();
387        }
388
389        /**
390         * Returns the url pointing to the image of inactive stars, is used to set the URL for the image
391         * of an inactive star. Override this method to provide your own images.
392         * 
393         * @param iteration
394         *            the sequence number of the star
395         * @return the url pointing to the image for inactive stars.
396         */
397        protected String getInactiveStarUrl(final int iteration)
398        {
399                IRequestHandler handler = new ResourceReferenceRequestHandler(STAR0);
400                return getRequestCycle().urlFor(handler).toString();
401        }
402
403        /**
404         * Sets the visibility of the rating label.
405         * 
406         * @param visible
407         *            true when the label should be visible
408         * @return this for chaining.
409         */
410        public RatingPanel setRatingLabelVisible(final boolean visible)
411        {
412                ratingLabel.setVisible(visible);
413                return this;
414        }
415
416        /**
417         * Returns <code>true</code> when the star identified by its sequence number should be shown as
418         * active.
419         * 
420         * @param star
421         *            the sequence number of the star (ranging from 0 to nrOfStars)
422         * @return <code>true</code> when the star should be rendered as active
423         */
424        protected abstract boolean onIsStarActive(int star);
425
426        /**
427         * Notification of a click on a rating star. Add your own components to the request target when
428         * you want to have them updated in the Ajax request. <strong>NB</strong> the target may be null
429         * when the click isn't handled using AJAX, but using a fallback scenario.
430         *  @param rating
431         *            the number of the star that is clicked, ranging from 1 to nrOfStars
432         * @param target
433         */
434        protected abstract void onRated(int rating, Optional<AjaxRequestTarget> target);
435}