From 1a4ca96b0554f9bdc57ee882fe21e54adf272b25 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Mon, 30 Dec 2024 15:17:46 -0500 Subject: [PATCH 01/31] Fix typespecs. --- test/support/fixtures/ballot_fixture.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/support/fixtures/ballot_fixture.ex b/test/support/fixtures/ballot_fixture.ex index ec2338a..54054b3 100644 --- a/test/support/fixtures/ballot_fixture.ex +++ b/test/support/fixtures/ballot_fixture.ex @@ -26,7 +26,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,7 +39,7 @@ 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) From e1b00a8bfc3cb164fbfef08f1d389d6e08e3a958 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 1 Jan 2025 11:50:01 -0500 Subject: [PATCH 02/31] Add `closed_at` to ballots table and schema. Remove `published_at` from the option fields of the changeset (we want to be more limiting with these fields). --- lib/flick/ranked_voting/ballot.ex | 13 +++++++++++-- .../20250101162200_add_closed_at_to_ballots.exs | 9 +++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 priv/repo/migrations/20250101162200_add_closed_at_to_ballots.exs diff --git a/lib/flick/ranked_voting/ballot.ex b/lib/flick/ranked_voting/ballot.ex index 93410ab..763d4df 100644 --- a/lib/flick/ranked_voting/ballot.ex +++ b/lib/flick/ranked_voting/ballot.ex @@ -23,9 +23,12 @@ 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 } + # TODO Add `changeset` type. + @typedoc """ A type for the empty `Flick.RankedVoting.Ballot` struct. @@ -43,11 +46,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 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 From 92e61c1b0c437adf6f58cd4739632c9abf472d6d Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 1 Jan 2025 11:50:58 -0500 Subject: [PATCH 03/31] Fix tests to accommodate new changeset restrictions. --- lib/flick/ranked_voting.ex | 2 +- test/flick/ranked_voting_test.exs | 11 ++++++----- test/support/fixtures/ballot_fixture.ex | 3 +-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index a047973..eb674ba 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -61,7 +61,7 @@ defmodule Flick.RankedVoting do 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 diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index 2775d7f..800de80 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 @@ -194,7 +195,7 @@ defmodule Flick.RankedVotingTest do 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 diff --git a/test/support/fixtures/ballot_fixture.ex b/test/support/fixtures/ballot_fixture.ex index 54054b3..27ed72c 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 From 9ccb8d3c9a0782afe2aa8d7bfd783c374468e26c Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 1 Jan 2025 11:51:12 -0500 Subject: [PATCH 04/31] Add new closed ballot fixture for upcoming tests. --- test/support/fixtures/ballot_fixture.ex | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/support/fixtures/ballot_fixture.ex b/test/support/fixtures/ballot_fixture.ex index 27ed72c..68caf92 100644 --- a/test/support/fixtures/ballot_fixture.ex +++ b/test/support/fixtures/ballot_fixture.ex @@ -45,4 +45,19 @@ defmodule Support.Fixtures.BallotFixture do {: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 From f470259bb455f134336a19e45162042e10db2643 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 1 Jan 2025 12:01:43 -0500 Subject: [PATCH 05/31] Add restrictions and update error atom for update when trying to update a non-draft ballot. --- lib/flick/ranked_voting.ex | 30 +++++++++++++++++++++++++++--- test/flick/ranked_voting_test.exs | 9 ++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index eb674ba..0e9d646 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())} 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, :can_only_update_draft_ballots} + + 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_ballots} end @doc """ @@ -278,4 +290,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/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index 800de80..529bd85 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -188,7 +188,14 @@ 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_ballots} = + 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_ballots} = RankedVoting.update_ballot(ballot, %{title: "some new title"}) end end From 07e42f9e3893003046467977b90d80c4543ad7da Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 1 Jan 2025 12:02:16 -0500 Subject: [PATCH 06/31] Introduce `close_ballot/2` and `ballot_status/1` functions. --- lib/flick/ranked_voting.ex | 42 +++++++++++++++++++++++++++++++ test/flick/ranked_voting_test.exs | 23 +++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index 0e9d646..938672f 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -81,6 +81,48 @@ defmodule Flick.RankedVoting do {:error, :ballot_already_published} end + @spec close_ballot(Ballot.t(), DateTime.t()) :: + {:ok, Ballot.t()} + | {:error, Ecto.Changeset.t(Ballot.t())} + | {: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. """ diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index 529bd85..60866ba 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -216,6 +216,29 @@ 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 + # TODO + end + describe "list_ballots/1" do test "success: lists ballots start with zero ballots" do assert [] = RankedVoting.list_ballots() From 4db1f13c96dd78dbf72e23ee4dabf367478cbb07 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Wed, 1 Jan 2025 12:02:23 -0500 Subject: [PATCH 07/31] Early notes. --- issue-32.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 issue-32.md diff --git a/issue-32.md b/issue-32.md new file mode 100644 index 0000000..8be1035 --- /dev/null +++ b/issue-32.md @@ -0,0 +1,25 @@ +> Users should be able to close a ballot, allowing no more voting. + +Currently we have two states for a ballot. + +published and unpublished + +this is managed by the `:published_at` field which currently can be `nil`. + +Feel like we introduce a `status` field. `unpublished`, `published` and `closed`. + +Remove `published_at` from being in the normal changeset. document and test this. + +Introduce a new function to `close_ballot`. + +Enforce state changes so there can only be one direction. + +Maybe do three diferent `render` templates for the show page depending on status. + +If we don't care about when a ballot was published or closed, it makes the status easier, a simple three value enum. + +If we need to know its status and when the status changed we'd need to save a rich JSON value + +We could also just add `closed_at` and then derive a status with a funcion. + +If feels like the status of a ballot, even in this simple use is starting to outgrow being attached to the ballot directly. From 77579fc8caadb0fb580fba5d9cec9a8ef84f19a4 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Thu, 2 Jan 2025 08:46:59 -0500 Subject: [PATCH 08/31] Removing validation for field we no longer cast in the general changeset. --- lib/flick/ranked_voting/ballot.ex | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/lib/flick/ranked_voting/ballot.ex b/lib/flick/ranked_voting/ballot.ex index 763d4df..80e8dea 100644 --- a/lib/flick/ranked_voting/ballot.ex +++ b/lib/flick/ranked_voting/ballot.ex @@ -64,7 +64,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" ) @@ -79,16 +78,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. From 7f493db7d26cd5585d402e3d7685c32307bc9d45 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Thu, 2 Jan 2025 08:49:17 -0500 Subject: [PATCH 09/31] Add tests. --- test/flick/ranked_voting_test.exs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index 60866ba..0898779 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -236,7 +236,28 @@ defmodule Flick.RankedVotingTest do end describe "ballot_status/1" do - # TODO + 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 From 8586e45a09e74311b5a32b34b9d1570b2325dc46 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Thu, 2 Jan 2025 09:04:43 -0500 Subject: [PATCH 10/31] Improve seed data, adding draft and closed ballots. --- priv/repo/seeds.exs | 92 +++++++++++++++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 25 deletions(-) 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) From 8dd37935ac9ff0b5a2b9d7908a649d4caf90a5b8 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Thu, 2 Jan 2025 09:27:33 -0500 Subject: [PATCH 11/31] Improvements to the admin index page. --- lib/flick_web/live/ballots/index_live.ex | 58 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 15 deletions(-) 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 From 256bc820e75f363548d0942b9ab2b3bc5f361fc9 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Fri, 3 Jan 2025 09:35:52 -0500 Subject: [PATCH 12/31] Fix test. --- test/flick_web/live/ballots/index_live_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a69504cb961e5ad93b4c8d4d2c112cddd5442687 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Fri, 3 Jan 2025 09:36:27 -0500 Subject: [PATCH 13/31] Update vote capture page to redirect for unpublished ballots with tests. --- lib/flick_web/live/vote/vote_capture_live.ex | 27 +++++++++---------- .../live/vote/vote_capture_live_test.exs | 6 +++++ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/lib/flick_web/live/vote/vote_capture_live.ex b/lib/flick_web/live/vote/vote_capture_live.ex index ddeb759..ec6e76a 100644 --- a/lib/flick_web/live/vote/vote_capture_live.ex +++ b/lib/flick_web/live/vote/vote_capture_live.ex @@ -15,12 +15,18 @@ 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() + if ballot.published_at do + socket + |> assign(:page_title, "Vote: #{ballot.question_title}") + |> assign(:ballot, ballot) + |> assign_form() + |> ok() + else + 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 +124,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/test/flick_web/live/vote/vote_capture_live_test.exs b/test/flick_web/live/vote/vote_capture_live_test.exs index 9cf8daf..d437482 100644 --- a/test/flick_web/live/vote/vote_capture_live_test.exs +++ b/test/flick_web/live/vote/vote_capture_live_test.exs @@ -67,6 +67,12 @@ 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 + defp ranked_answer_selector(index) do "div[data-feedback-for=\"vote[ranked_answers][#{index}][value]\"]" end From c0140f307f3eae60cd28e3d9d5fc74202779e73b Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Fri, 3 Jan 2025 09:37:04 -0500 Subject: [PATCH 14/31] Update test descriptions for consistency. (We don't need `success:` for non-ok-error-tuple responses. --- test/flick_web/live/vote/vote_capture_live_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d437482..09e19b5 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"}, From 721604c19eef9451c2d901af47901effe7aadf1e Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Tue, 7 Jan 2025 09:45:03 -0500 Subject: [PATCH 15/31] Update footer copy. --- lib/flick_web/components/layouts/app.html.heex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 + From 00fa740cf4a87836ff305e93b7680b148485a151 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Tue, 7 Jan 2025 09:45:27 -0500 Subject: [PATCH 16/31] Add new domain function `count_votes_for_ballot_id/1` and tests. --- lib/flick/ranked_voting.ex | 11 +++++++ test/flick/ranked_voting_test.exs | 54 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index 938672f..1927b70 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -255,6 +255,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. diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index 0898779..f7d39b0 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -525,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 = From 15602c67791c8b50b1da5f887191eff449cb5380 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Tue, 7 Jan 2025 09:45:43 -0500 Subject: [PATCH 17/31] First pass at a new results page. --- lib/flick_web/live/vote/results_live.ex | 48 ++++++++++ lib/flick_web/router.ex | 3 +- .../flick_web/live/vote/results_live_test.exs | 88 +++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 lib/flick_web/live/vote/results_live.ex create mode 100644 test/flick_web/live/vote/results_live_test.exs 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..fc55d5e --- /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/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/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..d0cec00 --- /dev/null +++ b/test/flick_web/live/vote/results_live_test.exs @@ -0,0 +1,88 @@ +defmodule FlickWeb.Vote.VoteCaptureLiveTest 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{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 From 3dcb27297939423555cf53942dcedfd9359cdbb3 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Fri, 10 Jan 2025 09:38:57 -0500 Subject: [PATCH 18/31] More design progress. --- lib/flick_web/components/core_components.ex | 9 +- lib/flick_web/live/ballots/viewer_live.ex | 282 +++++++++++++++----- 2 files changed, 217 insertions(+), 74 deletions(-) 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/live/ballots/viewer_live.ex b/lib/flick_web/live/ballots/viewer_live.ex index db5715f..13b82b5 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,33 +123,86 @@ 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"""

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 - -
@@ -169,58 +234,124 @@ defmodule FlickWeb.Ballots.ViewerLive do

