Bug 1542262 - Match AMO's rating logic in about:addons r=mstriemer,Pike
authorRob Wu <rob@robwu.nl>
Wed, 08 May 2019 18:30:04 +0000
changeset 532039 af2c36273f0c06c24d6dcc5625805f92f390eec0
parent 532038 077dec75c81a580e49256b4e988daab15e54427b
child 532040 043d273f14ca4ff406c4d6674701ecfa51ecbd9d
push id11265
push userffxbld-merge
push dateMon, 13 May 2019 10:53:39 +0000
treeherdermozilla-beta@77e0fe8dbdd3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmstriemer, Pike
bugs1542262
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1542262 - Match AMO's rating logic in about:addons r=mstriemer,Pike - Fill stars based on whether the rating is within the 0.5 range of a 0.5-multiple, rather than checking whether the rating is at least as large as the 0.5-multiple. This follows the boundaries at: https://github.com/mozilla/addons-frontend/blob/bb9277eeffa1aca38b49c8ed2f4dfa5823def394/src/ui/components/Rating/index.js#L139-L140 - Use a review star rating instead of re-using the bookmark star. This is not necessarily to be more consistent with AMO's stars, but to prevent the stars from becoming non-stars if we ever change the bookmark icon. The SVG icon is based on the path at: https://github.com/mozilla/addons-frontend/blob/bb9277eeffa1aca38b49c8ed2f4dfa5823def394/src/ui/components/IconStar/index.js#L19 - Turn it into a custom element to make re-use easier. Differential Revision: https://phabricator.services.mozilla.com/D29480
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/aboutaddons.css
toolkit/mozapps/extensions/content/aboutaddons.html
toolkit/mozapps/extensions/content/aboutaddons.js
toolkit/mozapps/extensions/content/rating-star.css
toolkit/mozapps/extensions/jar.mn
toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
toolkit/themes/shared/extensions/rating-star.svg
toolkit/themes/shared/mozapps.inc.mn
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -389,16 +389,22 @@ always-activate-button = Always Activate
 never-activate-button = Never Activate
 
 addon-detail-author-label = Author
 addon-detail-version-label = Version
 addon-detail-last-updated-label = Last Updated
 addon-detail-homepage-label = Homepage
 addon-detail-rating-label = Rating
 
+# The average rating that the add-on has received.
+# Variables:
+#   $rating (number) - A number between 0 and 5. The translation should show at most one digit after the comma.
+five-star-rating =
+  .title = Rated { NUMBER($rating, maximumFractionDigits: 1) } out of 5
+
 # This string is used to show that an add-on is disabled.
 # Variables:
 #   $name (string) - The name of the add-on
 addon-name-disabled = { $name } (disabled)
 
 # The number of reviews that an add-on has received on AMO.
 # Variables:
 #   $numberOfReviews (number) - The number of reviews received
--- a/toolkit/mozapps/extensions/content/aboutaddons.css
+++ b/toolkit/mozapps/extensions/content/aboutaddons.css
@@ -256,43 +256,16 @@ addon-details {
 .addon-detail-row input[type="checkbox"] {
   margin: 0;
 }
 
 .addon-detail-rating {
   display: flex;
 }
 
-.addon-detail-rating-star {
-  display: inline-block;
-  width: 16px;
-  height: 16px;
-  background: url("chrome://browser/skin/bookmark-hollow.svg");
-}
-
-.addon-detail-rating-star[fill="full"] {
-  background: url("chrome://browser/skin/bookmark.svg");
-}
-
-.addon-detail-rating-star[fill="half"] {
-  background: url("chrome://browser/skin/bookmark.svg");
-  width: 8px;
-  margin-inline-end: 8px;
-}
-
-.addon-detail-rating-star[fill="half"]::after {
-  content: "";
-  display: inline-block;
-  background: url("chrome://browser/skin/bookmark-hollow.svg");
-  background-position: 8px;
-  width: 8px;
-  height: 16px;
-  margin-left: 8px;
-}
-
 .addon-detail-rating > a {
   margin-inline-start: 8px;
 }
 
 .more-options-button {
   min-width: auto;
   min-height: auto;
   width: 24px;
--- a/toolkit/mozapps/extensions/content/aboutaddons.html
+++ b/toolkit/mozapps/extensions/content/aboutaddons.html
@@ -134,26 +134,31 @@
       </div>
       <div class="addon-detail-row addon-detail-row-homepage">
         <label data-l10n-id="addon-detail-homepage-label"></label>
         <a target="_blank"></a>
       </div>
       <div class="addon-detail-row addon-detail-row-rating">
         <label data-l10n-id="addon-detail-rating-label"></label>
         <div class="addon-detail-rating">
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
-          <span class="addon-detail-rating-star"></span>
+          <five-star-rating></five-star-rating>
           <a target="_blank"></a>
         </div>
       </div>
     </template>
 
+    <template name="five-star-rating">
+      <link rel="stylesheet" href="chrome://mozapps/content/extensions/rating-star.css">
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+      <span class="rating-star"></span>
+    </template>
+
     <template name="panel-list">
       <link rel="stylesheet" href="chrome://mozapps/content/extensions/panel-list.css">
       <div class="arrow top"></div>
       <div class="list">
         <slot></slot>
       </div>
       <div class="arrow bottom"></div>
     </template>
--- a/toolkit/mozapps/extensions/content/aboutaddons.js
+++ b/toolkit/mozapps/extensions/content/aboutaddons.js
@@ -571,16 +571,77 @@ class PluginOptions extends HTMLElement 
       let el = this.querySelector(`[action="${action}"]`);
       el.checked = addon.userDisabled === userDisabled;
       el.disabled = !(el.checked || hasPermission(addon, action));
     }
   }
 }
 customElements.define("plugin-options", PluginOptions);
 
