diff --git a/.gitignore b/.gitignore index 899f4fa..62755bd 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ npm-debug.log /assets/node_modules/ .env envs/ + +# VSCode configuration +/.vscode/ diff --git a/README.md b/README.md index a2d3a82..bfeb501 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,36 @@ -# cklist +# ✔️ cklist -Awesome checklists are about to come here. Stay tuned. +Awesome checklists are about to come here. Stay tuned... -## Contributing +## 🤝 Contributing -Contributions are welcome. +We are just getting started. Feel free to reach out if you feel like joining. -- cklists are based on the awesome [Phoenix Framework](https://www.phoenixframework.org/). -- Elixir & Erlang versions are managed with [asdf](https://asdf-vm.com/) in [.tool-versions](.tool-versions). -- We use [PostgreSQL](https://www.postgresql.org/) as database backend. For local development, we assume a user `cklist` exists (see [config/dev.exs](./config/dev.exs)). The authentication system makes use of the `citext` extension of PostgreSQL. If DB migration complains about missing the `citext` extension, try search for and installing the `postgres-contrib` package. +### Setup notes + +#### Framework + +cklists are based on the awesome [Phoenix Framework](https://www.phoenixframework.org/). + +#### Versions + +Elixir, Erlang, and npm versions are managed with [asdf](https://asdf-vm.com/) in [.tool-versions](.tool-versions). + +#### Database + +We use [PostgreSQL](https://www.postgresql.org/) as database backend. + +
+Database details + +Phoenix' authentication system makes use of the `citext` extension of PostgreSQL. If DB migration complains about missing the `citext` extension, try search for and installing the `postgres-contrib` package. + +Database secrets can be stored in `.env`-style configuration. This is useful for both, local development and deployments. To override the database configuration, put the following contents + +```none +DATABASE_URL="ecto://cklist:cklist@localhost/cklist_dev" +TEST_DATABASE_URL="ecto://cklist:cklist@localhost/cklist_test" +``` + +in a file `envs/.env` and adapt the defaults to your liking. +
diff --git a/lib/cklist/accounts/user.ex b/lib/cklist/accounts/user.ex index 9447257..0c42bb7 100644 --- a/lib/cklist/accounts/user.ex +++ b/lib/cklist/accounts/user.ex @@ -8,6 +8,8 @@ defmodule Cklist.Accounts.User do field :hashed_password, :string, redact: true field :confirmed_at, :naive_datetime + has_many :checklists, Cklist.Checklists.Checklist + timestamps(type: :utc_datetime) end diff --git a/lib/cklist/checklists.ex b/lib/cklist/checklists.ex new file mode 100644 index 0000000..1fb159e --- /dev/null +++ b/lib/cklist/checklists.ex @@ -0,0 +1,149 @@ +defmodule Cklist.Checklists do + @moduledoc """ + The Checklists context. + """ + + import Ecto.Query, warn: false + alias Cklist.Checklists.Run + alias Cklist.Checklists.Activity + alias Cklist.Checklists.Checklist + alias Cklist.Repo + + @doc """ + Returns the list of checklists. + + ## Examples + + iex> list_checklists() + [%Checklist{}, ...] + + """ + def list_checklists(nil) do + query = from l in Checklist, where: l.access == :public + Repo.all(query) + end + def list_checklists(user) do + query = from l in Checklist, where: l.user_id == ^user.id or l.access == :public + Repo.all(query) + end + + + @doc """ + Gets a single checklist. + + Raises `Ecto.NoResultsError` if the Checklist does not exist. + + ## Examples + + iex> get_checklist!(123) + %Checklist{} + + iex> get_checklist!(456) + ** (Ecto.NoResultsError) + + """ + def get_checklist!(id), do: Repo.get!(Checklist, id) + + @doc """ + Creates a checklist. + + ## Examples + + iex> create_checklist(%{field: value}) + {:ok, %Checklist{}} + + iex> create_checklist(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_checklist(attrs \\ %{}) do + %Checklist{} + |> Checklist.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Inserts a checklist + """ + def insert_checklist(changeset) do + Repo.insert(changeset) + end + + @doc """ + Updates a checklist. + + ## Examples + + iex> update_checklist(checklist, %{field: new_value}) + {:ok, %Checklist{}} + + iex> update_checklist(checklist, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_checklist(%Checklist{} = checklist, attrs) do + checklist + |> Checklist.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a checklist. + + ## Examples + + iex> delete_checklist(checklist) + {:ok, %Checklist{}} + + iex> delete_checklist(checklist) + {:error, %Ecto.Changeset{}} + + """ + def delete_checklist(%Checklist{} = checklist) do + Repo.delete(checklist) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking checklist changes. + + ## Examples + + iex> change_checklist(checklist) + %Ecto.Changeset{data: %Checklist{}} + + """ + def change_checklist(%Checklist{} = checklist, attrs \\ %{}) do + Checklist.changeset(checklist, attrs) + end + + def log_run_start(checklist, user) do + {:ok, run} = %Run{} + |> Run.changeset(%{checklist_id: checklist.id}) + |> Repo.insert() + + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "checklist_start"}}) + |> Repo.insert() + + # return current run + run + end + + def log_run_abort(run, user) do + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "checklist_abort"}}) + |> Repo.insert() + end + + def log_run_complete(run, user) do + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "checklist_complete"}}) + |> Repo.insert() + end + + def log_step_complete(run, user, step_id, is_done \\ true) do + %Activity{} + |> Activity.changeset(%{run_id: run.id, user_id: user.id, event: %{type: "step_done", step_id: step_id, done: is_done}}) + |> Repo.insert() + end +end diff --git a/lib/cklist/checklists/activity.ex b/lib/cklist/checklists/activity.ex new file mode 100644 index 0000000..2923100 --- /dev/null +++ b/lib/cklist/checklists/activity.ex @@ -0,0 +1,22 @@ +defmodule Cklist.Checklists.Activity do + use Ecto.Schema + import Ecto.Changeset + + schema "activity" do + # We assume events have a type. Any other properties are optional and type-dependent. + field :event, :map + + belongs_to :run, Cklist.Checklists.Run + belongs_to :user, Cklist.Accounts.User + + # We don't expect activities to be modified. + timestamps([type: :utc_datetime, updated_at: false]) + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [:event, :run_id, :user_id]) + |> validate_required([:event, :run_id, :user_id]) + end +end diff --git a/lib/cklist/checklists/checklist.ex b/lib/cklist/checklists/checklist.ex new file mode 100644 index 0000000..30108e7 --- /dev/null +++ b/lib/cklist/checklists/checklist.ex @@ -0,0 +1,23 @@ +defmodule Cklist.Checklists.Checklist do + use Ecto.Schema + import Ecto.Changeset + + schema "checklists" do + field :title, :string + field :description, :string + field :access, Ecto.Enum, values: [:public, :personal] + field :document, :map + + timestamps(type: :utc_datetime) + + belongs_to :user, Cklist.Accounts.User + has_many :runs, Cklist.Checklists.Run, on_delete: :delete_all + end + + @doc false + def changeset(checklist, attrs) do + checklist + |> cast(attrs, [:title, :description, :access, :document, :user_id]) + |> validate_required([:title, :description, :access, :document, :user_id]) + end +end diff --git a/lib/cklist/checklists/run.ex b/lib/cklist/checklists/run.ex new file mode 100644 index 0000000..b5f4651 --- /dev/null +++ b/lib/cklist/checklists/run.ex @@ -0,0 +1,16 @@ +defmodule Cklist.Checklists.Run do + use Ecto.Schema + import Ecto.Changeset + + schema "runs" do + belongs_to :checklist, Cklist.Checklists.Checklist + has_many :activities, Cklist.Checklists.Activity, on_delete: :delete_all + end + + @doc false + def changeset(run, attrs) do + run + |> cast(attrs, [:checklist_id]) + |> validate_required([:checklist_id]) + end +end diff --git a/lib/cklist_web.ex b/lib/cklist_web.ex index 1c5c75b..3351dce 100644 --- a/lib/cklist_web.ex +++ b/lib/cklist_web.ex @@ -85,6 +85,7 @@ defmodule CklistWeb do import Phoenix.HTML # Core UI components and translation import CklistWeb.CoreComponents + import CklistWeb.MyComponents import Cklist.Gettext # Shortcut for generating JS commands diff --git a/lib/cklist_web/components/core_components.ex b/lib/cklist_web/components/core_components.ex index eed9736..f6d94cb 100644 --- a/lib/cklist_web/components/core_components.ex +++ b/lib/cklist_web/components/core_components.ex @@ -501,7 +501,7 @@ defmodule CklistWeb.CoreComponents do - +
diff --git a/lib/cklist_web/components/layouts/root.html.heex b/lib/cklist_web/components/layouts/root.html.heex index 8e2ac1d..5837573 100644 --- a/lib/cklist_web/components/layouts/root.html.heex +++ b/lib/cklist_web/components/layouts/root.html.heex @@ -12,47 +12,6 @@ -
    - <%= if @current_users do %> -
  • - <%= @current_users.email %> -
  • -
  • - <.link - href={~p"/users/settings"} - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Settings - -
  • -
  • - <.link - href={~p"/users/log_out"} - method="delete" - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Log out - -
  • - <% else %> -
  • - <.link - href={~p"/users/register"} - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Register - -
  • -
  • - <.link - href={~p"/users/log_in"} - class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700" - > - Log in - -
  • - <% end %> -