Votes ({length(@votes)})

+
+ """ + end - <.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)} - - + attr :ballot_results_report, :list, required: true + attr :votes, :list, required: true + attr :title, :string, default: "Results" + + defp vote_results(assigns) do + ~H""" +
+

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

+
    + <%= for %{points: points, value: answer} <- @ballot_results_report do %> +
  1. {answer}: {points} points
  2. + <% end %> +
+
+ """ + end + + attr :ballot, Ballot, required: true + attr :votes, :list, required: true + attr :vote_forms, :map, required: true + + 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"> +
+ {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 (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 + + 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 +375,9 @@ 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 end From ec18ff33878be95e45effdab83c464f6b7bd4cab Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:29:18 -0500 Subject: [PATCH 19/31] Fix module name. --- test/flick_web/live/vote/results_live_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/flick_web/live/vote/results_live_test.exs b/test/flick_web/live/vote/results_live_test.exs index d0cec00..caf096a 100644 --- a/test/flick_web/live/vote/results_live_test.exs +++ b/test/flick_web/live/vote/results_live_test.exs @@ -1,4 +1,4 @@ -defmodule FlickWeb.Vote.VoteCaptureLiveTest do +defmodule FlickWeb.Vote.ResultsLiveTest do @moduledoc """ Verifies the expected logic of `FlickWeb.Vote.ResultsLive`. From a0afbe92a6f25847244ae7ce77f87d7779be8363 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:29:25 -0500 Subject: [PATCH 20/31] Fallback for `nil` description. --- lib/flick_web/live/ballots/viewer_live.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/flick_web/live/ballots/viewer_live.ex b/lib/flick_web/live/ballots/viewer_live.ex index 13b82b5..3ea38fc 100644 --- a/lib/flick_web/live/ballots/viewer_live.ex +++ b/lib/flick_web/live/ballots/viewer_live.ex @@ -380,4 +380,6 @@ defmodule FlickWeb.Ballots.ViewerLive do {:ok, html_doc, _deprecation_messages} = Earmark.as_html(description) html_doc end + + defp rendered_description(_description), do: nil end From b8841bb16879b17fcdcb8df7ad699acb9f9019da Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:48:51 -0500 Subject: [PATCH 21/31] Make headline less loud. --- lib/flick_web/live/vote/results_live.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/flick_web/live/vote/results_live.ex b/lib/flick_web/live/vote/results_live.ex index fc55d5e..f4371c0 100644 --- a/lib/flick_web/live/vote/results_live.ex +++ b/lib/flick_web/live/vote/results_live.ex @@ -31,7 +31,7 @@ defmodule FlickWeb.Vote.ResultsLive do def render(assigns) do ~H"""
-

Ballot Results

+

Ballot Results

For the ballot asking {@ballot.question_title} the results in!

From edbd601d14e1c71153047f1280df0d1c0d8c05f1 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:49:09 -0500 Subject: [PATCH 22/31] Add redirect logic for vote form when closed. --- lib/flick_web/live/vote/vote_capture_live.ex | 29 ++++++++++++------- .../live/vote/vote_capture_live_test.exs | 9 ++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/flick_web/live/vote/vote_capture_live.ex b/lib/flick_web/live/vote/vote_capture_live.ex index ec6e76a..fe07979 100644 --- a/lib/flick_web/live/vote/vote_capture_live.ex +++ b/lib/flick_web/live/vote/vote_capture_live.ex @@ -15,17 +15,24 @@ defmodule FlickWeb.Vote.VoteCaptureLive do %{"url_slug" => url_slug} = params ballot = RankedVoting.get_ballot_by_url_slug!(url_slug) - if ballot.published_at do - socket - |> assign(:page_title, "Vote: #{ballot.question_title}") - |> assign(:ballot, ballot) - |> assign_form() - |> ok() - else - socket - |> put_flash(:error, "This ballot is unpublished and can not accept votes.") - |> redirect(to: ~p"/") - |> 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 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 09e19b5..4e99216 100644 --- a/test/flick_web/live/vote/vote_capture_live_test.exs +++ b/test/flick_web/live/vote/vote_capture_live_test.exs @@ -73,6 +73,15 @@ defmodule FlickWeb.Vote.VoteCaptureLiveTest do 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 From 7a03c5983b46ad72b928ff740d923e665150dfdf Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:55:21 -0500 Subject: [PATCH 23/31] Add changeset type. --- lib/flick/ranked_voting.ex | 10 +++++----- lib/flick/ranked_voting/ballot.ex | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index 1927b70..c7c6cbe 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -15,7 +15,7 @@ defmodule Flick.RankedVoting do 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) @@ -35,7 +35,7 @@ defmodule Flick.RankedVoting do """ @spec update_ballot(Ballot.t(), map()) :: {:ok, Ballot.t()} - | {:error, Ecto.Changeset.t(Ballot.t())} + | {:error, Ballot.changeset()} | {:error, :can_only_update_draft_ballots} def update_ballot(%Ballot{published_at: nil, closed_at: nil} = ballot, attrs) do @@ -67,7 +67,7 @@ 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()) @@ -83,7 +83,7 @@ defmodule Flick.RankedVoting do @spec close_ballot(Ballot.t(), DateTime.t()) :: {:ok, Ballot.t()} - | {:error, Ecto.Changeset.t(Ballot.t())} + | {:error, Ballot.changeset()} | {:error, :ballot_not_published} def close_ballot(ballot, closed_at \\ DateTime.utc_now()) @@ -179,7 +179,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 diff --git a/lib/flick/ranked_voting/ballot.ex b/lib/flick/ranked_voting/ballot.ex index 80e8dea..cd3c9d3 100644 --- a/lib/flick/ranked_voting/ballot.ex +++ b/lib/flick/ranked_voting/ballot.ex @@ -27,7 +27,10 @@ defmodule Flick.RankedVoting.Ballot do closed_at: DateTime.t() | nil } - # TODO Add `changeset` type. + @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. From 26fe9d6d7a391b803d40b052028a2fd8b7ba3861 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:57:22 -0500 Subject: [PATCH 24/31] Turn off `Credo.Check.Refactor.VariableRebinding`. --- .credo.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`. From 6a4dfed9866e2659a90d60e300ca167cba6ff8ab Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 17:59:59 -0500 Subject: [PATCH 25/31] Make credo happy. --- test/flick_web/live/vote/results_live_test.exs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/flick_web/live/vote/results_live_test.exs b/test/flick_web/live/vote/results_live_test.exs index caf096a..08e7550 100644 --- a/test/flick_web/live/vote/results_live_test.exs +++ b/test/flick_web/live/vote/results_live_test.exs @@ -50,8 +50,7 @@ defmodule FlickWeb.Vote.ResultsLiveTest do assert flash["error"] == "This ballot is not closed and results are unavailable." end - defp populate_ballot_with_votes(%Ballot{published_at: published_at} = ballot) - when not is_nil(published_at) do + 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) From 377c3e41b9df82abac5e61de35ce35e3886bd900 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 18:00:41 -0500 Subject: [PATCH 26/31] Remove early notes. --- issue-32.md | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 issue-32.md diff --git a/issue-32.md b/issue-32.md deleted file mode 100644 index 8be1035..0000000 --- a/issue-32.md +++ /dev/null @@ -1,25 +0,0 @@ -> Users should be able to close a ballot, allowing no more voting. - -Currently we have two states for a ballot. - -published and unpublished - -this is managed by the `:published_at` field which currently can be `nil`. - -Feel like we introduce a `status` field. `unpublished`, `published` and `closed`. - -Remove `published_at` from being in the normal changeset. document and test this. - -Introduce a new function to `close_ballot`. - -Enforce state changes so there can only be one direction. - -Maybe do three diferent `render` templates for the show page depending on status. - -If we don't care about when a ballot was published or closed, it makes the status easier, a simple three value enum. - -If we need to know its status and when the status changed we'd need to save a rich JSON value - -We could also just add `closed_at` and then derive a status with a funcion. - -If feels like the status of a ballot, even in this simple use is starting to outgrow being attached to the ballot directly. From f64187d3f3b5a4deafddafd19cf4a71810ee9fc4 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 18:01:46 -0500 Subject: [PATCH 27/31] Prefer singular. --- lib/flick/ranked_voting.ex | 4 ++-- test/flick/ranked_voting_test.exs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index c7c6cbe..b4ff6ec 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -36,7 +36,7 @@ defmodule Flick.RankedVoting do @spec update_ballot(Ballot.t(), map()) :: {:ok, Ballot.t()} | {:error, Ballot.changeset()} - | {:error, :can_only_update_draft_ballots} + | {: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) @@ -48,7 +48,7 @@ defmodule Flick.RankedVoting do end def update_ballot(_ballot, _attrs) do - {:error, :can_only_update_draft_ballots} + {:error, :can_only_update_draft_ballot} end @doc """ diff --git a/test/flick/ranked_voting_test.exs b/test/flick/ranked_voting_test.exs index f7d39b0..f97367a 100644 --- a/test/flick/ranked_voting_test.exs +++ b/test/flick/ranked_voting_test.exs @@ -188,14 +188,14 @@ defmodule Flick.RankedVotingTest do test "failure: can not update a published ballot" do ballot = published_ballot_fixture() - assert {:error, :can_only_update_draft_ballots} = + 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_ballots} = + assert {:error, :can_only_update_draft_ballot} = RankedVoting.update_ballot(ballot, %{title: "some new title"}) end end From 0c3ddb02a9925b0e0c6ecde058f550c566c1e97e Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 18:03:40 -0500 Subject: [PATCH 28/31] Add docs. --- lib/flick/ranked_voting.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/flick/ranked_voting.ex b/lib/flick/ranked_voting.ex index b4ff6ec..1e9f493 100644 --- a/lib/flick/ranked_voting.ex +++ b/lib/flick/ranked_voting.ex @@ -81,6 +81,12 @@ 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()} From 158d3bfb4b3f52bda770bab4b556b2c79a47b69f Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 18:24:32 -0500 Subject: [PATCH 29/31] Can no longer edit the weight for a closed ballot. --- lib/flick_web/live/ballots/viewer_live.ex | 141 ++++++++++++---------- 1 file changed, 77 insertions(+), 64 deletions(-) diff --git a/lib/flick_web/live/ballots/viewer_live.ex b/lib/flick_web/live/ballots/viewer_live.ex index 3ea38fc..02e1805 100644 --- a/lib/flick_web/live/ballots/viewer_live.ex +++ b/lib/flick_web/live/ballots/viewer_live.ex @@ -200,40 +200,23 @@ defmodule FlickWeb.Ballots.ViewerLive do def render(assigns) do ~H""" -
-
-

