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("rating", new PropertyModel(rating, "rating"), 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}