+class FiveStarRating extends HTMLElement {
+  static get observedAttributes() {
+    return ["rating"];
+  }
+
+  constructor() {
+    super();
+    this.attachShadow({mode: "open"});
+    this.shadowRoot.append(importTemplate("five-star-rating"));
+  }
+
+  set rating(v) {
+    this.setAttribute("rating", v);
+  }
+
+  get rating() {
+    let v = parseFloat(this.getAttribute("rating"), 10);
+    if (v >= 0 && v <= 5) {
+      return v;
+    }
+    return 0;
+  }
+
+  get ratingBuckets() {
+    // 0    <= x <  0.25 = empty
+    // 0.25 <= x <  0.75 = half
+    // 0.75 <= x <= 1    = full
+    // ... et cetera, until x <= 5.
+    let {rating} = this;
+    return [0, 1, 2, 3, 4].map(ratingStart => {
+      let distanceToFull = rating - ratingStart;
+      if (distanceToFull < 0.25) {
+        return "empty";
+      }
+      if (distanceToFull < 0.75) {
+        return "half";
+      }
+      return "full";
+    });
+  }
+
+  connectedCallback() {
+    this.renderRating();
+  }
+
+  attributeChangedCallback() {
+    this.renderRating();
+  }
+
+  renderRating() {
+    let starElements = this.shadowRoot.querySelectorAll(".rating-star");
+    for (let [i, part] of this.ratingBuckets.entries()) {
+      starElements[i].setAttribute("fill", part);
+    }
+    document.l10n.setAttributes(this, "five-star-rating", {
+      rating: this.rating,
+    });
+  }
+}
+customElements.define("five-star-rating", FiveStarRating);
+
 class AddonDetails extends HTMLElement {
   connectedCallback() {
     if (this.children.length == 0) {
       this.render();
     }
   }
 
   setAddon(addon) {
@@ -690,24 +751,17 @@ class AddonDetails extends HTMLElement {
       homepageURL.textContent = addon.homepageURL;
     } else {
       homepageRow.remove();
     }
 
     // Rating.
     let ratingRow = this.querySelector(".addon-detail-row-rating");
     if (addon.averageRating) {
-      let stars = ratingRow.querySelectorAll(".addon-detail-rating-star");
-      for (let i = 0; i < stars.length; i++) {
-        let fill = "";
-        if (addon.averageRating > i) {
-          fill = addon.averageRating > i + 0.5 ? "full" : "half";
-        }
-        stars[i].setAttribute("fill", fill);
-      }
+      ratingRow.querySelector("five-star-rating").rating = addon.averageRating;
       let reviews = ratingRow.querySelector("a");
       reviews.href = addon.reviewURL;
       document.l10n.setAttributes(reviews, "addon-detail-reviews-link", {
         numberOfReviews: addon.reviewCount,
       });
     } else {
       ratingRow.remove();
     }
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/rating-star.css
@@ -0,0 +1,33 @@
+:host {
+  --rating-star-size: 1em;
+  --rating-star-spacing: 0.3ch;
+
+  display: inline-grid;
+  grid-template-columns: repeat(5, var(--rating-star-size));
+  grid-column-gap: var(--rating-star-spacing);
+  align-content: center;
+}
+
+.rating-star {
+  display: inline-block;
+  width: var(--rating-star-size);
+  height: var(--rating-star-size);
+  background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#empty");
+  background-position: center;
+  background-repeat: no-repeat;
+  background-size: 100%;
+
+  fill: currentColor;
+  -moz-context-properties: fill;
+}
+
+.rating-star[fill="half"] {
+  background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#half");
+}
+.rating-star[fill="full"] {
+  background-image: url("chrome://mozapps/skin/extensions/rating-star.svg#full");
+}
+
+.rating-star[fill="half"]:dir(rtl) {
+  transform: scaleX(-1);
+}
--- a/toolkit/mozapps/extensions/jar.mn
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -26,9 +26,10 @@ toolkit.jar:
   content/mozapps/extensions/abuse-report-frame.html            (content/abuse-report-frame.html)
   content/mozapps/extensions/abuse-report-frame.js              (content/abuse-report-frame.js)
   content/mozapps/extensions/abuse-report-panel.css             (content/abuse-report-panel.css)
   content/mozapps/extensions/abuse-report-panel.js              (content/abuse-report-panel.js)
   content/mozapps/extensions/message-bar.css                    (content/message-bar.css)
   content/mozapps/extensions/message-bar.js                     (content/message-bar.js)
   content/mozapps/extensions/panel-list.css                     (content/panel-list.css)
   content/mozapps/extensions/panel-item.css                     (content/panel-item.css)
+  content/mozapps/extensions/rating-star.css                    (content/rating-star.css)
 #endif
--- a/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
+++ b/toolkit/mozapps/extensions/test/browser/browser_html_detail_view.js
@@ -77,17 +77,17 @@ add_task(async function enableHtmlViews(
     id: "addon1@mochi.test",
     name: "Test add-on 1",
     creator: {name: "The creator", url: "http://example.com/me"},
     version: "3.1",
     description: "Short description",
     fullDescription: "Longer description\nWith brs!",
     type: "extension",
     contributionURL: "http://foo.com",
-    averageRating: 4.3,
+    averageRating: 4.279,
     reviewCount: 5,
     reviewURL: "http://example.com/reviews",
     homepageURL: "http://example.com/addon1",
     updateDate: new Date("2019-03-07T01:00:00"),
     applyBackgroundUpdates: AddonManager.AUTOUPDATE_ENABLE,
   }, {
     id: "addon2@mochi.test",
     name: "Test add-on 2",
@@ -334,25 +334,49 @@ add_task(async function testFullDetails(
   link = row.querySelector("a");
   checkLink(link, "http://example.com/addon1");
 
   // Reviews.
   row = rows.shift();
   checkLabel(row, "rating");
   let rating = row.lastElementChild;
   ok(rating.classList.contains("addon-detail-rating"), "Found the rating el");
-  let stars = Array.from(rating.querySelectorAll(".addon-detail-rating-star"));
+  let starsElem = rating.querySelector("five-star-rating");
+  is(starsElem.rating, 4.279, "Exact rating used for calculations");
+  let stars = Array.from(starsElem.shadowRoot.querySelectorAll(".rating-star"));
   let fullAttrs = stars.map(star => star.getAttribute("fill")).join(",");
   is(fullAttrs, "full,full,full,full,half", "Four and a half stars are full");
   link = rating.querySelector("a");
   checkLink(link, "http://example.com/reviews", {
     id: "addon-detail-reviews-link",
     args: {numberOfReviews: 5},
   });
 
+  // While we are here, let's test edge cases of star ratings.
+  async function testRating(rating, ratingRounded, expectation) {
+    starsElem.rating = rating;
+    await starsElem.ownerDocument.l10n.translateElements([starsElem]);
+    is(starsElem.ratingBuckets.join(","), expectation,
+       `Rendering of rating ${rating}`);
+
+    is(starsElem.title, `Rated ${ratingRounded} out of 5`,
+       "Rendered title must contain at most one fractional digit");
+  }
+  await testRating(0.000, "0", "empty,empty,empty,empty,empty");
+  await testRating(0.123, "0.1", "empty,empty,empty,empty,empty");
+  await testRating(0.249, "0.2", "empty,empty,empty,empty,empty");
+  await testRating(0.250, "0.3", "half,empty,empty,empty,empty");
+  await testRating(0.749, "0.7", "half,empty,empty,empty,empty");
+  await testRating(0.750, "0.8", "full,empty,empty,empty,empty");
+  await testRating(1.000, "1", "full,empty,empty,empty,empty");
+  await testRating(4.249, "4.2", "full,full,full,full,empty");
+  await testRating(4.250, "4.3", "full,full,full,full,half");
+  await testRating(4.749, "4.7", "full,full,full,full,half");
+  await testRating(5.000, "5", "full,full,full,full,full");
+
   // That should've been all the rows.
   is(rows.length, 0, "There are no more rows left");
 
   await closeView(win);
 });
 
 add_task(async function testMinimalExtension() {
   let win = await loadInitialView("extension");
new file mode 100644
--- /dev/null
+++ b/toolkit/themes/shared/extensions/rating-star.svg
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
+  <!--
+       This image defines three versions of the star:
+       #full = star filled with full color
+       #half = half-filled star (full color at left, transparent color at right)
+       #empty = star filled with transparent color
+  -->
+
+  <!-- Default image: full star -->
+  <view id="full" viewBox="0 0 64 64" />
+  <view id="half" viewBox="0 64 64 64" />
+  <view id="empty" viewBox="0 128 64 64" />
+
+  <defs>
+    <g id="star-shape" fill="context-fill" transform="translate(-140.000000, -607.000000)" fill-opacity="context-fill-opacity">
+      <path d="M154.994575,670.99995 C153.704598,671.000763 152.477615,670.442079 151.630967,669.468394 C150.784319,668.49471 150.401158,667.201652 150.580582,665.923653 L153.046749,648.259919 L141.193762,635.514481 C140.080773,634.318044 139.711733,632.608076 140.232152,631.058811 C140.752571,629.509546 142.078939,628.369589 143.688275,628.088421 L160.214424,625.130961 L168.013827,609.468577 C168.767364,607.955994 170.3113,607 172.000594,607 C173.689888,607 175.233824,607.955994 175.98736,609.468577 L183.790813,625.130961 L200.329111,628.08437 C201.934946,628.371492 203.25546,629.513805 203.771316,631.062053 C204.287172,632.610301 203.915846,634.316807 202.803377,635.51043 L190.954439,648.26397 L193.420606,665.923653 C193.652457,667.578241 192.93975,669.223573 191.574418,670.185702 C190.209085,671.147831 188.420524,671.265104 186.941351,670.489485 L172.002619,662.698806 L157.047688,670.50569 C156.413201,670.833752 155.708782,671.003331 154.994575,670.99995 Z"></path>
+    </g>
+    <clipPath id="left-half">
+      <rect x="0" y="0" width="50%" height="100%" />
+    </clipPath>
+    <clipPath id="right-half">
+      <rect x="50%" y="0" width="50%" height="100%" />
+    </clipPath>
+  </defs>
+
+  <!-- full -->
+  <use href="#star-shape" x="0" y="0" />
+
+  <!-- half -->
+  <g transform="translate(0, 64)">
+    <use href="#star-shape" clip-path="url(#left-half)" />
+    <use href="#star-shape" clip-path="url(#right-half)" opacity="0.25" />
+  </g>
+
+  <!-- empty -->
+  <g transform="translate(0, 128)">
+    <use href="#star-shape" opacity="0.25" />
+  </g>
+</svg>
--- a/toolkit/themes/shared/mozapps.inc.mn
+++ b/toolkit/themes/shared/mozapps.inc.mn
@@ -18,16 +18,19 @@
 
   skin/classic/mozapps/extensions/extensionGeneric-16.svg    (../../shared/extensions/extensionGeneric-16.svg)
   skin/classic/mozapps/extensions/utilities.svg              (../../shared/extensions/utilities.svg)
   skin/classic/mozapps/extensions/alerticon-warning.svg      (../../shared/extensions/alerticon-warning.svg)
   skin/classic/mozapps/extensions/alerticon-error.svg        (../../shared/extensions/alerticon-error.svg)
   skin/classic/mozapps/extensions/alerticon-info-positive.svg (../../shared/extensions/alerticon-info-positive.svg)
   skin/classic/mozapps/extensions/alerticon-info-negative.svg (../../shared/extensions/alerticon-info-negative.svg)
   skin/classic/mozapps/extensions/category-legacy.svg        (../../shared/extensions/category-legacy.svg)
+#ifndef ANDROID
+  skin/classic/mozapps/extensions/rating-star.svg            (../../shared/extensions/rating-star.svg)
+#endif
   skin/classic/mozapps/aboutNetworking.css                   (../../shared/aboutNetworking.css)
 #ifndef ANDROID
   skin/classic/mozapps/aboutProfiles.css                     (../../shared/aboutProfiles.css)
 #endif
   skin/classic/mozapps/aboutServiceWorkers.css               (../../shared/aboutServiceWorkers.css)
   skin/classic/mozapps/profile/profileDowngrade.css          (../../shared/profile/profileDowngrade.css)
   skin/classic/mozapps/profile/information.svg               (../../shared/profile/information.svg)