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 - + 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 +
+ {ballot.question_title} +
+ +
+ <.link href={~p"/ballot/#{ballot.url_slug}"} class="underline"> + Voting Page + + • + <.link href={~p"/ballot/#{ballot.url_slug}/#{ballot.secret}"} class="underline"> + Ballot Admin Page + +
- <:col :let={ballot}> - <.link href={~p"/ballot/#{ballot.url_slug}/#{ballot.secret}"}>Ballot Details + <:col :let={ballot} label="Status"> +
{status_label(ballot)}
""" end + + defp status_label(%Ballot{} = ballot) do + case Flick.RankedVoting.ballot_status(ballot) do + :closed -> "Closed" + :published -> "Published" + :draft -> "Draft" + end + end + + defp status_date_label(%Ballot{} = ballot) do + case Flick.RankedVoting.ballot_status(ballot) do + :closed -> "Closed on #{ballot.closed_at}" + :published -> "Published on #{ballot.published_at}" + :draft -> "Created on #{ballot.inserted_at}" + end + end end diff --git a/lib/flick_web/live/ballots/viewer_live.ex b/lib/flick_web/live/ballots/viewer_live.ex index db5715f..9f396ec 100644 --- a/lib/flick_web/live/ballots/viewer_live.ex +++ b/lib/flick_web/live/ballots/viewer_live.ex @@ -47,6 +47,18 @@ defmodule FlickWeb.Ballots.ViewerLive do end end + def handle_event("close", _params, socket) do + %{ballot: ballot} = socket.assigns + + case RankedVoting.close_ballot(ballot) do + {:ok, ballot} -> + {:noreply, assign(socket, :ballot, ballot)} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Could not close ballot.")} + end + end + def handle_event("present-inline-editor", %{"vote-id" => vote_id}, socket) do vote = assert_vote_id(socket, vote_id) @@ -111,116 +123,246 @@ defmodule FlickWeb.Ballots.ViewerLive do end @impl Phoenix.LiveView + def render(%{ballot: %Ballot{published_at: nil}} = assigns) do + ~H""" + <.shared_header /> + +
+

Edit Ballot

+

Your ballot is not published and can still be edited.

+
+ +
+
+
Question Title
+
{@ballot.question_title}
+
Description
+ +
+ <%!-- FIXME: Consider the security implications of `raw`. --%> + <%!-- https://github.com/zorn/flick/issues/77 --%> + {raw(rendered_description(@ballot.description))} +
+
Possible Answers
+
{@ballot.possible_answers}
+
URL Slug
+
{@ballot.url_slug}
+
+ <.link + :if={RankedVoting.can_update_ballot?(@ballot)} + navigate={~p"/ballot/#{@ballot.url_slug}/#{@ballot.secret}/edit"} + class="text-white no-underline" + > + <.button id="edit-ballot-button"> + Edit Ballot + + +
+ +
+

Publish Ballot

+

Once you are satisfied with your ballot, hit the publish button below.

+ + <.button phx-click="publish" id="publish-ballot-button">Publish Ballot +
+ """ + end + + def render(%{ballot: %Ballot{closed_at: nil}} = assigns) do + ~H""" + <.shared_header /> + +
+

Ballot is Published!

+

Your ballot is published. Use the URL below to invite people to vote!

+ <.link navigate={~p"/ballot/#{@ballot.url_slug}"}> + {URI.append_path(@socket.host_uri, "/ballot/#{@ballot.url_slug}")} + + +

+ When you no longer want to accept votes close the ballot using the button below. +

+ + <.button phx-click="close" id="close-ballot-button">Close Ballot +
+ +
+ <.vote_results + ballot_results_report={@ballot_results_report} + votes={@votes} + title="Early Results" + /> + + <.votes_table ballot={@ballot} votes={@votes} vote_forms={@vote_forms} /> +
+ """ + end + def render(assigns) do + ~H""" + <.shared_header /> + +
+

Ballot is Closed

+

Your ballot is closed. Result totals can be viewed by everyone at:

+ <.link navigate={~p"/ballot/#{@ballot.url_slug}/results"}> + {URI.append_path(@socket.host_uri, "/ballot/#{@ballot.url_slug}/results")} + +
+
+ <.vote_results + ballot_results_report={@ballot_results_report} + votes={@votes} + title="Final Results" + /> + + <.votes_table ballot={@ballot} votes={@votes} /> +
+ """ + end + + attr :ballot_results_report, :list, required: true + attr :votes, :list, required: true + attr :title, :string, default: "Results" + + defp vote_results(assigns) do ~H"""
-
-

