-
<%= for task <- @tasks do %>
<%= live_component(@socket,
@@ -73,7 +84,7 @@ defmodule TaskComponent do
diff --git a/assets/js/app.js b/assets/js/app.js index e1ec7c9..5413b05 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -18,8 +18,135 @@ import "phoenix_html" import {Socket} from "phoenix" import LiveSocket from "phoenix_live_view" +let Hooks = {} + +Hooks.Dragger = { + toggleAddDelete() { + const deleter = document.getElementById('deleter') + if (deleter) { + const adder = document.getElementById('adder') + deleter.hidden = adder.hidden + adder.hidden = !adder.hidden + } + }, + + get dragImage() { + const canvas = document.createElement("canvas") + canvas.width = canvas.height = 60 + const ctx = canvas.getContext("2d") + + ctx.beginPath() + ctx.arc(30, 30, 30, 0, 2 * Math.PI) + ctx.fill() + + return canvas + }, + + mounted() { + this.el.addEventListener("dragstart", event => { + event.dataTransfer.setData("text/plain", `task-id:${this.el.dataset.taskId}`) + event.dataTransfer.setDragImage(this.dragImage, 25, 25) + this.toggleAddDelete() + }) + + this.el.addEventListener("dragend", event => { + this.toggleAddDelete() + }) + }, +} + +Hooks.AddDropper = { + get bgClass() { return "has-background-warning"}, + + addTaskToPlan(payload) { return this.pushEvent("add-task-to-plan", payload) }, + + getTaskPayload(event) { return event.dataTransfer.getData("text/plain") }, + + parseTaskPayload(payload) { return payload.startsWith("task-id:") ? payload.split(":")[1] : null }, + + addHoverClass(event) { event.target.classList.add(this.bgClass) }, + + removeHoverClass(event) { event.target.classList.remove(this.bgClass) }, + + mounted() { + this.el.addEventListener("drop", event => { + event.preventDefault() + const payload = this.getTaskPayload(event) + const taskID = this.parseTaskPayload(payload) + if (taskID !== null) { + const planID = this.el.dataset.planId + this.addTaskToPlan({ "task_id": taskID, "plan_id": planID, }) + this.removeHoverClass(event) + } + }) + + this.el.addEventListener("dragover", event => event.preventDefault()) + + this.el.addEventListener("dragenter", event => { + const payload = this.getTaskPayload(event) + const taskID = this.parseTaskPayload(payload) + if (taskID !== null) { this.addHoverClass(event) } + }) + + this.el.addEventListener("dragleave", event => { + const payload = this.getTaskPayload(event) + const taskID = this.parseTaskPayload(payload) + if (taskID !== null) { this.removeHoverClass(event) } + }) + } +} + +Hooks.DeleteDropper = { + get hoverBGClass() { return "has-background-warning"}, + + get baseBGClass() { return "has-background-danger"}, + + deleteTaskFromPlan(payload) { return this.pushEvent("delete-task-from-plan", payload) }, + + getTaskPayload(event) { return event.dataTransfer.getData("text/plain") }, + + parseTaskPayload(payload) { return payload.startsWith("task-id:") ? payload.split(":")[1] : null }, + + addHoverClass(event) { + event.target.classList.add(this.hoverBGClass) + event.target.classList.remove(this.baseBGClass) + }, + + removeHoverClass(event) { + event.target.classList.remove(this.hoverBGClass) + event.target.classList.add(this.baseBGClass) + }, + + mounted() { + this.el.addEventListener("drop", event => { + event.preventDefault() + const payload = this.getTaskPayload(event) + const taskID = this.parseTaskPayload(payload) + if (taskID !== null) { + const planID = this.el.dataset.drop + this.deleteTaskFromPlan({ "task_id": taskID, "plan_id": planID, }) + this.removeHoverClass(event) + } + }) + + this.el.addEventListener("dragover", event => event.preventDefault()) + + this.el.addEventListener("dragenter", event => { + const payload = this.getTaskPayload(event) + const taskID = this.parseTaskPayload(payload) + if (taskID !== null) { this.addHoverClass(event) } + }) + + this.el.addEventListener("dragleave", event => { + const payload = this.getTaskPayload(event) + const taskID = this.parseTaskPayload(payload) + if (taskID !== null) { this.removeHoverClass(event) } + }) + } +} + let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") -let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks}) // Connect if there are any LiveViews on the page liveSocket.connect() @@ -33,15 +160,15 @@ liveSocket.connect() window.liveSocket = liveSocket document.addEventListener('DOMContentLoaded', () => { - const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); + const $navbarBurgers = Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0) if ($navbarBurgers.length > 0) { $navbarBurgers.forEach( el => { el.addEventListener('click', () => { - const target = el.dataset.target; - const $target = document.getElementById(target); - el.classList.toggle('is-active'); - $target.classList.toggle('is-active'); - }); - }); + const target = el.dataset.target + const $target = document.getElementById(target) + el.classList.toggle('is-active') + $target.classList.toggle('is-active') + }) + }) } -}); +}) diff --git a/lib/planner/tasks.ex b/lib/planner/tasks.ex index 7088335..fab47c3 100644 --- a/lib/planner/tasks.ex +++ b/lib/planner/tasks.ex @@ -1,8 +1,12 @@ defmodule Planner.Tasks do import Ecto.Query + + alias Ecto.Multi alias Ecto.UUID alias Planner.Repo alias Planner.Tasks.Task + alias Planner.Tasks.Plan + alias Planner.Tasks.PlanDetail def list_all_tasks, do: Repo.all(Task) @@ -15,6 +19,20 @@ defmodule Planner.Tasks do |> Repo.all() end + def list_unfinished_tasks_by_plan_id(plan_id) do + q = + Ecto.Query.from( + t in Task, + join: pd in PlanDetail, + on: t.id == pd.task_id, + where: pd.plan_id == ^plan_id and is_nil(t.finished_at), + order_by: [desc: t.updated_at] + ) + + Repo.all(q) + |> Repo.preload(:plans) + end + def list_finished_tasks do from( t in Task, @@ -32,6 +50,15 @@ defmodule Planner.Tasks do |> Repo.insert() end + def create_task_and_add_to_plan(task_attrs, plan) do + Multi.new() + |> Multi.insert(:task, Task.changeset(%Task{}, task_attrs)) + |> Multi.run(:plan_detail, fn _repo, %{task: task} -> + create_plan_detail(%{"task_id" => task.id, "plan_id" => plan.id}) + end) + |> Repo.transaction() + end + def update_task(%Task{} = task, attrs) do task |> Task.changeset(attrs) @@ -69,4 +96,89 @@ defmodule Planner.Tasks do _ -> task_exists?(task_id) end end + + def list_plans do + Repo.all(Plan) + end + + def list_unfinished_plans do + from( + p in Plan, + where: is_nil(p.finished_at), + order_by: [desc: p.updated_at] + ) + |> Repo.all() + end + + def get_plan!(id), do: Repo.get!(Plan, id) + + def create_plan(attrs \\ %{}) do + %Plan{} + |> Plan.changeset(attrs) + |> Repo.insert() + end + + def update_plan(%Plan{} = plan, attrs) do + plan + |> Plan.changeset(attrs) + |> Repo.update() + end + + def delete_plan(%Plan{} = plan) do + Repo.delete(plan) + end + + def change_plan(%Plan{} = plan, attrs \\ %{}) do + Plan.changeset(plan, attrs) + end + + def plan_exists?(id), do: Repo.exists?(from(p in Plan, where: p.id == ^id)) + + def finish_plan_by_id!(id) do + get_plan!(id) + |> Plan.finish_plan() + |> Repo.update() + end + + def verify_plan_id_from_url(plan_id) do + plan_id = + case UUID.dump(plan_id) do + # don't actually want the dumped UUID, so discard + {:ok, _} -> plan_id + :error -> :error + end + + case plan_id do + :error -> :error + _ -> plan_exists?(plan_id) + end + end + + def list_plan_details do + Repo.all(PlanDetail) + end + + def get_plan_detail!(id), do: Repo.get!(PlanDetail, id) + + def get_plan_detail_by!(clauses), do: Repo.get_by!(PlanDetail, clauses) + + def create_plan_detail(attrs \\ %{}, on_conflict \\ :nothing) do + %PlanDetail{} + |> PlanDetail.changeset(attrs) + |> Repo.insert(on_conflict: on_conflict) + end + + def update_plan_detail(%PlanDetail{} = plan_detail, attrs) do + plan_detail + |> PlanDetail.changeset(attrs) + |> Repo.update() + end + + def delete_plan_detail(%PlanDetail{} = plan_detail) do + Repo.delete(plan_detail) + end + + def change_plan_detail(%PlanDetail{} = plan_detail, attrs \\ %{}) do + PlanDetail.changeset(plan_detail, attrs) + end end diff --git a/lib/planner/tasks/plan.ex b/lib/planner/tasks/plan.ex new file mode 100644 index 0000000..77cd977 --- /dev/null +++ b/lib/planner/tasks/plan.ex @@ -0,0 +1,29 @@ +defmodule Planner.Tasks.Plan do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "plans" do + field :description, :string + field :finished_at, :naive_datetime + field :end, :naive_datetime + field :name, :string + field :start, :naive_datetime + + timestamps() + end + + def changeset(plan, attrs) do + plan + |> cast(attrs, [:description, :finished_at, :start, :end, :name]) + |> validate_required([:name]) + end + + def finish_plan(plan) do + # TODO, this should check if `finished_at` is not nil, first + change(plan, finished_at: now()) + end + + defp now(), do: NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) +end diff --git a/lib/planner/tasks/plan_detail.ex b/lib/planner/tasks/plan_detail.ex new file mode 100644 index 0000000..87fe14f --- /dev/null +++ b/lib/planner/tasks/plan_detail.ex @@ -0,0 +1,19 @@ +defmodule Planner.Tasks.PlanDetail do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "plan_details" do + field :sort, :integer + field :task_id, :binary_id + field :plan_id, :binary_id + + timestamps() + end + + def changeset(plan_detail, attrs) do + plan_detail + |> cast(attrs, [:sort, :task_id, :plan_id]) + end +end diff --git a/lib/planner/tasks/task.ex b/lib/planner/tasks/task.ex index 1235370..7b73d4b 100644 --- a/lib/planner/tasks/task.ex +++ b/lib/planner/tasks/task.ex @@ -10,6 +10,8 @@ defmodule Planner.Tasks.Task do field(:finished_at, :naive_datetime) field(:due_at, :naive_datetime) + many_to_many(:plans, Planner.Tasks.Plan, join_through: "plan_details", on_delete: :delete_all) + timestamps() end diff --git a/lib/planner_web/live/tasks_components.ex b/lib/planner_web/live/tasks_components.ex index a689a39..66ab8d7 100644 --- a/lib/planner_web/live/tasks_components.ex +++ b/lib/planner_web/live/tasks_components.ex @@ -21,7 +21,7 @@ defmodule TasksComponent do ~L"""