NEW: plans (#33)

Fixes #9
This commit is contained in:
Matthew Ryan Dillon 2020-08-23 15:19:10 -07:00 committed by GitHub
parent 177067ab00
commit 9af0124138
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 608 additions and 60 deletions

View file

@ -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')
})
})
}
});
})

View file

@ -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

29
lib/planner/tasks/plan.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -21,7 +21,7 @@ defmodule TasksComponent do
~L"""
<div class="content">
<%= f = form_for(@changeset, "#", [phx_submit: "new-task"]) %>
<div class="field">
<div id="adder" class="field">
<div class="control">
<%= text_input(f,
:value,
@ -33,6 +33,17 @@ defmodule TasksComponent do
</div>
</form>
<%= if(!is_nil(@active_plan)) do %>
<div
id="deleter"
phx-hook="DeleteDropper"
data-drop="<%= @active_plan.id %>"
class="has-background-danger"
style="height: 38px; width: 100%"
hidden=true
></div>
<% end %>
<ul class="tasks">
<%= for task <- @tasks do %>
<%= live_component(@socket,
@ -73,7 +84,7 @@ defmodule TaskComponent do
<div class="ml-5-5">
<%= if(@is_active) do %>
<%= case @live_action do %>
<% :show -> %>
<% :show_task -> %>
<%= live_component(@socket,
TaskDetailsComponent,
id: "task_details:#{@task.id}",
@ -81,7 +92,7 @@ defmodule TaskComponent do
route_index_tasks: @route_index_tasks,
route_edit_task: @route_edit_task
)%>
<% :edit -> %>
<% :edit_task -> %>
<%= live_component(@socket,
TaskEditComponent,
id: "task_edit:#{@task.id}",
@ -116,7 +127,7 @@ defmodule TaskDetailsComponent do
def render(assigns) do
~L"""
<div class="box">
<div class="box" id="task-details-<%= @task.id %>" draggable="true" phx-hook="Dragger" data-task-id="<%= @task.id %>">
<%= live_patch("",
to: @route_index_tasks.(@socket),
class: "delete is-pulled-right"

View file

@ -2,48 +2,165 @@ defmodule PlannerWeb.TasksLive do
use PlannerWeb, :live_view
alias Planner.Tasks
alias Planner.Tasks.Plan
def mount(_params, _session, socket) do
socket =
socket
|> assign(:tasks, Tasks.list_unfinished_tasks())
|> assign(:active_task, nil)
|> assign(:plans, Tasks.list_unfinished_plans())
|> assign(:plan_changeset, Tasks.change_plan(%Plan{}))
{:ok, socket}
end
def handle_params(%{"task_id" => task_id}, _, socket) do
case Tasks.verify_task_id_from_url(task_id) do
true -> {:noreply, assign(socket, :active_task, task_id)}
_ -> {:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
# plan: yes, task: yes
def handle_params(%{"plan_id" => plan_id, "task_id" => task_id}, _, socket) do
case Tasks.verify_plan_id_from_url(plan_id) and Tasks.verify_task_id_from_url(task_id) do
true ->
socket =
socket
|> assign(:active_task, task_id)
|> assign(:active_plan, Tasks.get_plan!(plan_id))
|> assign(:tasks, Tasks.list_unfinished_tasks_by_plan_id(plan_id))
|> add_plan_routes(plan_id)
{:noreply, socket}
_ ->
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
end
end
# plan: no, task: yes
def handle_params(%{"task_id" => task_id}, _, socket) do
case Tasks.verify_task_id_from_url(task_id) do
true ->
socket =
socket
|> assign(:active_task, task_id)
|> assign(:active_plan, nil)
|> assign(:tasks, Tasks.list_unfinished_tasks())
|> add_task_routes()
{:noreply, assign(socket, :active_task, task_id)}
_ ->
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
end
end
# plan: yes, task: no
def handle_params(%{"plan_id" => plan_id}, _, socket) do
case Tasks.verify_plan_id_from_url(plan_id) do
true ->
socket =
socket
|> assign(:active_task, nil)
|> assign(:active_plan, Tasks.get_plan!(plan_id))
|> assign(:tasks, Tasks.list_unfinished_tasks_by_plan_id(plan_id))
|> add_plan_routes(plan_id)
{:noreply, socket}
_ ->
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
end
end
# plan: no, task: no
def handle_params(_, _, socket) do
{:noreply, assign(socket, :active_task, nil)}
socket =
socket
|> assign(:active_task, nil)
|> assign(:active_plan, nil)
|> assign(:tasks, Tasks.list_unfinished_tasks())
|> add_task_routes()
{:noreply, socket}
end
def render(assigns) do
~L"""
<div phx-window-keydown="keydown" phx-key="Escape">
<div class="columns" phx-window-keydown="keydown" phx-key="Escape">
<div class="column is-one-quarter">
<h4 class="title is-4">plans</h4>
<nav class="panel">
<%= f = form_for(@plan_changeset, "#", phx_submit: "new-plan", class: "panel-block") %>
<div class="control">
<%= text_input(f,
:name,
placeholder: "add new plan",
class: "input", autocomplete: "off"
)%>
<%= error_tag(f, :name) %>
</div>
</form>
<%= live_patch("all unfinished tasks", to: Routes.tasks_path(@socket, :index), class: "panel-block") %>
<%= for plan <- @plans do %>
<%= live_patch(
plan.name,
to: Routes.tasks_path(@socket, :show_plan, plan.id),
class: "panel-block",
phx_hook: "AddDropper",
data_plan_id: plan.id
) %>
<% end %>
</nav>
</div>
<div class="column">
<%= case @active_plan do %>
<%= nil -> %>
<h4 class="title is-4">all unfinished tasks</h4>
<% _ -> %>
<h4 class="title is-4">
<button
type="button"
role="checkbox"
class="doit"
phx-click="finish-plan"
phx-value-plan-id="<%= @active_plan.id %>">
</button>
&nbsp;
<%= @active_plan.name %>
</h4>
<% end %>
<%= live_component(@socket,
TasksComponent,
id: :all_unfinished_tasks,
id: :tasks,
live_action: @live_action,
tasks: @tasks,
active_plan: @active_plan,
active_task: @active_task,
route_show_task: &(Routes.tasks_path(&1, :show, &2)),
route_edit_task: &(Routes.tasks_path(&1, :edit, &2)),
route_index_tasks: &(Routes.tasks_path(&1, :index))
route_show_task: @route_show_task,
route_edit_task: @route_edit_task,
route_index_tasks: @route_index_tasks
)%>
</div>
</div>
"""
end
def handle_event("new-plan", %{"plan" => plan_params}, socket) do
case Tasks.create_plan(plan_params) do
{:ok, _plan} ->
{:noreply, assign(socket, plans: Tasks.list_unfinished_plans())}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, plan_changeset: changeset)}
end
end
def handle_event("finish-plan", %{"plan-id" => plan_id}, socket) do
{_, plan} = Tasks.finish_plan_by_id!(plan_id)
socket = put_flash(socket, :info, "finished plan \"#{plan.name}\"")
socket = assign(socket, plans: Tasks.list_unfinished_plans())
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
end
def handle_event("keydown", _params, socket) do
route = get_index_route(socket)
case socket.assigns.live_action do
:index -> {:noreply, socket}
_ -> {:noreply, push_patch(socket, to: Routes.tasks_path(socket, :index))}
_ -> {:noreply, push_patch(socket, to: route)}
end
end
@ -57,7 +174,9 @@ defmodule PlannerWeb.TasksLive do
socket
|> refresh_tasks_and_flash_msg("task \"#{task.value}\" updated")
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :show, task.id))}
route = get_index_route(socket)
{:noreply, push_patch(socket, to: route)}
{:error, changeset} ->
send_update(TaskEditComponent, id: "task_edit:#{task.id}", changeset: changeset)
@ -72,27 +191,83 @@ defmodule PlannerWeb.TasksLive do
def handle_event("delete-task", %{"task-id" => task_id}, socket) do
{_, task} = Tasks.delete_task_by_id!(task_id)
{:noreply, refresh_tasks_and_flash_msg(socket, "task \"#{task.value}\" deleted")}
socket = refresh_tasks_and_flash_msg(socket, "task \"#{task.value}\" deleted")
route = get_index_route(socket)
{:noreply, push_patch(socket, to: route)}
end
def handle_event("new-task", %{"task" => task_params}, socket) do
add_new_task(task_params, socket.assigns.active_plan, socket)
end
def handle_event("add-task-to-plan", plan_detail_params, socket) do
{_, pd} = Tasks.create_plan_detail(plan_detail_params)
{:noreply,
refresh_tasks_and_flash_msg(socket, "task #{pd.task_id} added to plan #{pd.plan_id}")}
end
def handle_event("delete-task-from-plan", %{"task_id" => task_id, "plan_id" => plan_id}, socket) do
plan_detail = Tasks.get_plan_detail_by!(task_id: task_id, plan_id: plan_id)
{_, pd} = Tasks.delete_plan_detail(plan_detail)
{:noreply,
refresh_tasks_and_flash_msg(socket, "task #{pd.task_id} removed from plan #{pd.plan_id}")}
end
defp refresh_tasks_and_flash_msg(socket, msg) do
tasks =
case socket.assigns.active_plan do
nil -> Tasks.list_unfinished_tasks()
plan -> Tasks.list_unfinished_tasks_by_plan_id(plan.id)
end
socket
|> assign(:tasks, tasks)
|> put_flash(:info, msg)
end
defp add_new_task(task_params, active_plan = nil, socket) do
case Tasks.create_task(task_params) do
{:ok, task} ->
socket =
socket
|> refresh_tasks_and_flash_msg("task \"#{task.value}\" created")
{:noreply, push_patch(socket, to: Routes.tasks_path(socket, :show, task.id))}
{:noreply, refresh_tasks_and_flash_msg(socket, "task \"#{task.value}\" created")}
{:error, %Ecto.Changeset{} = changeset} ->
send_update(TasksComponent, id: :all_unfinished_tasks, changeset: changeset)
send_update(TasksComponent, id: :tasks, changeset: changeset)
{:noreply, socket}
end
end
defp refresh_tasks_and_flash_msg(socket, msg) do
defp add_new_task(task_params, active_plan, socket) do
case Tasks.create_task_and_add_to_plan(task_params, active_plan) do
{:ok, %{plan_detail: _, task: task}} ->
{:noreply, refresh_tasks_and_flash_msg(socket, "task \"#{task.value}\" created")}
{:error, :task, %Ecto.Changeset{} = changeset, _} ->
send_update(TasksComponent, id: :tasks, changeset: changeset)
{:noreply, socket}
end
end
defp add_plan_routes(socket, plan_id) do
socket
|> assign(:tasks, Tasks.list_unfinished_tasks())
|> put_flash(:info, msg)
|> assign(:route_show_task, &Routes.tasks_path(&1, :show_task, plan_id, &2))
|> assign(:route_edit_task, &Routes.tasks_path(&1, :edit_task, plan_id, &2))
|> assign(:route_index_tasks, &Routes.tasks_path(&1, :show_plan, plan_id))
end
defp add_task_routes(socket) do
socket
|> assign(:route_show_task, &Routes.tasks_path(&1, :show_task, &2))
|> assign(:route_edit_task, &Routes.tasks_path(&1, :edit_task, &2))
|> assign(:route_index_tasks, &Routes.tasks_path(&1, :index))
end
defp get_index_route(socket) do
case socket.assigns.active_plan do
nil -> Routes.tasks_path(socket, :index)
plan -> Routes.tasks_path(socket, :show_plan, plan.id)
end
end
end

