Data model:
No need to have entering a rating cause a full page load.
Create the resource:
$ mix help phx.gen.json
$ mix phx.gen.json Ratings Rating ratings user_id:references:users product_id:references:products stars:integer
Edit the migration (no nulls, delete_all)
Add the line to a new section in the router. Note API vs. Browser vs. AJAX.
...
pipeline :ajax do
plug :accepts, ["json"]
plug :fetch_session
plug :fetch_flash
plug HuskyShopWeb.Plugs.FetchSession # FIXME: "FetchUser"
end
...
scope "/ajax", HuskyShopWeb do
pipe_through :ajax
resources "/ratings", RatingController, except: [:new, :edit]
end
Show new routes with /ajax prefix:
$ mix phx.routes
Run migration: mix ecto.migrate
Add the assocaitions and fix validation:
ratings/rating.ex
schema "ratings" do
field :stars, :integer
belongs_to :user, HuskyShop.Users.User
belongs_to :product, HuskyShop.Products.Product
...
|> cast(attrs, [:stars, :user_id, :product_id])
|> validate_required([:stars, :user_id, :product_id])
BOTH users/user.ex and products/product.ex
has_many :ratings, HuskyShop.Ratings.Rating
First, let's preload ratings:
lib/husky_shop/products/products.ex
def get_product!(id) do
Repo.one! from p in Product,
where: p.id = ^id,
preload: [:ratings]
end
Update product display:
lib/husky_shop_web/templates/product/show.html.eex
<!-- in the product field list -->
<li>
<strong>Rating:</strong>
<%= avg_rating(@product) %>
<!-- C-x 8 RET, "black star" -->
<%= if @current_user do %>
<div id="rating-form">
<select id="rating-select">
<option value="1">★</option>
<option value="2">★★</option>
<option value="3">★★★</option>
<option value="4">★★★★</option>
<option value="5">★★★★★</option>
</select>
<button id="rating-button"
data-user-id="<%= @current_user.id %>"
data-product-id="<%= @product.id %>"
class="btn btn-secondary">Rate!</button>
</div>
<% end %>
</li>
Add our display helper function to the view
lib/husky_shop_web/views/product_view.ex
def avg_rating(product) do
rats = Enum.map(product.ratings, &(&1.stars))
if Enum.empty?(rats) do
"?? (no ratings)"
else
mean = Enum.sum(rats) / Enum.count(rats)
"#{mean} / 5 (#{Enum.count(rats)} ratings)"
end
end
The ratings form we made doesn't have a "form" tag, which means it won't POST when we press the button. If we want it do do something, we'll have to write the logic in JavaScript.
We'll use jQuery, which we already installed for Bootstrap.
assets/js/app.js
$(function () {
$('#rating-button').click((ev) => {
let rating = $('#rating-select').val();
let user_id = $(ev.target).data('user-id');
let product_id = $(ev.target).data('product-id');
let text = JSON.stringify({
rating: {
user_id: user_id,
product_id: product_id,
stars: rating,
},
});
$.ajax(rating_path, {
method: "post",
dataType: "json",
contentType: "application/json; charset=UTF-8",
data: text,
success: (resp) => {
$('#rating-form').text(`(your rating: ${resp.data.stars})`);
},
});
});
});
Problem: A user can create multiple ratings.
First, let's ban that by adding a unique index to the migration.
priv/repo/migrations/201810*ratings*
create index(:ratings, [:user_id, :product_id], unique: true)
husky_shop/ratings/rating.ex
|> unique_constraint(:user_id, name: :ratings_user_id_product_id_index)
Now replace insert with upsert in ratings/ratings.ex
def create_rating(attrs \\ %{}) do
rat = %Rating{}
|> Rating.changeset(attrs)
|> Repo.insert(
on_conflict: :replace_all,
conflict_target: [:user_id, :product_id])
end
Next problem: We're not updating the average.
rating_controller.ex
# This goes first.
def index(conn, %{"product_id" => p_id}) do
ratings = Ratings.list_ratings(p_id)
render(conn, "index.json", ratings: ratings)
end
Create list_ratings/1 in husky_shop/ratings/ratings.ex
def list_ratings(product_id) do
Repo.all from r in Rating,
where: r.product_id == ^product_id
end
Add an ID to the average in product/show:
<span id="rating-avg">
<%= avg_rating(@product) %>
</span>
Let's add lodash.
$ npm install --save lodash
app.js
// Below bootstrap import
import _ from "lodash";
app.js, add a function
$(function () {
function update_stars(product_id) {
$.ajax(`${rating_path}?product_id=${product_id}`, {
method: "get",
dataType: "json",
contentType: "application/json; charset=UTF-8",
data: "",
success: (resp) => {
let count = resp.data.length;
let sum = _.sum(_.map(resp.data, (rat) => rat.stars));
$('#rating-avg').text(`${sum/count} / 5 (${count} ratings)`);
},
});
}
...
success: (resp) => {
$('#rating-form').text(`(your rating: ${resp.data.stars})`);
update_stars(product_id);
},
(current state as branch: 6-ratings)