Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to close a ballot from accepting new votes #109

Merged
merged 32 commits into from
Jan 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1a4ca96
Fix typespecs.
zorn Dec 30, 2024
92229f3
Merge branch 'main' into issue-32-add-closed-ballots
zorn Jan 1, 2025
e1b00a8
Add `closed_at` to ballots table and schema. Remove `published_at` fr…
zorn Jan 1, 2025
92e61c1
Fix tests to accommodate new changeset restrictions.
zorn Jan 1, 2025
9ccb8d3
Add new closed ballot fixture for upcoming tests.
zorn Jan 1, 2025
f470259
Add restrictions and update error atom for update when trying to upda…
zorn Jan 1, 2025
07e42f9
Introduce `close_ballot/2` and `ballot_status/1` functions.
zorn Jan 1, 2025
4db1f13
Early notes.
zorn Jan 1, 2025
77579fc
Removing validation for field we no longer cast in the general change…
zorn Jan 2, 2025
7f493db
Add tests.
zorn Jan 2, 2025
8586e45
Improve seed data, adding draft and closed ballots.
zorn Jan 2, 2025
8dd3793
Improvements to the admin index page.
zorn Jan 2, 2025
256bc82
Fix test.
zorn Jan 3, 2025
a69504c
Update vote capture page to redirect for unpublished ballots with tests.
zorn Jan 3, 2025
c0140f3
Update test descriptions for consistency. (We don't need `success:` f…
zorn Jan 3, 2025
721604c
Update footer copy.
zorn Jan 7, 2025
00fa740
Add new domain function `count_votes_for_ballot_id/1` and tests.
zorn Jan 7, 2025
15602c6
First pass at a new results page.
zorn Jan 7, 2025
3dcb272
More design progress.
zorn Jan 10, 2025
ec18ff3
Fix module name.
zorn Jan 11, 2025
a0afbe9
Fallback for `nil` description.
zorn Jan 11, 2025
b8841bb
Make headline less loud.
zorn Jan 11, 2025
edbd601
Add redirect logic for vote form when closed.
zorn Jan 11, 2025
7a03c59
Add changeset type.
zorn Jan 11, 2025
26fe9d6
Turn off `Credo.Check.Refactor.VariableRebinding`.
zorn Jan 11, 2025
6a4dfed
Make credo happy.
zorn Jan 11, 2025
377c3e4
Remove early notes.
zorn Jan 11, 2025
f64187d
Prefer singular.
zorn Jan 11, 2025
0c3ddb0
Add docs.
zorn Jan 11, 2025
158d3bf
Can no longer edit the weight for a closed ballot.
zorn Jan 11, 2025
01d1228
Remove debug.
zorn Jan 11, 2025
d3c2eae
Update check to allow `1`. Will revisit in #117.
zorn Jan 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.UtcNowTruncate, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Refactor.WithClauses, []},

#
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/code-quality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 91 additions & 8 deletions lib/flick/ranked_voting.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 """
Expand All @@ -55,20 +67,68 @@ 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

def publish_ballot(_ballot, _published_at) 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.
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
27 changes: 14 additions & 13 deletions lib/flick/ranked_voting/ballot.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -43,19 +49,24 @@ 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
ballot
|> 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"
)
Expand All @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion lib/flick_web/components/core_components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -480,7 +481,13 @@ defmodule FlickWeb.CoreComponents do
<table class="w-[40rem] sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">{col[:label]}</th>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal">
<%= if Map.get(col, :title, nil) do %>
<span title={col.title}>{col.label}</span>
<% else %>
{col[:label]}
<% end %>
</th>
<th :if={@action != []} class="relative p-0 pb-4">
<span class="sr-only">{gettext("Actions")}</span>
</th>
Expand Down
4 changes: 2 additions & 2 deletions lib/flick_web/components/layouts/app.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@
<a href="https://github.com/zorn/flick" class="underline">
GitHub Project
</a>
&bull; <a href="mailto:[email protected]" class="underline">Contact</a>
</FlickWeb.UI.page_column>.
&bull; <a href="mailto:[email protected]" class="underline">Contact Site Admin</a>
</FlickWeb.UI.page_column>
</section>
58 changes: 43 additions & 15 deletions lib/flick_web/live/ballots/index_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,28 +21,54 @@ defmodule FlickWeb.Ballots.IndexLive do
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<div class="prose">
<p>Admin: Ballots</p>
<div class="prose mb-4">
<h2>Administration</h2>

<p>
The following page is a list of all ballots in the system, allowing an authenticated admin to quickly see and link to each page.
</p>

<p>Only authenticated admins can see this page.</p>

<h2>Ballots</h2>
</div>

<.table id="ballots" rows={@ballots} row_id={&"ballot-row-#{&1.id}"}>
<:col :let={ballot} label="Title">
{ballot.question_title}
</:col>
<:col :let={ballot} label="Published">
<%= if ballot.published_at do %>
{ballot.published_at}
<% else %>
Not Published
<% end %>
</:col>
<:col :let={ballot}>
<.link :if={ballot.published_at} href={~p"/ballot/#{ballot.url_slug}"}>Voting Page</.link>
<div class="text-lg mb-4">
{ballot.question_title}
</div>

<div class="font-normal">
<.link href={~p"/ballot/#{ballot.url_slug}"} class="underline">
Voting Page
</.link>
&bull;
<.link href={~p"/ballot/#{ballot.url_slug}/#{ballot.secret}"} class="underline">
Ballot Admin Page
</.link>
</div>
</:col>
<:col :let={ballot}>
<.link href={~p"/ballot/#{ballot.url_slug}/#{ballot.secret}"}>Ballot Details</.link>
<:col :let={ballot} label="Status">
<div title={"#{status_date_label(ballot)}"}>{status_label(ballot)}</div>
</:col>
</.table>
"""
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
Loading
Loading