View file

@ -53,8 +53,12 @@ defmodule PlannerWeb.Router do
pipe_through([:browser, :require_authenticated_user])
live("/", TasksLive, :index)
live("/:task_id", TasksLive, :show)
live("/:task_id/edit", TasksLive, :edit)
live("/tasks", TasksLive, :index)
live("/tasks/:task_id", TasksLive, :show_task)
live("/tasks/:task_id/edit", TasksLive, :edit_task)
live("/plans/:plan_id/tasks", TasksLive, :show_plan)
live("/plans/:plan_id/tasks/:task_id", TasksLive, :show_task)
live("/plans/:plan_id/tasks/:task_id/edit", TasksLive, :edit_task)
get("/users/settings", UserSettingsController, :edit)
put("/users/settings/update_password", UserSettingsController, :update_password)

View file

@ -0,0 +1,16 @@
defmodule Planner.Repo.Migrations.CreatePlans do
use Ecto.Migration
def change do
create table(:plans, primary_key: false) do
add :id, :binary_id, primary_key: true
add :description, :string
add :finished_at, :naive_datetime
add :start, :naive_datetime
add :end, :naive_datetime
add :name, :string
timestamps()
end
end
end

View file

@ -0,0 +1,18 @@
defmodule Planner.Repo.Migrations.CreatePlanDetails do
use Ecto.Migration
def change do
create table(:plan_details, primary_key: false) do
add :id, :binary_id, primary_key: true
add :sort, :integer
add :task_id, references(:tasks, on_delete: :nothing, type: :binary_id)
add :plan_id, references(:plans, on_delete: :nothing, type: :binary_id)
timestamps()
end
create index(:plan_details, [:task_id])
create index(:plan_details, [:plan_id])
create unique_index(:plan_details, [:task_id, :plan_id])
end
end

View file

@ -1,11 +1,46 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Planner.Repo.insert!(%Planner.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Planner.Tasks
tasks_records = [
%{"value" => "task1"},
%{"value" => "task2"},
%{"value" => "task3"},
%{"value" => "task4"},
%{"value" => "task5"},
%{"value" => "task6"}
]
tasks =
Enum.map(tasks_records, fn record ->
{:ok, task} = Tasks.create_task(record)
task
end)
plans_records = [
%{name: "plan1"},
%{name: "plan2"},
%{name: "plan3"}
]
plans =
Enum.map(plans_records, fn record ->
{:ok, plan} = Tasks.create_plan(record)
plan
end)
[t1, t2, t3, t4, t5, _] = tasks
[p1, p2, _] = plans
plan_details_records = [
%{plan_id: p1.id, task_id: t1.id, sort: 0},
%{plan_id: p1.id, task_id: t2.id, sort: 0},
%{plan_id: p1.id, task_id: t3.id, sort: 0},
%{plan_id: p2.id, task_id: t4.id, sort: 0},
%{plan_id: p2.id, task_id: t5.id, sort: 0}
# deliberately leave off the last task
]
Enum.each(plan_details_records, fn record ->
Tasks.create_plan_detail(record)
end)