diff --git a/.credo.exs b/.credo.exs
index 2cafe33..773e0ec 100644
--- a/.credo.exs
+++ b/.credo.exs
@@ -151,7 +151,6 @@
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.UtcNowTruncate, []},
- {Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Refactor.WithClauses, []},
#
@@ -189,6 +188,7 @@
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SinglePipe, []},
+ {Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []}
# Custom checks can be created using `mix credo.gen.check`.
diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml
index 2fec16f..4d8212e 100644
--- a/.github/workflows/code-quality.yaml
+++ b/.github/workflows/code-quality.yaml
@@ -38,7 +38,7 @@ jobs:
if: always()
- name: Check for compile-time dependencies
- run: mix xref graph --label compile-connected --fail-above 0
+ run: mix xref graph --label compile-connected --fail-above 1
if: always()
- name: Check for security vulnerabilities in Phoenix project
diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex
index a047973..1e9f493 100644
--- a/lib/flick/ranked_voting.ex
+++ b/lib/flick/ranked_voting.ex
@@ -11,9 +11,15 @@ defmodule Flick.RankedVoting do
@doc """
Creates a new `Flick.RankedVoting.Ballot` entity with the given `title` and `questions`.
+
+ Attempts to pass in `published_at` or `closed_at` will raise an `ArgumentError`
+ Please look to `published_ballot/2` and `close_ballot/2` for those lifecycle needs.
"""
- @spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, Ecto.Changeset.t(Ballot.t())}
+ @spec create_ballot(map()) :: {:ok, Ballot.t()} | {:error, Ballot.changeset()}
def create_ballot(attrs) when is_map(attrs) do
+ raise_if_attempting_to_set_published_at(attrs)
+ raise_if_attempting_to_set_closed_at(attrs)
+
%Ballot{}
|> change_ballot(attrs)
|> Repo.insert()
@@ -23,20 +29,26 @@ defmodule Flick.RankedVoting do
Updates the given `Flick.RankedVoting.Ballot` entity with the given attributes.
If the `Flick.RankedVoting.Ballot` has already been published, an error is returned.
+
+ Attempts to pass in `published_at` or `closed_at` will raise an `ArgumentError`
+ Please look to `published_ballot/2` and `close_ballot/2` for those lifecycle needs.
"""
@spec update_ballot(Ballot.t(), map()) ::
{:ok, Ballot.t()}
- | {:error, Ecto.Changeset.t(Ballot.t())}
- | {:error, :can_not_update_published_ballot}
+ | {:error, Ballot.changeset()}
+ | {:error, :can_only_update_draft_ballot}
+
+ def update_ballot(%Ballot{published_at: nil, closed_at: nil} = ballot, attrs) do
+ raise_if_attempting_to_set_published_at(attrs)
+ raise_if_attempting_to_set_closed_at(attrs)
- def update_ballot(%Ballot{published_at: nil} = ballot, attrs) do
ballot
|> change_ballot(attrs)
|> Repo.update()
end
def update_ballot(_ballot, _attrs) do
- {:error, :can_not_update_published_ballot}
+ {:error, :can_only_update_draft_ballot}
end
@doc """
@@ -55,13 +67,13 @@ defmodule Flick.RankedVoting do
"""
@spec publish_ballot(Ballot.t(), DateTime.t()) ::
{:ok, Ballot.t()}
- | {:error, Ecto.Changeset.t(Ballot.t())}
+ | {:error, Ballot.changeset()}
| {:error, :ballot_already_published}
def publish_ballot(ballot, published_at \\ DateTime.utc_now())
def publish_ballot(%Ballot{published_at: nil} = ballot, published_at) do
ballot
- |> change_ballot(%{published_at: published_at})
+ |> Ecto.Changeset.cast(%{published_at: published_at}, [:published_at])
|> Repo.update()
end
@@ -69,6 +81,54 @@ defmodule Flick.RankedVoting do
{:error, :ballot_already_published}
end
+ @doc """
+ Closes the given `Flick.RankedVoting.Ballot` entity.
+
+ Once a `Flick.RankedVoting.Ballot` entity is closed, it can no longer be updated
+ and no more votes can be cast.
+ """
+ @spec close_ballot(Ballot.t(), DateTime.t()) ::
+ {:ok, Ballot.t()}
+ | {:error, Ballot.changeset()}
+ | {:error, :ballot_not_published}
+ def close_ballot(ballot, closed_at \\ DateTime.utc_now())
+
+ def close_ballot(%Ballot{published_at: nil}, _closed_at) do
+ {:error, :ballot_not_published}
+ end
+
+ def close_ballot(%Ballot{closed_at: nil} = ballot, closed_at) do
+ ballot
+ |> Ecto.Changeset.cast(%{closed_at: closed_at}, [:closed_at])
+ |> Repo.update()
+ end
+
+ def close_ballot(%Ballot{closed_at: _non_nil_value}, _closed_at) do
+ {:error, :ballot_already_closed}
+ end
+
+ @typedoc """
+ Represents the three states of a ballot: `:draft`, `:published`, and `:closed`.
+
+ - A `:draft` ballot can be edited, and then published.
+ - A `:published` ballot can no longer be updated, can accept votes and can be closed.
+ - A `:closed` ballot can no longer be updated, and can no longer accept votes.
+ """
+ @type ballot_status :: :draft | :published | :closed
+
+ @doc """
+ Returns the `t:ballot_status/0` of the given `Flick.RankedVoting.Ballot` entity.
+ """
+ @spec ballot_status(Ballot.t()) :: ballot_status()
+ def ballot_status(ballot) do
+ case ballot do
+ %Ballot{closed_at: nil, published_at: nil} -> :draft
+ %Ballot{closed_at: nil, published_at: _non_nil_value} -> :published
+ %Ballot{closed_at: _non_nil_value, published_at: nil} -> raise "invalid state observed"
+ %Ballot{closed_at: _non_nil_value, published_at: _another_non_nil_value} -> :closed
+ end
+ end
+
@doc """
Returns a list of all `Flick.RankedVoting.Ballot` entities.
"""
@@ -125,7 +185,7 @@ defmodule Flick.RankedVoting do
@doc """
Returns an `Ecto.Changeset` representing changes to a `Flick.RankedVoting.Ballot` entity.
"""
- @spec change_ballot(Ballot.t() | Ballot.struct_t(), map()) :: Ecto.Changeset.t(Ballot.t())
+ @spec change_ballot(Ballot.t() | Ballot.struct_t(), map()) :: Ballot.changeset()
def change_ballot(%Ballot{} = ballot, attrs) do
Ballot.changeset(ballot, attrs)
end
@@ -201,6 +261,17 @@ defmodule Flick.RankedVoting do
|> Repo.all()
end
+ @doc """
+ Returns the count of `Flick.RankedVoting.Vote` entities associated with the given
+ `ballot_id`.
+ """
+ @spec count_votes_for_ballot_id(Ballot.id()) :: non_neg_integer()
+ def count_votes_for_ballot_id(ballot_id) do
+ Vote
+ |> where(ballot_id: ^ballot_id)
+ |> Repo.aggregate(:count)
+ end
+
@typedoc """
A report describing the voting results for a ballot, displaying each possible
answer and the point total it received.
@@ -278,4 +349,16 @@ defmodule Flick.RankedVoting do
min(5, possible_answer_count)
end
+
+ defp raise_if_attempting_to_set_published_at(attrs) do
+ if Map.has_key?(attrs, :published_at) or Map.has_key?(attrs, "published_at") do
+ raise ArgumentError, "`published_at` can not be set during creation or mutation of a ballot"
+ end
+ end
+
+ defp raise_if_attempting_to_set_closed_at(attrs) do
+ if Map.has_key?(attrs, :closed_at) or Map.has_key?(attrs, "closed_at") do
+ raise ArgumentError, "`closed_at` can not be set during creation or mutation of a ballot"
+ end
+ end
end
diff --git a/lib/flick/ranked_voting/ballot.ex b/lib/flick/ranked_voting/ballot.ex
index 93410ab..cd3c9d3 100644
--- a/lib/flick/ranked_voting/ballot.ex
+++ b/lib/flick/ranked_voting/ballot.ex
@@ -23,9 +23,15 @@ defmodule Flick.RankedVoting.Ballot do
url_slug: String.t(),
secret: Ecto.UUID.t(),
possible_answers: String.t(),
- published_at: DateTime.t() | nil
+ published_at: DateTime.t() | nil,
+ closed_at: DateTime.t() | nil
}
+ @typedoc """
+ A changeset for a `Flick.RankedVoting.Ballot` entity.
+ """
+ @type changeset :: Ecto.Changeset.t(t())
+
@typedoc """
A type for the empty `Flick.RankedVoting.Ballot` struct.
@@ -43,11 +49,17 @@ defmodule Flick.RankedVoting.Ballot do
field :secret, :binary_id, read_after_writes: true
field :possible_answers, :string
field :published_at, :utc_datetime_usec
+ field :closed_at, :utc_datetime_usec
timestamps(type: :utc_datetime_usec)
end
@required_fields [:question_title, :possible_answers, :url_slug]
- @optional_fields [:published_at, :description]
+
+ # With intent, we do not allow `published_at` or `closed_at` to be set inside
+ # a normal changeset. Instead look to the
+ # `Flick.RankedVoting.publish_ballot/2` and
+ # `Flick.RankedVoting.close_ballot/2` to perform those updates.
+ @optional_fields [:description]
@spec changeset(t() | struct_t(), map()) :: Ecto.Changeset.t(t()) | Ecto.Changeset.t(struct_t())
def changeset(ballot, attrs) do
@@ -55,7 +67,6 @@ defmodule Flick.RankedVoting.Ballot do
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> validate_possible_answers()
- |> validate_published_at()
|> validate_format(:url_slug, ~r/^[a-zA-Z0-9-]+$/,
message: "can only contain letters, numbers, and hyphens"
)
@@ -70,16 +81,6 @@ defmodule Flick.RankedVoting.Ballot do
|> Enum.map(&String.trim/1)
end
- @spec validate_published_at(Ecto.Changeset.t()) :: Ecto.Changeset.t()
- def validate_published_at(%Ecto.Changeset{data: %__MODULE__{id: nil}} = changeset) do
- # We do not want "new" ballots to be created as already published.
- validate_change(changeset, :published_at, fn :published_at, _updated_value ->
- [published_at: "new ballots can not be published"]
- end)
- end
-
- def validate_published_at(changeset), do: changeset
-
defp validate_possible_answers(changeset) do
# Because we validated the value as `required` before this, we don't need to
# concern ourselves with an empty list here.
diff --git a/lib/flick_web/components/core_components.ex b/lib/flick_web/components/core_components.ex
index 738f866..b175cc4 100644
--- a/lib/flick_web/components/core_components.ex
+++ b/lib/flick_web/components/core_components.ex
@@ -465,6 +465,7 @@ defmodule FlickWeb.CoreComponents do
slot :col, required: true do
attr :label, :string
+ attr :title, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
@@ -480,7 +481,13 @@ defmodule FlickWeb.CoreComponents do
-
{col[:label]}
+
+ <%= if Map.get(col, :title, nil) do %>
+ {col.label}
+ <% else %>
+ {col[:label]}
+ <% end %>
+
{gettext("Actions")}
diff --git a/lib/flick_web/components/layouts/app.html.heex b/lib/flick_web/components/layouts/app.html.heex
index 6c93655..f7d53a1 100644
--- a/lib/flick_web/components/layouts/app.html.heex
+++ b/lib/flick_web/components/layouts/app.html.heex
@@ -34,6 +34,6 @@
GitHub Project
- • Contact
- .
+ • Contact Site Admin
+
diff --git a/lib/flick_web/live/ballots/index_live.ex b/lib/flick_web/live/ballots/index_live.ex
index 3310e33..733eb99 100644
--- a/lib/flick_web/live/ballots/index_live.ex
+++ b/lib/flick_web/live/ballots/index_live.ex
@@ -8,6 +8,8 @@ defmodule FlickWeb.Ballots.IndexLive do
use FlickWeb, :live_view
+ alias Flick.RankedVoting.Ballot
+
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
socket
@@ -19,28 +21,54 @@ defmodule FlickWeb.Ballots.IndexLive do
@impl Phoenix.LiveView
def render(assigns) do
~H"""
-
-
Admin: Ballots
+
+
Administration
+
+
+ The following page is a list of all ballots in the system, allowing an authenticated admin to quickly see and link to each page.
+
+
+
Only authenticated admins can see this page.
+
+
Ballots
<.table id="ballots" rows={@ballots} row_id={&"ballot-row-#{&1.id}"}>
<:col :let={ballot} label="Title">
- {ballot.question_title}
-
- <:col :let={ballot} label="Published">
- <%= if ballot.published_at do %>
- {ballot.published_at}
- <% else %>
- Not Published
- <% end %>
-
- <:col :let={ballot}>
- <.link :if={ballot.published_at} href={~p"/ballot/#{ballot.url_slug}"}>Voting Page
+
This ballot was published at: {@ballot.published_at}
-
-
- You can invite people to vote using the URL:
- <.link navigate={~p"/ballot/#{@ballot.url_slug}"}>
- {URI.append_path(@socket.host_uri, "/ballot/#{@ballot.url_slug}")}
-
-
- {vote.weight}
- <%!-- TODO: As the user clicks `Edit` we should focus the form input. --%>
- <.link phx-click="present-inline-editor" phx-value-vote-id={vote.id} class="underline">
- Edit
-
-
+ """
+ end
+
+ attr :vote, Vote, required: true
+ attr :vote_forms, :map, required: true
+
+ defp weight_form(assigns) do
+ ~H"""
+ <.form
+ :let={form}
+ :if={@vote_forms && form_for_vote(@vote_forms, @vote)}
+ for={form_for_vote(@vote_forms, @vote)}
+ phx-change="validate"
+ phx-submit="save"
+ >
+
+ <%!-- TODO: In the future we should draw red outline here when invalid. --%>
+ <%!-- https://github.com/zorn/flick/issues/37 --%>
+
+ <.button>Save
+ <.link phx-click="dismiss-inline-editor" phx-value-vote-id={@vote.id} class="underline">
+ Cancel
+
+
+ """
+ end
+
+ defp shared_header(assigns) do
+ ~H"""
+
+
Ballot Admin
+
+
This page is where you'll edit, publish, tally and close your ballot.
+
+
Bookmark This Page
+
+
+ This site does not use registered accounts. To return to this ballot admin page you'll need to full URL. Be sure to bookmark it now.
+
"""
end
@@ -244,4 +386,11 @@ defmodule FlickWeb.Ballots.ViewerLive do
%RankedAnswer{value: value} = Enum.at(vote.ranked_answers, index)
value
end
+
+ defp rendered_description(description) when is_binary(description) do
+ {:ok, html_doc, _deprecation_messages} = Earmark.as_html(description)
+ html_doc
+ end
+
+ defp rendered_description(_description), do: nil
end
diff --git a/lib/flick_web/live/vote/results_live.ex b/lib/flick_web/live/vote/results_live.ex
new file mode 100644
index 0000000..f4371c0
--- /dev/null
+++ b/lib/flick_web/live/vote/results_live.ex
@@ -0,0 +1,48 @@
+defmodule FlickWeb.Vote.ResultsLive do
+ @moduledoc """
+ A live view that presents the results of a closed `Flick.RankedVoting.Ballot` entity.
+ """
+
+ use FlickWeb, :live_view
+
+ alias Flick.RankedVoting
+
+ @impl Phoenix.LiveView
+ def mount(params, _session, socket) do
+ %{"url_slug" => url_slug} = params
+ ballot = RankedVoting.get_ballot_by_url_slug!(url_slug)
+
+ if ballot.closed_at do
+ socket
+ |> assign(:page_title, "Results: #{ballot.question_title}")
+ |> assign(:ballot, ballot)
+ |> assign(:ballot_results_report, RankedVoting.get_ballot_results_report(ballot.id))
+ |> assign(:vote_count, RankedVoting.count_votes_for_ballot_id(ballot.id))
+ |> ok()
+ else
+ socket
+ |> put_flash(:error, "This ballot is not closed and results are unavailable.")
+ |> redirect(to: ~p"/")
+ |> ok()
+ end
+ end
+
+ @impl Phoenix.LiveView
+ def render(assigns) do
+ ~H"""
+
+
Ballot Results
+
+
For the ballot asking {@ballot.question_title} the results in!
+
+
With a total of {@vote_count} votes cast, the outcome of the ranked vote is:
+
+
+ <%= for %{points: points, value: answer} <- @ballot_results_report do %>
+
{answer}: {points} points
+ <% end %>
+
+
+ """
+ end
+end
diff --git a/lib/flick_web/live/vote/vote_capture_live.ex b/lib/flick_web/live/vote/vote_capture_live.ex
index ddeb759..fe07979 100644
--- a/lib/flick_web/live/vote/vote_capture_live.ex
+++ b/lib/flick_web/live/vote/vote_capture_live.ex
@@ -15,12 +15,25 @@ defmodule FlickWeb.Vote.VoteCaptureLive do
%{"url_slug" => url_slug} = params
ballot = RankedVoting.get_ballot_by_url_slug!(url_slug)
- socket
- |> verify_ballot_is_published(ballot)
- |> assign(:page_title, "Vote: #{ballot.question_title}")
- |> assign(:ballot, ballot)
- |> assign_form()
- |> ok()
+ cond do
+ ballot.closed_at ->
+ socket
+ |> redirect(to: ~p"/ballot/#{ballot.url_slug}/results")
+ |> ok()
+
+ ballot.published_at ->
+ socket
+ |> assign(:page_title, "Vote: #{ballot.question_title}")
+ |> assign(:ballot, ballot)
+ |> assign_form()
+ |> ok()
+
+ true ->
+ socket
+ |> put_flash(:error, "This ballot is unpublished and can not accept votes.")
+ |> redirect(to: ~p"/")
+ |> ok()
+ end
end
@spec assign_form(Socket.t()) :: Socket.t()
@@ -118,13 +131,4 @@ defmodule FlickWeb.Vote.VoteCaptureLive do
defp options(ballot) do
[nil] ++ Flick.RankedVoting.Ballot.possible_answers_as_list(ballot.possible_answers)
end
-
- defp verify_ballot_is_published(socket, ballot) do
- if ballot.published_at do
- socket
- else
- # FIXME: We can make this a better user experience in the future.
- throw("can not vote on an unpublished ballot")
- end
- end
end
diff --git a/lib/flick_web/router.ex b/lib/flick_web/router.ex
index a4296e6..96de689 100644
--- a/lib/flick_web/router.ex
+++ b/lib/flick_web/router.ex
@@ -48,8 +48,9 @@ defmodule FlickWeb.Router do
live "/", IndexLive, :index
live "/ballot/new", Ballots.EditorLive, :new
- live "/ballot/:url_slug/:secret", Ballots.ViewerLive, :edit
+ live "/ballot/:url_slug/results", Vote.ResultsLive, :index
live "/ballot/:url_slug/:secret/edit", Ballots.EditorLive, :edit
+ live "/ballot/:url_slug/:secret", Ballots.ViewerLive, :edit
live "/ballot/:url_slug", Vote.VoteCaptureLive, :new
live_storybook "/storybook", backend_module: Elixir.FlickWeb.Storybook
diff --git a/priv/repo/migrations/20250101162200_add_closed_at_to_ballots.exs b/priv/repo/migrations/20250101162200_add_closed_at_to_ballots.exs
new file mode 100644
index 0000000..c4859c6
--- /dev/null
+++ b/priv/repo/migrations/20250101162200_add_closed_at_to_ballots.exs
@@ -0,0 +1,9 @@
+defmodule Flick.Repo.Migrations.AddClosedAtToBallots do
+ use Ecto.Migration
+
+ def change do
+ alter table(:ballots) do
+ add :closed_at, :utc_datetime_usec, null: true
+ end
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index f05569f..55ddb19 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -10,37 +10,79 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
-{:ok, ballot} =
+defmodule SeedScripts do
+ alias Flick.RankedVoting.Ballot
+
+ @doc """
+ Given a published `Flick.RankedVoting.Ballot` will populate said ballot with 25 votes.
+
+ It is expected that the ballot has three possible answers.
+ """
+ def populate_ballot_with_votes(%Ballot{published_at: published_at} = ballot)
+ when not is_nil(published_at) do
+ # This assumes a ballot with three possible answers.
+
+ for _ <- 1..25 do
+ available_answers = Ballot.possible_answers_as_list(ballot.possible_answers)
+
+ if length(available_answers) != 3 do
+ raise """
+ The ballot with the question title '#{ballot.question_title}' must have at least three possible answers. We saw '#{available_answers}'.
+ """
+ end
+
+ first_answer = Enum.random(available_answers)
+ available_answers = Enum.reject(available_answers, &(&1 == first_answer))
+ second_answer = Enum.random(available_answers)
+ available_answers = Enum.reject(available_answers, &(&1 == second_answer))
+ third_answer = Enum.random(available_answers)
+
+ # For a little variance, we'll sometime use an empty quote value for the third
+ # answer, since a user need not always provide a full ranked list answer.
+ third_answer = Enum.random([third_answer, ""])
+
+ full_name = if Enum.random(1..5) > 1, do: Faker.Person.name()
+
+ {:ok, _vote} =
+ Flick.RankedVoting.create_vote(ballot, %{
+ "full_name" => full_name,
+ "ranked_answers" => [
+ %{"value" => first_answer},
+ %{"value" => second_answer},
+ %{"value" => third_answer}
+ ]
+ })
+ end
+ end
+end
+
+# Create a published ballot with some votes.
+{:ok, sandwich_ballot} =
Flick.RankedVoting.create_ballot(%{
question_title: "What is your sandwich preference?",
possible_answers: "Turkey, Ham, Roast Beef",
url_slug: "sandwich-preference"
})
-{:ok, published_ballot} = Flick.RankedVoting.publish_ballot(ballot)
-
-for _ <- 1..25 do
- available_answers = ["Turkey", "Ham", "Roast Beef"]
+{:ok, sandwich_ballot_published} = Flick.RankedVoting.publish_ballot(sandwich_ballot)
+SeedScripts.populate_ballot_with_votes(sandwich_ballot_published)
- first_answer = Enum.random(available_answers)
- available_answers = Enum.reject(available_answers, &(&1 == first_answer))
- second_answer = Enum.random(available_answers)
- available_answers = Enum.reject(available_answers, &(&1 == second_answer))
- third_answer = Enum.random(available_answers)
-
- # For a little variance, we'll sometime use an empty quote value for the third
- # answer, since a user need not always provide a full ranked list answer.
- third_answer = Enum.random([third_answer, ""])
+# Create a draft ballot.
+{:ok, color_ballot} =
+ Flick.RankedVoting.create_ballot(%{
+ question_title: "What is your favorite color?",
+ possible_answers: "Red, Green, Blue",
+ url_slug: "favorite-color"
+ })
- full_name = if Enum.random(1..5) > 1, do: Faker.Person.name()
+# Create a closed ballot.
+{:ok, fruit_ballot} =
+ Flick.RankedVoting.create_ballot(%{
+ question_title: "What is your favorite fruit?",
+ possible_answers: "Apple, Banana, Orange",
+ url_slug: "favorite-fruit"
+ })
- {:ok, _vote} =
- Flick.RankedVoting.create_vote(published_ballot, %{
- "full_name" => full_name,
- "ranked_answers" => [
- %{"value" => first_answer},
- %{"value" => second_answer},
- %{"value" => third_answer}
- ]
- })
-end
+{:ok, fruit_ballot_published} = Flick.RankedVoting.publish_ballot(fruit_ballot)
+SeedScripts.populate_ballot_with_votes(fruit_ballot_published)
+{:ok, fruit_ballot_closed} = Flick.RankedVoting.close_ballot(fruit_ballot_published)
diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs
index 2775d7f..f97367a 100644
--- a/test/flick/ranked_voting_test.exs
+++ b/test/flick/ranked_voting_test.exs
@@ -132,10 +132,11 @@ defmodule Flick.RankedVotingTest do
end
test "failure: can not attempt to create a ballot that is already `published`" do
- assert {:error, changeset} =
- RankedVoting.create_ballot(%{published_at: ~U[2021-01-01 00:00:00Z]})
-
- assert "new ballots can not be published" in errors_on(changeset).published_at
+ assert_raise ArgumentError,
+ "`published_at` can not be set during creation or mutation of a ballot",
+ fn ->
+ RankedVoting.create_ballot(%{published_at: ~U[2021-01-01 00:00:00Z]})
+ end
end
test "success: `secret` is created after row insertion" do
@@ -187,14 +188,21 @@ defmodule Flick.RankedVotingTest do
test "failure: can not update a published ballot" do
ballot = published_ballot_fixture()
- assert {:error, :can_not_update_published_ballot} =
+ assert {:error, :can_only_update_draft_ballot} =
+ RankedVoting.update_ballot(ballot, %{title: "some new title"})
+ end
+
+ test "failure: can not update a closed ballot" do
+ ballot = closed_ballot_fixture()
+
+ assert {:error, :can_only_update_draft_ballot} =
RankedVoting.update_ballot(ballot, %{title: "some new title"})
end
end
describe "publish_ballot/2" do
test "success: you can publish a non-published ballot" do
- ballot = ballot_fixture(%{published_at: nil})
+ ballot = ballot_fixture()
published_at = DateTime.utc_now()
assert {:ok, published_ballot} = RankedVoting.publish_ballot(ballot, published_at)
assert %Ballot{published_at: ^published_at} = published_ballot
@@ -208,6 +216,50 @@ defmodule Flick.RankedVotingTest do
end
end
+ describe "close_ballot/2" do
+ test "success: closes a published ballot" do
+ ballot = published_ballot_fixture()
+ closed_at = DateTime.utc_now()
+ assert {:ok, closed_ballot} = RankedVoting.close_ballot(ballot, closed_at)
+ assert %Ballot{closed_at: ^closed_at} = closed_ballot
+ end
+
+ test "failure: can not close an unpublished ballot" do
+ ballot = ballot_fixture()
+ assert {:error, :ballot_not_published} = RankedVoting.close_ballot(ballot)
+ end
+
+ test "failure: can not close a closed ballot" do
+ ballot = closed_ballot_fixture()
+ assert {:error, :ballot_already_closed} = RankedVoting.close_ballot(ballot)
+ end
+ end
+
+ describe "ballot_status/1" do
+ test "returns `:draft` for a non-published ballot" do
+ ballot = ballot_fixture()
+ assert :draft = RankedVoting.ballot_status(ballot)
+ end
+
+ test "returns `:published` for a published ballot" do
+ ballot = published_ballot_fixture()
+ assert :published = RankedVoting.ballot_status(ballot)
+ end
+
+ test "returns `:closed` for a closed ballot" do
+ ballot = closed_ballot_fixture()
+ assert :closed = RankedVoting.ballot_status(ballot)
+ end
+
+ test "raises when encountering an unknown status" do
+ ballot = %Ballot{published_at: nil, closed_at: DateTime.utc_now()}
+
+ assert_raise RuntimeError, "invalid state observed", fn ->
+ RankedVoting.ballot_status(ballot)
+ end
+ end
+ end
+
describe "list_ballots/1" do
test "success: lists ballots start with zero ballots" do
assert [] = RankedVoting.list_ballots()
@@ -473,6 +525,60 @@ defmodule Flick.RankedVotingTest do
end
end
+ describe "list_votes_for_ballot_id/1" do
+ setup do
+ ballot =
+ published_ballot_fixture(
+ question_title: "What's for dinner?",
+ possible_answers: "Pizza, Tacos, Sushi, Burgers"
+ )
+
+ {:ok, published_ballot: ballot}
+ end
+
+ test "returns a lists votes of a ballot", ~M{published_ballot} do
+ assert [] = RankedVoting.list_votes_for_ballot_id(published_ballot.id)
+
+ {:ok, vote} =
+ RankedVoting.create_vote(published_ballot, %{
+ "ranked_answers" => [
+ %{"value" => "Tacos"},
+ %{"value" => "Pizza"},
+ %{"value" => "Burgers"}
+ ]
+ })
+
+ assert [^vote] = RankedVoting.list_votes_for_ballot_id(published_ballot.id)
+ end
+ end
+
+ describe "count_votes_for_ballot_id/1" do
+ setup do
+ ballot =
+ published_ballot_fixture(
+ question_title: "What's for dinner?",
+ possible_answers: "Pizza, Tacos, Sushi, Burgers"
+ )
+
+ {:ok, published_ballot: ballot}
+ end
+
+ test "returns a count of the votes of a ballot", ~M{published_ballot} do
+ assert 0 = RankedVoting.count_votes_for_ballot_id(published_ballot.id)
+
+ {:ok, _vote} =
+ RankedVoting.create_vote(published_ballot, %{
+ "ranked_answers" => [
+ %{"value" => "Tacos"},
+ %{"value" => "Pizza"},
+ %{"value" => "Burgers"}
+ ]
+ })
+
+ assert 1 = RankedVoting.count_votes_for_ballot_id(published_ballot.id)
+ end
+ end
+
describe "get_ballot_results_report/1" do
setup do
ballot =
diff --git a/test/flick_web/live/ballots/index_live_test.exs b/test/flick_web/live/ballots/index_live_test.exs
index 3038952..74b23e6 100644
--- a/test/flick_web/live/ballots/index_live_test.exs
+++ b/test/flick_web/live/ballots/index_live_test.exs
@@ -32,7 +32,7 @@ defmodule FlickWeb.Ballots.IndexLiveTest do
for ballot <- ballots do
row_selector = "tbody#ballots tr#ballot-row-#{ballot.id}"
assert has_element?(view, row_selector, ballot.question_title)
- assert has_element?(view, row_selector, "Not Published")
+ assert has_element?(view, row_selector, "Draft")
end
end
end
diff --git a/test/flick_web/live/vote/results_live_test.exs b/test/flick_web/live/vote/results_live_test.exs
new file mode 100644
index 0000000..08e7550
--- /dev/null
+++ b/test/flick_web/live/vote/results_live_test.exs
@@ -0,0 +1,87 @@
+defmodule FlickWeb.Vote.ResultsLiveTest do
+ @moduledoc """
+ Verifies the expected logic of `FlickWeb.Vote.ResultsLive`.
+
+ Vote: http://localhost:4000/ballot//results
+ """
+
+ use FlickWeb.ConnCase, async: true
+
+ alias Flick.RankedVoting.Ballot
+
+ setup ~M{conn} do
+ ballot =
+ ballot_fixture(%{
+ question_title: "What movie should we go see?",
+ possible_answers: "WarGames, The Matrix, Tron",
+ url_slug: "movie-night"
+ })
+
+ {:ok, ballot} = Flick.RankedVoting.publish_ballot(ballot)
+ populate_ballot_with_votes(ballot)
+
+ {:ok, ballot} = Flick.RankedVoting.close_ballot(ballot)
+ {:ok, view, _html} = live(conn, ~p"/ballot/movie-night/results")
+ ~M{conn, view, ballot}
+ end
+
+ test "renders results of a ballot", ~M{view, ballot} do
+ assert render(view) =~
+ "For the ballot asking #{ballot.question_title} the results in!"
+
+ assert render(view) =~ "With a total of 25 votes cast"
+ end
+
+ test "redirects any unpublished ballots", ~M{conn} do
+ unpublished_ballot = ballot_fixture()
+
+ assert {:error, {:redirect, %{to: "/", flash: flash}}} =
+ live(conn, ~p"/ballot/#{unpublished_ballot.url_slug}/results")
+
+ assert flash["error"] == "This ballot is not closed and results are unavailable."
+ end
+
+ test "redirects any non-closed ballots", ~M{conn} do
+ {:ok, published_ballot} = Flick.RankedVoting.publish_ballot(ballot_fixture())
+
+ assert {:error, {:redirect, %{to: "/", flash: flash}}} =
+ live(conn, ~p"/ballot/#{published_ballot.url_slug}/results")
+
+ assert flash["error"] == "This ballot is not closed and results are unavailable."
+ end
+
+ defp populate_ballot_with_votes(%Ballot{} = ballot) do
+ # This assumes a ballot with three possible answers.
+ for _ <- 1..25 do
+ available_answers = Ballot.possible_answers_as_list(ballot.possible_answers)
+
+ if length(available_answers) != 3 do
+ raise """
+ The ballot with the question title '#{ballot.question_title}' must have at least three possible answers. We saw '#{available_answers}'.
+ """
+ end
+
+ first_answer = Enum.random(available_answers)
+ available_answers = Enum.reject(available_answers, &(&1 == first_answer))
+ second_answer = Enum.random(available_answers)
+ available_answers = Enum.reject(available_answers, &(&1 == second_answer))
+ third_answer = Enum.random(available_answers)
+
+ # For a little variance, we'll sometime use an empty quote value for the third
+ # answer, since a user need not always provide a full ranked list answer.
+ third_answer = Enum.random([third_answer, ""])
+
+ full_name = if Enum.random(1..5) > 1, do: Faker.Person.name()
+
+ {:ok, _vote} =
+ Flick.RankedVoting.create_vote(ballot, %{
+ "full_name" => full_name,
+ "ranked_answers" => [
+ %{"value" => first_answer},
+ %{"value" => second_answer},
+ %{"value" => third_answer}
+ ]
+ })
+ end
+ end
+end
diff --git a/test/flick_web/live/vote/vote_capture_live_test.exs b/test/flick_web/live/vote/vote_capture_live_test.exs
index 9cf8daf..4e99216 100644
--- a/test/flick_web/live/vote/vote_capture_live_test.exs
+++ b/test/flick_web/live/vote/vote_capture_live_test.exs
@@ -23,7 +23,7 @@ defmodule FlickWeb.Vote.VoteCaptureLiveTest do
~M{conn, view, ballot}
end
- test "success: renders a vote form", ~M{view, ballot} do
+ test "renders a vote form", ~M{view, ballot} do
# Presents the question.
assert has_element?(view, "#question-title", ballot.question_title)
@@ -44,7 +44,7 @@ defmodule FlickWeb.Vote.VoteCaptureLiveTest do
end)
end
- test "success: can submit a form and create a vote", ~M{view} do
+ test "can submit a form and create a vote", ~M{view} do
payload = %{
"ranked_answers" => %{
"0" => %{"_persistent_id" => "0", "value" => "The Matrix"},
@@ -67,6 +67,21 @@ defmodule FlickWeb.Vote.VoteCaptureLiveTest do
# `list_votes_for_ballot/1` function.
end
+ test "redirects when ballot is an unpublished draft", ~M{conn} do
+ ballot_fixture(%{url_slug: "red-car"})
+ assert {:error, {:redirect, %{to: "/", flash: flash}}} = live(conn, ~p"/ballot/red-car")
+ assert flash["error"] == "This ballot is unpublished and can not accept votes."
+ end
+
+ test "redirects to the result page when the ballot is closed", ~M{conn} do
+ ballot = closed_ballot_fixture()
+
+ expected_url = "/ballot/#{ballot.url_slug}/results"
+
+ assert {:error, {:redirect, %{to: ^expected_url, flash: %{}}}} =
+ live(conn, ~p"/ballot/#{ballot.url_slug}")
+ end
+
defp ranked_answer_selector(index) do
"div[data-feedback-for=\"vote[ranked_answers][#{index}][value]\"]"
end
diff --git a/test/support/fixtures/ballot_fixture.ex b/test/support/fixtures/ballot_fixture.ex
index ec2338a..68caf92 100644
--- a/test/support/fixtures/ballot_fixture.ex
+++ b/test/support/fixtures/ballot_fixture.ex
@@ -15,8 +15,7 @@ defmodule Support.Fixtures.BallotFixture do
Enum.into(attrs, %{
question_title: "What day should have dinner?",
possible_answers: "Monday, Tuesday, Wednesday, Thursday, Friday",
- url_slug: "dinner-day-#{System.unique_integer()}",
- published_at: nil
+ url_slug: "dinner-day-#{System.unique_integer()}"
})
end
@@ -26,7 +25,7 @@ defmodule Support.Fixtures.BallotFixture do
When not provided, all required attributes will be generated.
"""
- @spec ballot_fixture(map()) :: {:ok, Ballot.t()}
+ @spec ballot_fixture(map()) :: Ballot.t()
def ballot_fixture(attrs \\ %{}) do
attrs = valid_ballot_attributes(attrs)
{:ok, ballot} = Flick.RankedVoting.create_ballot(attrs)
@@ -39,11 +38,26 @@ defmodule Support.Fixtures.BallotFixture do
When not provided, all required attributes will be generated.
"""
- @spec published_ballot_fixture(map()) :: {:ok, Ballot.t()}
+ @spec published_ballot_fixture(map()) :: Ballot.t()
def published_ballot_fixture(attrs \\ %{}) do
attrs = valid_ballot_attributes(attrs)
{:ok, ballot} = Flick.RankedVoting.create_ballot(attrs)
{:ok, published_ballot} = Flick.RankedVoting.publish_ballot(ballot)
published_ballot
end
+
+ @doc """
+ Creates a `Flick.RankedVoting.Ballot` entity in the `Flick.Repo` for the passed in
+ optional attributes, publishes the ballot, and then closes the ballot.
+
+ When not provided, all required attributes will be generated.
+ """
+ @spec closed_ballot_fixture(map()) :: Ballot.t()
+ def closed_ballot_fixture(attrs \\ %{}) do
+ attrs = valid_ballot_attributes(attrs)
+ {:ok, ballot} = Flick.RankedVoting.create_ballot(attrs)
+ {:ok, published_ballot} = Flick.RankedVoting.publish_ballot(ballot)
+ {:ok, closed_ballot} = Flick.RankedVoting.close_ballot(published_ballot)
+ closed_ballot
+ end
end