Ballot Admin

-
- -
- <%= 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 - <% end %> -
- -
-

Vote Results

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

Votes ({length(@votes)})

-
+ <.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 @@ -257,44 +240,23 @@ defmodule FlickWeb.Ballots.ViewerLive do attr :ballot, Ballot, required: true attr :votes, :list, required: true - attr :vote_forms, :map, required: true + attr :vote_forms, :map, default: nil defp votes_table(assigns) do + dbg(assigns) + ~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"> -
- {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 - - + <.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} @@ -340,6 +302,57 @@ defmodule FlickWeb.Ballots.ViewerLive do """ 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"""
From 01d12288ec15e0655390461f9957f55205e30783 Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 18:25:06 -0500 Subject: [PATCH 30/31] Remove debug. --- lib/flick_web/live/ballots/viewer_live.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/flick_web/live/ballots/viewer_live.ex b/lib/flick_web/live/ballots/viewer_live.ex index 02e1805..9f396ec 100644 --- a/lib/flick_web/live/ballots/viewer_live.ex +++ b/lib/flick_web/live/ballots/viewer_live.ex @@ -243,8 +243,6 @@ defmodule FlickWeb.Ballots.ViewerLive do attr :vote_forms, :map, default: nil defp votes_table(assigns) do - dbg(assigns) - ~H""" <.table id="votes" rows={@votes} row_id={&"vote-row-#{&1.id}"}> <:col :let={vote} label="Name"> From d3c2eae7d62f7b464ce09ca5d277aa34a11a187c Mon Sep 17 00:00:00 2001 From: Mike Zornek Date: Sat, 11 Jan 2025 18:46:43 -0500 Subject: [PATCH 31/31] Update check to allow `1`. Will revisit in #117. --- .github/workflows/code-quality.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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
{col[:label]} + <%= if Map.get(col, :title, nil) do %> + {col.label} + <% else %> + {col[:label]} + <% end %> + {gettext("Actions")}