diff --git a/.formatter.exs b/.formatter.exs index cb2a852..2b73acd 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -10,6 +10,6 @@ :phoenix ], subdirectories: ["priv/*/migrations"], - # plugins: [Spark.Formatter, Phoenix.LiveView.HTMLFormatter], # FIXME:seems broken + plugins: [Spark.Formatter, Phoenix.LiveView.HTMLFormatter], inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] ] diff --git a/lib/putzplan/accounts/user.ex b/lib/putzplan/accounts/user.ex index 0a57183..6540272 100644 --- a/lib/putzplan/accounts/user.ex +++ b/lib/putzplan/accounts/user.ex @@ -28,15 +28,11 @@ defmodule Putzplan.Accounts.User do client_secret "insecure_secret" nonce true redirect_uri "http://127.0.0.1:4000/auth" - authorization_params [scope: "profile email"] + authorization_params scope: "profile email" end end end - identities do - identity :id, [:id] - end - sqlite do table "users" repo Putzplan.Repo @@ -85,4 +81,8 @@ defmodule Putzplan.Accounts.User do uuid_primary_key :id, writable?: true, default: nil attribute :name, :string, allow_nil?: false end + + identities do + identity :id, [:id] + end end diff --git a/lib/putzplan/pub_sub.ex b/lib/putzplan/pub_sub.ex new file mode 100644 index 0000000..d7bcbd6 --- /dev/null +++ b/lib/putzplan/pub_sub.ex @@ -0,0 +1,43 @@ +defmodule Putzplan.PubSub do + @tasks_topic "tasks" + + def subscribe_completed(id) do + Phoenix.PubSub.subscribe(__MODULE__, get_topic(id)) + end + + def subscribe_tasks do + Phoenix.PubSub.subscribe(__MODULE__, @tasks_topic) + end + + def upsert_task(task) do + Phoenix.PubSub.broadcast!(__MODULE__, @tasks_topic, {:upsert, task}) + end + + def delete_task(task) do + Phoenix.PubSub.broadcast!(__MODULE__, @tasks_topic, {:delete, task}) + end + + def update_task_by_id(id) do + Phoenix.PubSub.broadcast!(__MODULE__, @tasks_topic, {:update, id}) + end + + def upsert_completed_task(completed_task) do + Phoenix.PubSub.broadcast!( + __MODULE__, + get_topic(completed_task.tasks.id), + {:upsert, completed_task} + ) + end + + def delete_completed_task(completed_task) do + Phoenix.PubSub.broadcast!( + __MODULE__, + get_topic(completed_task.tasks.id), + {:delete, completed_task} + ) + end + + defp get_topic(id) do + "completed:" <> id + end +end diff --git a/lib/putzplan/tasks/completed_task.ex b/lib/putzplan/tasks/completed_task.ex index 44ac3a6..c9b361f 100644 --- a/lib/putzplan/tasks/completed_task.ex +++ b/lib/putzplan/tasks/completed_task.ex @@ -1,8 +1,13 @@ defmodule Putzplan.Tasks.CompletedTask do use Ash.Resource, otp_app: :putzplan, domain: Putzplan.Tasks, data_layer: AshSqlite.DataLayer + sqlite do + table "completed_tasks" + repo Putzplan.Repo + end + actions do - defaults [:read] + defaults [:destroy] read :read_with_relations do primary? true @@ -13,8 +18,8 @@ defmodule Putzplan.Tasks.CompletedTask do create :create do primary? true - argument :user, :map, allow_nil?: false - argument :task, :map, allow_nil?: false + argument :user, :uuid, allow_nil?: false + argument :task, :uuid, allow_nil?: false change set_attribute(:completion, &Date.utc_today/0) change manage_relationship(:user, :users, type: :append) @@ -41,9 +46,4 @@ defmodule Putzplan.Tasks.CompletedTask do source_attribute :task_id end end - - sqlite do - table "completed_tasks" - repo Putzplan.Repo - end end diff --git a/lib/putzplan/tasks/task.ex b/lib/putzplan/tasks/task.ex index 8914a89..61bfc6c 100644 --- a/lib/putzplan/tasks/task.ex +++ b/lib/putzplan/tasks/task.ex @@ -4,42 +4,13 @@ defmodule Putzplan.Tasks.Task do domain: Putzplan.Tasks, data_layer: AshSqlite.DataLayer - actions do - defaults [:read, :destroy, create: :*, update: :*] - end - - calculations do - calculate :last_completed, - :date, - expr( - fragment( - "SELECT c.completion FROM \"completed_tasks\" AS c WHERE c.task_id = ? ORDER BY c.completion DESC LIMIT 1", - id - ) || today() - ) - - calculate :due, - :date, - expr( - fragment( - # HACK: to cast to date - "SELECT strftime('%F', ?)", - date_add( - last_completed, - repetition_days, - :day - ) - ) - ) - end - sqlite do table "tasks" repo Putzplan.Repo end - relationships do - has_many :completed_tasks, Putzplan.Tasks.CompletedTask + actions do + defaults [:read, :destroy, create: :*, update: :*] end # Attributes are the simple pieces of data that exist on your resource @@ -58,4 +29,37 @@ defmodule Putzplan.Tasks.Task do constraints min: 1 end end + + relationships do + has_many :completed_tasks, Putzplan.Tasks.CompletedTask + end + + calculations do + calculate :last_completed, + :date, + expr( + fragment( + "SELECT c.completion FROM \"completed_tasks\" AS c WHERE c.task_id = ? ORDER BY c.completion DESC LIMIT 1", + id + ) + ) + + calculate :due, + :date, + expr( + if last_completed do + fragment( + # HACK: to cast to date + "SELECT strftime('%F', ?)", + date_add( + last_completed, + repetition_days, + :day + ) + ) + else + today() + end + ) + end end diff --git a/lib/putzplan_web/auth_overrides.ex b/lib/putzplan_web/auth_overrides.ex index b25ed75..e9df549 100644 --- a/lib/putzplan_web/auth_overrides.ex +++ b/lib/putzplan_web/auth_overrides.ex @@ -15,6 +15,6 @@ defmodule PutzplanWeb.AuthOverrides do # end override AshAuthentication.Phoenix.Components.SignIn do - set :show_banner, false + set :show_banner, false end end diff --git a/lib/putzplan_web/live/completed_task_live/form_component.ex b/lib/putzplan_web/live/completed_task_live/form_component.ex deleted file mode 100644 index 5362db9..0000000 --- a/lib/putzplan_web/live/completed_task_live/form_component.ex +++ /dev/null @@ -1,73 +0,0 @@ -defmodule PutzplanWeb.CompletedTaskLive.FormComponent do - use PutzplanWeb, :live_component - - @impl true - def render(assigns) do - ~H""" -
- <.header> - <%= @title %> - <:subtitle>Use this form to manage completed_task records in your database. - - - <.simple_form - for={@form} - id="completed_task-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - - <.input field={@form[:user]} type="text" label="User" /><.input field={@form[:task]} type="text" label="Task" /> - - <:actions> - <.button phx-disable-with="Saving...">Save Completed task - - -
- """ - end - - @impl true - def update(assigns, socket) do - {:ok, - socket - |> assign(assigns) - |> assign_form()} - end - - @impl true - def handle_event("validate", %{"completed_task" => completed_task_params}, socket) do - {:noreply, - assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, completed_task_params))} - end - - def handle_event("save", %{"completed_task" => completed_task_params}, socket) do - case AshPhoenix.Form.submit(socket.assigns.form, params: completed_task_params) do - {:ok, completed_task} -> - notify_parent({:saved, completed_task}) - - socket = - socket - |> put_flash(:info, "Completed task created successfully") - |> push_patch(to: socket.assigns.patch) - - {:noreply, socket} - - {:error, form} -> - {:noreply, assign(socket, form: form)} - end - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - - defp assign_form(%{assigns: %{completed_task: completed_task}} = socket) do - form = - AshPhoenix.Form.for_create(completed_task, :create, - as: "completed_task", - actor: socket.assigns.current_user - ) - - assign(socket, form: to_form(form)) - end -end diff --git a/lib/putzplan_web/live/completed_task_live/index.ex b/lib/putzplan_web/live/completed_task_live/index.ex deleted file mode 100644 index 5197afa..0000000 --- a/lib/putzplan_web/live/completed_task_live/index.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule PutzplanWeb.CompletedTaskLive.Index do - use PutzplanWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - Listing Completed tasks - <:actions> - <.link patch={~p"/completed_tasks/new"}> - <.button>New Completed task - - - - - <.table - id="completed_tasks" - rows={@streams.completed_tasks} - row_click={fn {_id, completed_task} -> JS.navigate(~p"/completed_tasks/#{completed_task}") end} - > - - <:col :let={{_id, completed_task}} label="Id"><%= completed_task.id %> - - <:action :let={{_id, completed_task}}> -
- <.link navigate={~p"/completed_tasks/#{completed_task}"}>Show -
- - - - - - - <.modal :if={@live_action == :new} id="completed_task-modal" show on_cancel={JS.patch(~p"/completed_tasks")}> - <.live_component - module={PutzplanWeb.CompletedTaskLive.FormComponent} - current_user={@current_user} - id={:new} - title={@page_title} - action={@live_action} - completed_task={@completed_task} - patch={~p"/completed_tasks"} - /> - - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> stream( - :completed_tasks, - Ash.read!(Putzplan.Tasks.CompletedTask, actor: socket.assigns[:current_user]) - ) - |> assign_new(:current_user, fn -> nil end)} - end - - @impl true - def handle_params(params, _url, socket) do - {:noreply, apply_action(socket, socket.assigns.live_action, params)} - end - - defp apply_action(socket, :new, _params) do - socket - |> assign(:page_title, "New Completed task") - end - - defp apply_action(socket, :index, _params) do - socket - |> assign(:page_title, "Listing Completed tasks") - |> assign(:completed_task, nil) - end - - @impl true - def handle_info({PutzplanWeb.CompletedTaskLive.FormComponent, {:saved, completed_task}}, socket) do - {:noreply, stream_insert(socket, :completed_tasks, completed_task)} - end -end diff --git a/lib/putzplan_web/live/completed_task_live/show.ex b/lib/putzplan_web/live/completed_task_live/show.ex deleted file mode 100644 index f196e58..0000000 --- a/lib/putzplan_web/live/completed_task_live/show.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule PutzplanWeb.CompletedTaskLive.Show do - use PutzplanWeb, :live_view - - @impl true - def render(assigns) do - ~H""" - <.header> - Completed task <%= @completed_task.id %> - <:subtitle>This is a completed_task record from your database. - - - - <.list> - - <:item title="Id"><%= @completed_task.id %> - - - - <.back navigate={~p"/completed_tasks"}>Back to completed_tasks - - - """ - end - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - actor = socket.assigns.current_user - completed_task = Ash.get!(Putzplan.Tasks.CompletedTask, id, actor: actor) - - {:noreply, - socket - |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign( - :completed_task, - completed_task - )} - end - - defp page_title(:show), do: "Show Completed task" - defp page_title(:edit), do: "Edit Completed task" -end diff --git a/lib/putzplan_web/live/task_live/form_component.ex b/lib/putzplan_web/live/task_live/form_component.ex index 346553a..38cf74f 100644 --- a/lib/putzplan_web/live/task_live/form_component.ex +++ b/lib/putzplan_web/live/task_live/form_component.ex @@ -1,12 +1,15 @@ defmodule PutzplanWeb.TaskLive.FormComponent do use PutzplanWeb, :live_component + @pubsub_name Putzplan.PubSub + @pubsub_topic_tasks "tasks" + @impl true def render(assigns) do ~H"""
<.header> - <%= @title %> + {@title} <:subtitle>Use this form to manage task records in your database. @@ -17,11 +20,12 @@ defmodule PutzplanWeb.TaskLive.FormComponent do phx-change="validate" phx-submit="save" > - - - <.input field={@form[:description]} type="text" label="Description" /><.input field={@form[:repetition_days]} type="number" label="Repetition days" /> - - + <.input field={@form[:description]} type="text" label="Description" /><.input + field={@form[:repetition_days]} + type="number" + label="Repetition days" + /> + <:actions> <.button phx-disable-with="Saving...">Save Task @@ -46,7 +50,8 @@ defmodule PutzplanWeb.TaskLive.FormComponent do def handle_event("save", %{"task" => task_params}, socket) do case AshPhoenix.Form.submit(socket.assigns.form, params: task_params) do {:ok, task} -> - notify_parent({:saved, task}) + task = Ash.load!(task, :due) + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_topic_tasks, {:upsert, task}) socket = socket @@ -60,8 +65,6 @@ defmodule PutzplanWeb.TaskLive.FormComponent do end end - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - defp assign_form(%{assigns: %{task: task}} = socket) do form = if task do diff --git a/lib/putzplan_web/live/task_live/index.ex b/lib/putzplan_web/live/task_live/index.ex index 964368a..70cf5dc 100644 --- a/lib/putzplan_web/live/task_live/index.ex +++ b/lib/putzplan_web/live/task_live/index.ex @@ -8,64 +8,122 @@ defmodule PutzplanWeb.TaskLive.Index do Listing Tasks <:actions> <.link patch={~p"/tasks/new"}> - <.button>New Task + <.button>New Task - <.table - id="tasks" - rows={@streams.tasks} - row_click={fn {_id, task} -> JS.navigate(~p"/tasks/#{task}") end} - > - - <:col :let={{_id, task}} label="Description"><%= task.description %> - - <:col :let={{_id, task}} label="Due"><%= task.due %> - - <:action :let={{_id, task}}> -
- <.link navigate={~p"/tasks/#{task}"}>Show -
- - <.link patch={~p"/tasks/#{task}/edit"}>Edit - - - - <:action :let={{id, task}}> - <.link - phx-click={JS.push("delete", value: %{id: task.id}) |> hide("##{id}")} - data-confirm="Are you sure?" +
+ <%= for {id, task} <- @streams.tasks do %> +
- Delete - - - - +
+
{task.description}
+
+ <.link + phx-click={JS.push("complete", value: %{id: task.id})} + class="text-green-600 hover:text-green-800 p-1 rounded-full hover:bg-green-100" + > + + + + +
+
+
+
Due
+
{format_date(task.due)}
+
- <.modal :if={@live_action in [:new, :edit]} id="task-modal" show on_cancel={JS.patch(~p"/tasks")}> - <.live_component - module={PutzplanWeb.TaskLive.FormComponent} - id={(@task && @task.id) || :new} - title={@page_title} - - current_user={@current_user} - - action={@live_action} - task={@task} - patch={~p"/tasks"} - /> - - +
+
+ <.link navigate={~p"/tasks/#{task}"}>Show +
+ +
+ <.link + patch={~p"/tasks/#{task}/edit"} + class="text-blue-600 hover:text-blue-800 p-1 rounded-full hover:bg-blue-100" + phx-click-stop-propagation="true" + > + + + + + + <.link + phx-click={JS.push("delete", value: %{id: task.id}) |> hide(id)} + data-confirm="Are you sure?" + class="text-red-600 hover:text-red-800 p-1 rounded-full hover:bg-red-100" + phx-click-stop-propagation="true" + > + + + + +
+
+
+ <% end %> +
+ + <.modal :if={@live_action in [:new, :edit]} id="task-modal" show on_cancel={JS.patch(~p"/tasks")}> + <.live_component + module={PutzplanWeb.TaskLive.FormComponent} + id={(@task && @task.id) || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + task={@task} + patch={~p"/"} + /> + """ end @impl true def mount(_params, _session, socket) do + if connected?(socket), do: Putzplan.PubSub.subscribe_tasks() + {:ok, socket - |> stream(:tasks, Ash.read!(Putzplan.Tasks.Task, load: [:due], actor: socket.assigns[:current_user])) + |> stream( + :tasks, + Ash.read!(Putzplan.Tasks.Task, load: [:due], actor: socket.assigns[:current_user]) + ) |> assign_new(:current_user, fn -> nil end)} end @@ -74,10 +132,29 @@ defmodule PutzplanWeb.TaskLive.Index do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end + @impl true + def handle_info({:delete, task}, socket) do + {:noreply, stream_delete(socket, :tasks, task)} + end + + @impl true + def handle_info({:upsert, task}, socket) do + {:noreply, stream_insert(socket, :tasks, task)} + end + + @impl true + def handle_info({:update, task_id}, socket) do + task = Ash.get!(Putzplan.Tasks.Task, task_id, load: [:due]) + {:noreply, stream_insert(socket, :tasks, task)} + end + defp apply_action(socket, :edit, %{"id" => id}) do socket |> assign(:page_title, "Edit Task") - |> assign(:task, Ash.get!(Putzplan.Tasks.Task, id, actor: socket.assigns.current_user)) + |> assign( + :task, + Ash.get!(Putzplan.Tasks.Task, id, actor: socket.assigns.current_user) + ) end defp apply_action(socket, :new, _params) do @@ -92,16 +169,62 @@ defmodule PutzplanWeb.TaskLive.Index do |> assign(:task, nil) end - @impl true - def handle_info({PutzplanWeb.TaskLive.FormComponent, {:saved, task}}, socket) do - {:noreply, stream_insert(socket, :tasks, task)} - end - @impl true def handle_event("delete", %{"id" => id}, socket) do task = Ash.get!(Putzplan.Tasks.Task, id, actor: socket.assigns.current_user) Ash.destroy!(task, actor: socket.assigns.current_user) + Putzplan.PubSub.delete_task(task) - {:noreply, stream_delete(socket, :tasks, task)} + {:noreply, socket} + end + + @impl true + def handle_event("complete", %{"id" => id}, socket) do + completed_task = + Putzplan.Tasks.CompletedTask + |> Ash.Changeset.for_create(:create, %{task: id, user: socket.assigns.current_user.id}) + |> Ash.create!(actor: socket.assigns.current_user) + + task = Ash.get!(Putzplan.Tasks.Task, id, load: [:due]) + Putzplan.PubSub.upsert_task(task) + Putzplan.PubSub.upsert_completed_task(completed_task) + + {:noreply, socket} + end + + defp format_date(date) do + string = + case Date.diff(date, Date.utc_today()) do + -1 -> + "yesterday" + + 0 -> + "today" + + 1 -> + "tomorrow" + + days when days > 0 and days < 7 -> + "next " <> get_weekday(Date.day_of_week(date)) + + _ -> + Date.to_string(date) + end + + String.capitalize(string) + end + + defp get_weekday(index) do + days = %{ + 1 => "monday", + 2 => "tuesday", + 3 => "wednesday", + 4 => "thursday", + 5 => "friday", + 6 => "saturday", + 7 => "sunday" + } + + Map.get(days, index) end end diff --git a/lib/putzplan_web/live/task_live/show.ex b/lib/putzplan_web/live/task_live/show.ex index 98c4d7a..68d0f2f 100644 --- a/lib/putzplan_web/live/task_live/show.ex +++ b/lib/putzplan_web/live/task_live/show.ex @@ -6,45 +6,92 @@ defmodule PutzplanWeb.TaskLive.Show do def render(assigns) do ~H""" <.header> - Task <%= @task.description %> - <:actions> - <.link patch={~p"/tasks/#{@task}/show/edit"} phx-click={JS.push_focus()}> - <.button>Edit task - - - + {@task.description} + <:actions> + <.link patch={~p"/tasks/#{@task}/show/edit"} phx-click={JS.push_focus()}> + <.button>Edit task + + - <.table - id="completed_tasks" - rows={@streams.completed_tasks} +
+ +
+ + <.modal + :if={@live_action in [:new, :edit]} + id="task-modal" + show + on_cancel={JS.patch(~p"/tasks/#{@task}")} > - <:col :let={{_id, completed_task}} label="Completed by"><%= completed_task.users.name %> - <:col :let={{_id, completed_task}} label="Date"><%= completed_task.completion %> - - - <.back navigate={~p"/tasks"}>Back to tasks - - - <.modal :if={@live_action == :edit} id="task-modal" show on_cancel={JS.patch(~p"/tasks/#{@task}")}> - <.live_component - module={PutzplanWeb.TaskLive.FormComponent} - id={@task.id} - title={@page_title} - action={@live_action} - - current_user={@current_user} - - task={@task} - patch={~p"/tasks/#{@task}"} - /> - + <.live_component + module={PutzplanWeb.TaskLive.FormComponent} + id={(@task && @task.id) || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + task={@task} + patch={~p"/tasks/#{@task}"} + /> + + <.back navigate={~p"/"}>Back to tasks """ end @impl true - def mount(_params, _session, socket) do + def mount(%{"id" => id}, _session, socket) do + if connected?(socket), do: Putzplan.PubSub.subscribe_completed(id) {:ok, socket} end @@ -66,4 +113,27 @@ defmodule PutzplanWeb.TaskLive.Show do defp page_title(:show), do: "Show Task" defp page_title(:edit), do: "Edit Task" + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + completed_task = + Ash.get!(Putzplan.Tasks.CompletedTask, id, actor: socket.assigns.current_user) + + Ash.destroy!(completed_task, actor: socket.assigns.current_user) + + Putzplan.PubSub.delete_completed_task(completed_task) + Putzplan.PubSub.update_task_by_id(completed_task.tasks.id) + + {:noreply, socket} + end + + @impl true + def handle_info({:delete, completed_task}, socket) do + {:noreply, stream_delete(socket, :completed_tasks, completed_task)} + end + + @impl true + def handle_info({:upsert, completed_task}, socket) do + {:noreply, stream_insert(socket, :completed_tasks, completed_task)} + end end diff --git a/lib/putzplan_web/router.ex b/lib/putzplan_web/router.ex index 48038a7..0d17f83 100644 --- a/lib/putzplan_web/router.ex +++ b/lib/putzplan_web/router.ex @@ -45,10 +45,6 @@ defmodule PutzplanWeb.Router do live "/tasks/:id", TaskLive.Show, :show live "/tasks/:id/show/edit", TaskLive.Show, :edit - - live "/completed_tasks", CompletedTaskLive.Index, :index - live "/completed_tasks/new", CompletedTaskLive.Index, :new - live "/completed_tasks/:id", CompletedTaskLive.Show, :show end end