Ballot Admin

- -
-
Question Title
-
{@ballot.question_title}
-
Description
- <%!-- FIXME: Showing this inline feels wrong. - Revisit how we render this "preview" of the ballot. --%> -
{@ballot.description}
-
Possible Answers
-
{@ballot.possible_answers}
-
URL Slug
-
{@ballot.url_slug}
-
- <.link - :if={RankedVoting.can_update_ballot?(@ballot)} - navigate={~p"/ballot/#{@ballot.url_slug}/#{@ballot.secret}/edit"} - class="text-white no-underline" - > - <.button id="edit-ballot-button"> - Edit Ballot - - -
- -
- <%= if @ballot.published_at do %> -
-

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}")} - -

-
- <% else %> - <.button phx-click="publish" id="publish-ballot-button">Publish +

{@title} ({length(@votes)} votes received)

+
    + <%= for %{points: points, value: answer} <- @ballot_results_report do %> +
  1. {answer}: {points} points
  2. <% end %> -
- -
-

Vote Results

-
    - <%= for %{points: points, value: answer} <- @ballot_results_report do %> -
  1. {answer}: {points} points
  2. - <% end %> -
-
- -
-

Votes ({length(@votes)})

-
- - <.table id="votes" rows={@votes} row_id={&"vote-row-#{&1.id}"}> - <:col :let={vote} label="Name"> - {vote.full_name || "No Name"} - - <:col :let={vote} label="Weight"> -
- {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 - -
- <.form - :let={form} - :if={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 - - - - <:col :let={vote} :if={show_answer(@ballot, 0)} label="First Preference (5pts)"> - {answer_at_index(vote, 0)} - - <:col :let={vote} :if={show_answer(@ballot, 1)} label="Second Preference (4pts)"> - {answer_at_index(vote, 1)} - - <:col :let={vote} :if={show_answer(@ballot, 2)} label="Third Preference (3pts)"> - {answer_at_index(vote, 2)} - - <:col :let={vote} :if={show_answer(@ballot, 3)} label="Fourth Preference (2pts)"> - {answer_at_index(vote, 3)} - - <:col :let={vote} :if={show_answer(@ballot, 4)} label="Fifth Preference (1pt)"> - {answer_at_index(vote, 4)} - - + +
+ """ + end + + attr :ballot, Ballot, required: true + attr :votes, :list, required: true + attr :vote_forms, :map, default: nil + + defp votes_table(assigns) do + ~H""" + <.table id="votes" rows={@votes} row_id={&"vote-row-#{&1.id}"}> + <:col :let={vote} label="Name"> + {vote.full_name || "No Name"} + + <:col :let={vote} label="Weight"> + <.weight_label + vote={vote} + vote_forms={@vote_forms} + enable_inline_editor={!is_nil(@vote_forms)} + /> + <.weight_form vote={vote} vote_forms={@vote_forms} /> + + <:col + :let={vote} + :if={show_answer(@ballot, 0)} + label="First (5pts)" + title="First Preference is worth 5pts." + > + {answer_at_index(vote, 0)} + + <:col + :let={vote} + :if={show_answer(@ballot, 1)} + label="Second (4pts)" + title="Second Preference is worth 4pts." + > + {answer_at_index(vote, 1)} + + <:col + :let={vote} + :if={show_answer(@ballot, 2)} + label="Third (3pts)" + title="Third Preference is worth 3pts." + > + {answer_at_index(vote, 2)} + + <:col + :let={vote} + :if={show_answer(@ballot, 3)} + label="Fourth (2pts)" + title="Fourth Preference is worth 2pts." + > + {answer_at_index(vote, 3)} + + <:col + :let={vote} + :if={show_answer(@ballot, 4)} + label="Fifth (1pt)" + title="Fifth Preference is worth 1pt." + > + {answer_at_index(vote, 4)} + + + """ + end + + attr :vote, Vote, required: true + attr :vote_forms, :map, required: true + attr :enable_inline_editor, :boolean, default: true + + defp weight_label(%{enable_inline_editor: false} = assigns) do + ~H""" +
{@vote.weight}
+ """ + end + + defp weight_label(assigns) do + ~H""" +
+ {@vote.weight}   + <.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 %> +
  1. {answer}: {points} points
  2. + <% 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
{col[:label]} + <%= if Map.get(col, :title, nil) do %> + {col.label} + <% else %> + {col[:label]} + <% end %> + {gettext("Actions")}