<%= @inner_content %> diff --git a/lib/cklist_web/components/my_components.ex b/lib/cklist_web/components/my_components.ex new file mode 100644 index 0000000..b3d13e9 --- /dev/null +++ b/lib/cklist_web/components/my_components.ex @@ -0,0 +1,197 @@ +defmodule CklistWeb.MyComponents do + @moduledoc """ + Provides custom UI components for the Cklist project. + """ + + use Phoenix.Component + import CklistWeb.CoreComponents + + @doc """ + Renders a progress bar. + + ## Example + + <.progress_bar steps=5 done=2 /> + """ + attr :steps, :integer, required: true + attr :done, :integer, required: true + + def progress_bar(assigns) do + ~H""" +
+
+
+ """ + end + + def sequential_run(assigns) do + ~H""" +
+
+ <%= if @completed do %> +

Look at all the things you did:

+
    + <%= for step <- @checklist.document["steps"] do %> +
  • <%= step["name"] %>
  • + <% end %> +
+ <% else %> + <.card title={@current_step["name"]} description={Map.get(@current_step, "description", "")} /> + <%= if @next_step do %> + <.preview_card title={@next_step["name"]} /> + <% end %> + <% end %> +
+ +
+ <.abort_button class="basis-1/3" hidden={@completed} /> + + <.next_button class="basis-1/3" :if={@completed}> + <%= if @completed, do: "Awesome!" %> + +
+
+ """ + end + + def checklist_run(assigns) do + ~H""" +
+
+ <.input + :for={step <- @checklist.document["steps"]} + phx-change="step_done" + checked={Map.get(@step_state, step["name"], false)} + id={step["name"]} + type="checkbox" label={"#{step["name"]}: #{Map.get(step, "description", "")}"} + name={step["name"]} + disabled={@completed} + /> +
+ +
+

Congratulations! 🎉

+

You finished the checklist.

+
+ +
+ <.abort_button class="basis-1/3" hidden={@completed} /> + + <.next_button class="basis-1/3" hidden={not @completed}> + Awesome! + +
+
+ """ + end + + @doc """ + Renders one step of a sequential checklist as a card. + + ## Example + + <.card title="My card" description="Lorem ipsum dolor sit amet..." /> + """ + attr :title, :string, required: true + attr :description, :string, required: false + def card(assigns) do + ~H""" +
+

<%= @title %>

+ <%= if @description do %> +

<%= @description %>

+ <% end %> +
+ <.next_button class="basis-1/3" /> +
+
+ """ + end + + @doc """ + Renders a preview of one step of the next step of a sequential checklist + + ## Example + + <.preview_card title="My preview card" /> + """ + def preview_card(assigns) do + ~H""" +
+

<%= @title %>

+
+ """ + end + + @doc """ + Renders an abort button. + + ## Example + + <.abort_button hidden={@completed} /> + """ + attr :class, :string, default: nil + attr :hidden, :boolean, default: false + + def abort_button(assigns) do + ~H""" + <.button + class={"#{if @hidden, do: "hidden", else: ""} #{@class}"} + phx-click="abort" + data-confirm="Are you sure you want to abort this checklist?" + > + Abort + + """ + end + + @doc """ + Renders a button to go to the next step. + + ## Example + + <.next_button hidden={not @completed}> + Awesome! + + """ + attr :class, :string, default: nil + attr :hidden, :boolean, default: false + slot :inner_block, required: false + + def next_button(assigns) do + ~H""" + <.button + class={"#{if @hidden, do: "hidden", else: ""} #{@class}"} + phx-click="next_step" + > + <%= render_slot(@inner_block) || "Next" %> + + """ + end + + attr :class, :string, default: nil + attr :rest, :global, doc: "Arbitrary HTML attributes to apply to the button tag" + slot :inner_block, required: true + def my_button(assigns) do + ~H""" + <.core_button + type="button" + class={"px-2 #{@class}"} + {@rest} + > + <%= render_slot(@inner_block) %> + + """ + end + + attr :class, :string, default: nil + attr :rest, :global, doc: "Arbitrary HTML attributes to apply to the button tag" + slot :inner_block, required: true + defp core_button(assigns) do + ~H""" + + """ + end +end diff --git a/lib/cklist_web/controllers/checklist_controller.ex b/lib/cklist_web/controllers/checklist_controller.ex new file mode 100644 index 0000000..ce12e8f --- /dev/null +++ b/lib/cklist_web/controllers/checklist_controller.ex @@ -0,0 +1,67 @@ +defmodule CklistWeb.ChecklistController do + use CklistWeb, :controller + + alias Cklist.Checklists + + def index(conn, _params) do + checklists = Checklists.list_checklists(conn.assigns.current_user) + render(conn, :index, checklists: checklists) + end + + def create(conn, params) do + checklist_params = params["checklist"] + steps = Map.filter(params, fn {key, _val} -> String.starts_with?(key, "step-") end) + descriptions = Map.filter(params, fn {key, _val} -> String.starts_with?(key, "desc-") end) + |> Enum.map(fn {key, val} -> {String.replace(key, "desc", "step"), val} end ) + |> Map.new() + steps = Map.merge(steps, descriptions, fn _key, name, desc -> %{name: name, description: desc} end) + |> Map.values() + + document = %{ + sequential: Map.get(params, "sequential", true), + steps: steps + } + + checklist_params = checklist_params + |> Map.put("user_id", conn.assigns.current_user.id) + |> Map.put("document", document) + + case Checklists.create_checklist(checklist_params) do + {:ok, checklist} -> + conn + |> put_flash(:info, "Checklist created successfully.") + |> redirect(to: ~p"/checklists/#{checklist}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :new, changeset: changeset) + end + end + + def show(conn, %{"id" => id}) do + checklist = Checklists.get_checklist!(id) + render(conn, :show, checklist: checklist) + end + + def update(conn, %{"id" => id, "checklist" => checklist_params}) do + checklist = Checklists.get_checklist!(id) + + case Checklists.update_checklist(checklist, checklist_params) do + {:ok, checklist} -> + conn + |> put_flash(:info, "Checklist updated successfully.") + |> redirect(to: ~p"/checklists/#{checklist}") + + {:error, %Ecto.Changeset{} = changeset} -> + render(conn, :edit, checklist: checklist, changeset: changeset) + end + end + + def delete(conn, %{"id" => id}) do + checklist = Checklists.get_checklist!(id) + {:ok, _checklist} = Checklists.delete_checklist(checklist) + + conn + |> put_flash(:info, "Checklist deleted successfully.") + |> redirect(to: ~p"/checklists") + end +end diff --git a/lib/cklist_web/controllers/checklist_html.ex b/lib/cklist_web/controllers/checklist_html.ex new file mode 100644 index 0000000..21a0cbf --- /dev/null +++ b/lib/cklist_web/controllers/checklist_html.ex @@ -0,0 +1,13 @@ +defmodule CklistWeb.ChecklistHTML do + use CklistWeb, :html + + embed_templates "checklist_html/*" + + @doc """ + Renders a checklist form. + """ + attr :changeset, Ecto.Changeset, required: true + attr :action, :string, required: true + + def checklist_form(assigns) +end diff --git a/lib/cklist_web/controllers/checklist_html/checklist_form.html.heex b/lib/cklist_web/controllers/checklist_html/checklist_form.html.heex new file mode 100644 index 0000000..df75322 --- /dev/null +++ b/lib/cklist_web/controllers/checklist_html/checklist_form.html.heex @@ -0,0 +1,26 @@ +<.simple_form :let={f} for={@changeset} phx-change="form_changed" phx-submit="save_checklist"> + <.error :if={@changeset.action}> + Oops, something went wrong! Please check the errors below. + + <.input field={f[:title]} type="text" label="Title" /> + <.input field={f[:description]} type="text" label="Description" /> + <.input field={f[:access]} type="select" options={[Personal: "personal", Public: "public"]} label="Access" /> + <.input name="sequential" value={f[:document].value.sequential} type="select" options={[True: true, False: false]} label="Sequential" /> + + + <%= for {step, index} <- Enum.with_index(f[:document].value.steps) do %> +
+ <.input name={"step-#{index}"} type="text" value={step.name} /> + <.input name={"desc-#{index}"} type="text" value={step.description} /> +
+ <% end %> + + <.my_button phx-click="step_add">Add step + <.my_button phx-click="step_remove">Remove step + + <:actions> + <.button>Save Checklist + + diff --git a/lib/cklist_web/controllers/checklist_html/index.html.heex b/lib/cklist_web/controllers/checklist_html/index.html.heex new file mode 100644 index 0000000..769f17b --- /dev/null +++ b/lib/cklist_web/controllers/checklist_html/index.html.heex @@ -0,0 +1,32 @@ +<.header> + Listing Checklists + <:actions> + <%= if @current_user do %> + <.link href={~p"/checklists/new"}> + <.button>New Checklist + + <% end %> + + + +<.table id="checklists" rows={@checklists} row_click={if @current_user, do: &JS.navigate(~p"/checklists/#{&1}")}> + <:col :let={checklist} label="Title"><%= checklist.title %> + <:col :let={checklist} label="Description"><%= checklist.description %> + <:action :let={checklist}> + <%= if @current_user do %> +
+ <.link navigate={~p"/checklists/#{checklist}"}>Show +
+ <.link navigate={~p"/checklists/#{checklist}/run"}> + <.icon name="hero-play" /> + + <% end %> + + <:action :let={checklist}> + <%= if @current_user do %> + <.link href={~p"/checklists/#{checklist}"} method="delete" data-confirm="Are you sure you want to delete this checklist?"> + <.icon name="hero-trash" /> + + <% end %> + + diff --git a/lib/cklist_web/controllers/checklist_html/new.html.heex b/lib/cklist_web/controllers/checklist_html/new.html.heex new file mode 100644 index 0000000..a8072f7 --- /dev/null +++ b/lib/cklist_web/controllers/checklist_html/new.html.heex @@ -0,0 +1,8 @@ +<.header> + New Checklist + <:subtitle>Use this form to manage checklist records in your database. + + +<.checklist_form changeset={@changeset} /> + +<.back navigate={~p"/checklists"}>Back to checklists diff --git a/lib/cklist_web/controllers/checklist_html/run.html.heex b/lib/cklist_web/controllers/checklist_html/run.html.heex new file mode 100644 index 0000000..e19dc23 --- /dev/null +++ b/lib/cklist_web/controllers/checklist_html/run.html.heex @@ -0,0 +1,9 @@ +<.header> + <%= @checklist.title %> + <:subtitle><%= @checklist.description %> + + +<.progress_bar steps={@steps} done={@steps_done} /> + +<.checklist_run checklist={@checklist} step_state={@step_state} completed={@completed} /> +<.sequential_run checklist={@checklist} current_step={@current_step} next_step={@next_step} completed={@completed} /> diff --git a/lib/cklist_web/controllers/checklist_html/show.html.heex b/lib/cklist_web/controllers/checklist_html/show.html.heex new file mode 100644 index 0000000..578bd01 --- /dev/null +++ b/lib/cklist_web/controllers/checklist_html/show.html.heex @@ -0,0 +1,33 @@ +<.header> + Checklist <%= @checklist.id %> + <:subtitle>This is a checklist record from your database. + <:actions> + <.link href={~p"/checklists/#{@checklist}/run"}> + <.button> + Run +   + <.icon name="hero-play" class="my-0.5" /> + + + + + +<.list> + <:item title="Title"><%= @checklist.title %> + <:item title="Description"><%= @checklist.description %> + <:item title="Access"><%= @checklist.access %> + <:item title="Sequential"><%= @checklist.document["sequential"] %> + <:item title="Steps"> +
+ <%= for step <- @checklist.document["steps"] do %> +
<%= step["name"] %>
+ <%= if step["description"] do %> +
<%= step["description"] %>
+ <% end %> + <% end %> +
+ + <:item title="Document version"><%= @checklist.document["version"] %> + + +<.back navigate={~p"/checklists"}>Back to checklists diff --git a/lib/cklist_web/controllers/page_html/home.html.heex b/lib/cklist_web/controllers/page_html/home.html.heex index e9fc48d..3443d08 100644 --- a/lib/cklist_web/controllers/page_html/home.html.heex +++ b/lib/cklist_web/controllers/page_html/home.html.heex @@ -40,55 +40,91 @@
- -

- Phoenix Framework - - v<%= Application.spec(:phoenix, :vsn) %> - + + + +

+ cklist

- Peace of mind from prototype to production. + Peace of mind. Every time.

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. + Setup checklists once, run them many times. Share them with family and friends.

- -
diff --git a/lib/cklist_web/live/cklist_new_live.ex b/lib/cklist_web/live/cklist_new_live.ex new file mode 100644 index 0000000..9b2236f --- /dev/null +++ b/lib/cklist_web/live/cklist_new_live.ex @@ -0,0 +1,134 @@ +defmodule CklistWeb.CklistNewLive do + use CklistWeb, :live_view + + alias Cklist.Checklists + alias Cklist.Checklists.Checklist + + def render(assigns) do + CklistWeb.ChecklistHTML.new(assigns) + end + + def mount(_params, _session, socket) do + changeset = Checklists.change_checklist( + %Checklist{ + access: :personal, + document: %{ + version: "0.1", + sequential: true, + steps: [ + %{ name: "Step one", description: "Lorem ipsum" }, + %{ name: "Step two", description: "dolor sit amet" } + ] + }, + user_id: socket.assigns.current_user.id, + } + ) + + socket = socket + |> assign(:changeset, changeset) + {:ok, socket} + end + + def handle_event("form_changed", params, socket) do + new_checklist = checklist_from(params, socket) + + changeset = Checklists.change_checklist( + socket.assigns.changeset.data, + Map.merge( + socket.assigns.changeset.changes, + Map.put(new_checklist, :document, document_from(params, socket)) + ) + ) + + socket = socket + |> assign(:changeset, changeset) + + { :noreply, socket } + end + + def handle_event("step_add", _params, socket) do + document = latest_document(socket) + + changeset = Checklists.change_checklist( + socket.assigns.changeset.data, + Map.merge( + socket.assigns.changeset.changes, + %{ document: %{ + sequential: document.sequential, + steps: document.steps ++ [%{name: "New step", description: "with description"}], + version: document.version, + }} + ) + ) + + socket = socket + |> assign(:changeset, changeset) + + { :noreply, socket } + end + + def handle_event("step_remove", _params, socket) do + document = latest_document(socket) + + changeset = Checklists.change_checklist( + socket.assigns.changeset.data, + Map.merge( + socket.assigns.changeset.changes, + %{ document: %{ + sequential: document.sequential, + steps: List.delete_at(document.steps, length(document.steps) - 1), + version: document.version, + }} + ) + ) + + socket = socket + |> assign(:changeset, changeset) + + { :noreply, socket } + end + + def handle_event("save_checklist", _params, socket) do + changeset = socket.assigns.changeset + + case changeset.valid? && Checklists.insert_checklist(changeset) do + {:ok, checklist} -> + {:noreply, socket + |> put_flash(:info, "Checklist created successfully.") + |> redirect(to: ~p"/checklists/#{checklist}")} + + false -> + {:noreply, socket + |> put_flash(:error, "Checklist creation failed.") + |> assign(:changeset, changeset)} + end + end + + defp checklist_from(params, _socket) do + %{ + title: params["checklist"]["title"], + description: params["checklist"]["description"], + access: (if params["checklist"]["access"] === "personal", do: :personal, else: :public) + } + end + + defp document_from(params, socket) do + document = latest_document(socket) + steps = Map.filter(params, fn {key, _val} -> String.starts_with?(key, "step-") end) + descriptions = Map.filter(params, fn {key, _val} -> String.starts_with?(key, "desc-") end) + |> Enum.map(fn {key, val} -> {String.replace(key, "desc", "step"), val} end ) + |> Map.new() + steps = Map.merge(steps, descriptions, fn _key, name, desc -> %{name: name, description: desc} end) + |> Map.values() + + %{ + sequential: (if Map.get(params, "sequential", document.sequential) == "true", do: true, else: false), + steps: steps, + version: document.version, + } + end + + defp latest_document(socket) do + Map.get(socket.assigns.changeset.changes, :document, socket.assigns.changeset.data.document) + end +end diff --git a/lib/cklist_web/live/cklist_run_live.ex b/lib/cklist_web/live/cklist_run_live.ex new file mode 100644 index 0000000..2d10f18 --- /dev/null +++ b/lib/cklist_web/live/cklist_run_live.ex @@ -0,0 +1,94 @@ +defmodule CklistWeb.CklistRunLive do + use CklistWeb, :live_view + + alias Cklist.Checklists + + def render(assigns) do + CklistWeb.ChecklistHTML.run(assigns) + end + + def mount(%{"id" => id}, _session, socket) do + user = socket.assigns.current_user + + checklist = Checklists.get_checklist!(id) + run = Checklists.log_run_start(checklist, user) + + socket = socket + |> assign(:checklist, checklist) + |> assign(:run, run) + |> assign(:steps, length(checklist.document["steps"])) + |> assign(:steps_done, 0) + |> assign(:completed, false) + |> assign(:step_state, %{}) + + |> assign(:current_step, Enum.at(checklist.document["steps"], 0)) + |> assign(:next_step, Enum.at(checklist.document["steps"], 1)) + {:ok, socket} + end + + # Handles aborting a checklist. + def handle_event("abort", _params, %{assigns: assigns} = socket) do + Checklists.log_run_abort(assigns.run, assigns.current_user) + { + :noreply, + socket + |> put_flash(:info, "Aborting checklist run") + |> redirect(to: ~p"/checklists/#{assigns.checklist}") + } + end + + # Handles "next_step" event for sequential checklists. + def handle_event("next_step", _params, %{assigns: assigns} = socket) do + case assigns.completed do + true -> + { + :noreply, + socket + |> put_flash(:info, "Well done!") + |> redirect(to: ~p"/checklists/#{assigns.checklist}") + } + false -> + Checklists.log_step_complete(assigns.run, assigns.current_user, assigns.steps_done) + updated_steps_done = assigns.steps_done + 1 + completed = assigns.steps === updated_steps_done + if completed do + Checklists.log_run_complete(assigns.run, assigns.current_user) + end + { + :noreply, + socket + |> assign(:current_step, Enum.at(assigns.checklist.document["steps"], updated_steps_done)) + |> assign(:next_step, Enum.at(assigns.checklist.document["steps"], updated_steps_done + 1)) + |> assign(:steps_done, updated_steps_done) + |> assign(:completed, completed) + } + end + end + + # Handles "step_done" events for non-sequential checklists. + def handle_event("step_done", params, %{assigns: assigns} = socket) do + [step_name] = params["_target"] + is_done = Map.get(params, step_name) == "true" + updated_state = Map.put(assigns.step_state, step_name, is_done) + updated_steps_done = Enum.reduce(updated_state, 0, &is_done/2) + + step_id = Enum.find_index(assigns.checklist.document["steps"], &(&1["name"] == step_name)) + Checklists.log_step_complete(assigns.run, assigns.current_user, step_id, is_done) + + completed = updated_steps_done === assigns.steps + if completed do + Checklists.log_run_complete(assigns.run, assigns.current_user) + end + + { + :noreply, + socket + |> assign(:step_state, updated_state) + |> assign(:steps_done, updated_steps_done) + |> assign(:completed, completed) + } + end + + defp is_done({_, true}, count), do: count + 1 + defp is_done(_, count), do: count +end diff --git a/lib/cklist_web/router.ex b/lib/cklist_web/router.ex index b1255e8..b901026 100644 --- a/lib/cklist_web/router.ex +++ b/lib/cklist_web/router.ex @@ -24,6 +24,8 @@ defmodule CklistWeb.Router do pipe_through :browser get "/", PageController, :home + + resources "/checklists", ChecklistController, only: [:index] end # Other scopes may use custom stacks. @@ -71,7 +73,12 @@ defmodule CklistWeb.Router do on_mount: [{CklistWeb.UserAuth, :ensure_authenticated}] do live "/user/settings", UserSettingsLive, :edit live "/user/settings/confirm_email/:token", UserSettingsLive, :confirm_email + + live "/checklists/new", CklistNewLive + live "/checklists/:id/run", CklistRunLive end + + resources "/checklists", ChecklistController, except: [:index, :new] end scope "/", CklistWeb do diff --git a/priv/repo/migrations/20240128194147_create_checklists.exs b/priv/repo/migrations/20240128194147_create_checklists.exs new file mode 100644 index 0000000..2fb7147 --- /dev/null +++ b/priv/repo/migrations/20240128194147_create_checklists.exs @@ -0,0 +1,16 @@ +defmodule Cklist.Repo.Migrations.CreateChecklists do + use Ecto.Migration + + def change do + create table(:checklists) do + add :title, :string + add :description, :string + add :document, :map + add :user_id, references(:users, on_delete: :delete_all) + + timestamps(type: :utc_datetime) + end + + create index(:checklists, [:user_id]) + end +end diff --git a/priv/repo/migrations/20240211152500_add_access_property.exs b/priv/repo/migrations/20240211152500_add_access_property.exs new file mode 100644 index 0000000..0de09e9 --- /dev/null +++ b/priv/repo/migrations/20240211152500_add_access_property.exs @@ -0,0 +1,9 @@ +defmodule Cklist.Repo.Migrations.AddAccessProperty do + use Ecto.Migration + + def change do + alter table("checklists") do + add :access, :string + end + end +end diff --git a/priv/repo/migrations/20240310152508_create_checklist_run.exs b/priv/repo/migrations/20240310152508_create_checklist_run.exs new file mode 100644 index 0000000..e3acfbf --- /dev/null +++ b/priv/repo/migrations/20240310152508_create_checklist_run.exs @@ -0,0 +1,9 @@ +defmodule Cklist.Repo.Migrations.CreateChecklistRun do + use Ecto.Migration + + def change do + create table(:runs) do + add :checklist_id, references(:checklists, on_delete: :delete_all) + end + end +end diff --git a/priv/repo/migrations/20240310153254_create_activity_log.exs b/priv/repo/migrations/20240310153254_create_activity_log.exs new file mode 100644 index 0000000..4299458 --- /dev/null +++ b/priv/repo/migrations/20240310153254_create_activity_log.exs @@ -0,0 +1,14 @@ +defmodule Cklist.Repo.Migrations.CreateActivityLog do + use Ecto.Migration + + def change do + create table(:activity) do + add :event, :map + + add :user_id, references(:users, on_delete: :delete_all) + add :run_id, references(:runs, on_delete: :delete_all) + + timestamps([type: :utc_datetime, updated_at: false]) + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 067eff9..f944f87 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -10,9 +10,89 @@ # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. -Cklist.Repo.insert!(%Cklist.Accounts.User{ - email: "audry@cklist.org", +alias Cklist.{Repo, Accounts, Checklists} + +roman = Repo.insert!(%Accounts.User{ + email: "roman@cklist.org", password: "romanholiday", hashed_password: Bcrypt.hash_pwd_salt("romanholiday"), confirmed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) }) + +aaron = Repo.insert!(%Accounts.User{ + email: "aaron@cklist.org", + password: "aaronholiday", + hashed_password: Bcrypt.hash_pwd_salt("aaronholiday"), + confirmed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second) +}) + +Repo.insert!(%Checklists.Checklist{ + title: "First things first!", + description: "Sequential checklist", + access: :public, + user_id: aaron.id, + + document: %{ + version: "0.1", + sequential: true, + steps: [ + %{ name: "First thing" }, + %{ name: "Second thing" }, + %{ name: "Third thing" }, + ] + } +}) + +Repo.insert!(%Checklists.Checklist{ + title: "Routine", + description: "Morning todo list", + access: :personal, + user_id: roman.id, + + document: %{ + version: "0.1", + sequential: false, + steps: [ + %{ name: "Get up" }, + %{ name: "Eat breakfast" }, + %{ name: "Have a shower" }, + %{ name: "Brush teeth" }, + ] + } +}) + +Repo.insert!(%Checklists.Checklist{ + title: "Shopping list", + description: "All you need for carbonara", + access: :personal, + user_id: roman.id, + + document: %{ + version: "0.1", + sequential: false, + steps: [ + %{ name: "Guanciale" }, + %{ name: "Pecorino romano" }, + %{ name: "Eggs" }, + %{ name: "Spaghetti" }, + %{ name: "Salt" }, + %{ name: "Pepper" }, + ] + } +}) + +Repo.insert!(%Checklists.Checklist{ + title: "To do list", + description: "Things to do", + access: :public, + user_id: aaron.id, + + document: %{ + version: "0.1", + sequential: false, + steps: [ + %{ name: "One thing" }, + %{ name: "That other thing" }, + ] + } +}) diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg index 9f26bab..64e3d6b 100644 --- a/priv/static/images/logo.svg +++ b/priv/static/images/logo.svg @@ -1,6 +1 @@ - + \ No newline at end of file diff --git a/test/cklist/checklists_test.exs b/test/cklist/checklists_test.exs new file mode 100644 index 0000000..f5d5824 --- /dev/null +++ b/test/cklist/checklists_test.exs @@ -0,0 +1,65 @@ +defmodule Cklist.ChecklistsTest do + use Cklist.DataCase + + alias Cklist.Checklists + + describe "checklists" do + alias Cklist.Checklists.Checklist + + import Cklist.ChecklistsFixtures + import Cklist.AccountsFixtures + + @invalid_attrs %{description: nil, title: nil, document: nil} + + test "list_checklists/1 returns all checklists except private ones of other users" do + id = user_fixture().id + checklists = multiple_checklist_fixture(%{id1: id, id2: user_fixture().id}) + assert Checklists.list_checklists(%{id: id}) == checklists + end + + test "get_checklist!/1 returns the checklist with given id" do + checklist = checklist_fixture() + assert Checklists.get_checklist!(checklist.id) == checklist + end + + test "create_checklist/1 with valid data creates a checklist" do + valid_attrs = %{description: "some description", title: "some title", user_id: 1, access: "personal", document: %{}} + + assert {:ok, %Checklist{} = checklist} = Checklists.create_checklist(valid_attrs) + assert checklist.description == "some description" + assert checklist.title == "some title" + assert checklist.document == %{} + end + + test "create_checklist/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Checklists.create_checklist(@invalid_attrs) + end + + test "update_checklist/2 with valid data updates the checklist" do + checklist = checklist_fixture() + update_attrs = %{description: "some updated description", title: "some updated title", document: %{}} + + assert {:ok, %Checklist{} = checklist} = Checklists.update_checklist(checklist, update_attrs) + assert checklist.description == "some updated description" + assert checklist.title == "some updated title" + assert checklist.document == %{} + end + + test "update_checklist/2 with invalid data returns error changeset" do + checklist = checklist_fixture() + assert {:error, %Ecto.Changeset{}} = Checklists.update_checklist(checklist, @invalid_attrs) + assert checklist == Checklists.get_checklist!(checklist.id) + end + + test "delete_checklist/1 deletes the checklist" do + checklist = checklist_fixture() + assert {:ok, %Checklist{}} = Checklists.delete_checklist(checklist) + assert_raise Ecto.NoResultsError, fn -> Checklists.get_checklist!(checklist.id) end + end + + test "change_checklist/1 returns a checklist changeset" do + checklist = checklist_fixture() + assert %Ecto.Changeset{} = Checklists.change_checklist(checklist) + end + end +end diff --git a/test/cklist_web/controllers/checklist_controller_test.exs b/test/cklist_web/controllers/checklist_controller_test.exs new file mode 100644 index 0000000..5a2b0be --- /dev/null +++ b/test/cklist_web/controllers/checklist_controller_test.exs @@ -0,0 +1,111 @@ +defmodule CklistWeb.ChecklistControllerTest do + use CklistWeb.ConnCase + + import Cklist.ChecklistsFixtures + import Cklist.AccountsFixtures + + @create_attrs %{ + description: "some description", + title: "some title", + document: %{}, + user_id: 1, + access: "personal" + } + @update_attrs %{ + description: "some updated description", + title: "some updated title", + document: %{}, + user_id: 1, + access: "personal" + } + @invalid_attrs %{description: nil, title: nil, document: nil} + + @remember_me_cookie "_cklist_web_user_remember_me" + + setup %{conn: conn} do + user_token = Cklist.Accounts.generate_user_session_token(user_fixture()) + + %{ + conn: + conn + |> init_test_session(%{}) + |> put_session(:user_token, user_token) + |> put_req_cookie(@remember_me_cookie, user_token) + } + end + + describe "index" do + test "lists all checklists", %{conn: conn} do + conn = get(conn, ~p"/checklists") + assert html_response(conn, 200) =~ "Listing Checklists" + end + end + + describe "new checklist" do + test "renders form", %{conn: conn} do + conn = get(conn, ~p"/checklists/new") + assert html_response(conn, 200) =~ "New Checklist" + end + end + + describe "create checklist" do + test "redirects to show when data is valid", %{conn: conn} do + conn = post(conn, ~p"/checklists", checklist: @create_attrs) + + assert %{id: id} = redirected_params(conn) + assert redirected_to(conn) == ~p"/checklists/#{id}" + + conn = get(conn, ~p"/checklists/#{id}") + assert html_response(conn, 200) =~ "Checklist #{id}" + end + + test "renders errors when data is invalid", %{conn: conn} do + conn = post(conn, ~p"/checklists", checklist: @invalid_attrs) + assert html_response(conn, 200) =~ "New Checklist" + end + end + + describe "edit checklist" do + setup [:create_checklist] + + test "renders form for editing chosen checklist", %{conn: conn, checklist: checklist} do + conn = get(conn, ~p"/checklists/#{checklist}/edit") + assert html_response(conn, 200) =~ "Edit Checklist" + end + end + + describe "update checklist" do + setup [:create_checklist] + + test "redirects when data is valid", %{conn: conn, checklist: checklist} do + conn = put(conn, ~p"/checklists/#{checklist}", checklist: @update_attrs) + assert redirected_to(conn) == ~p"/checklists/#{checklist}" + + conn = get(conn, ~p"/checklists/#{checklist}") + assert html_response(conn, 200) =~ "some updated description" + end + + test "renders errors when data is invalid", %{conn: conn, checklist: checklist} do + conn = put(conn, ~p"/checklists/#{checklist}", checklist: @invalid_attrs) + assert html_response(conn, 200) =~ "Edit Checklist" + end + end + + describe "delete checklist" do + setup [:create_checklist] + + test "deletes chosen checklist", %{conn: conn, checklist: checklist} do + conn = delete(conn, ~p"/checklists/#{checklist}") + assert redirected_to(conn) == ~p"/checklists" + + assert_error_sent 404, fn -> + get(conn, ~p"/checklists/#{checklist}") + end + end + end + + defp create_checklist(_) do + checklist = checklist_fixture() + %{checklist: checklist} + end +end diff --git a/test/cklist_web/controllers/page_controller_test.exs b/test/cklist_web/controllers/page_controller_test.exs index e5c0f4c..4ba6b46 100644 --- a/test/cklist_web/controllers/page_controller_test.exs +++ b/test/cklist_web/controllers/page_controller_test.exs @@ -3,6 +3,6 @@ defmodule CklistWeb.PageControllerTest do test "GET /", %{conn: conn} do conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert html_response(conn, 200) =~ "Peace of mind" end end diff --git a/test/cklist_web/live/user_registration_live_test.exs b/test/cklist_web/live/user_registration_live_test.exs index f6ff04c..365b2ee 100644 --- a/test/cklist_web/live/user_registration_live_test.exs +++ b/test/cklist_web/live/user_registration_live_test.exs @@ -50,7 +50,7 @@ defmodule CklistWeb.UserRegistrationLiveTest do # Now do a logged in request and assert on the menu conn = get(conn, "/") response = html_response(conn, 200) - assert response =~ email + # assert response =~ email assert response =~ "Settings" assert response =~ "Log out" end diff --git a/test/support/fixtures/checklists_fixtures.ex b/test/support/fixtures/checklists_fixtures.ex new file mode 100644 index 0000000..224099c --- /dev/null +++ b/test/support/fixtures/checklists_fixtures.ex @@ -0,0 +1,54 @@ +defmodule Cklist.ChecklistsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Cklist.Checklists` context. + """ + + @doc """ + Generate a checklist. + """ + def checklist_fixture(attrs \\ %{}) do + {:ok, checklist} = + attrs + |> Enum.into(%{ + description: "some description", + document: %{}, + title: "some title", + access: "personal", + user_id: 1 + }) + |> Cklist.Checklists.create_checklist() + + checklist + end + + @doc """ + Generates multiple checklists of different users. + """ + def multiple_checklist_fixture(users) do + attrs = %{} + {:ok, checklist1} = + attrs + |> Enum.into(%{ + description: "some description", + document: %{}, + title: "some title", + access: "personal", + user_id: users.id1 + }) + |> Cklist.Checklists.create_checklist() + + {:ok, checklist2} = + attrs + |> Enum.into(%{ + description: "some description", + document: %{}, + title: "some other title", + access: "public", + user_id: users.id2 + }) + |> Cklist.Checklists.create_checklist() + + [checklist1, checklist2] + end +end