diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index 38d2a6e7..995a5e51 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -62,6 +62,51 @@ defmodule Radiator.Outline do |> Repo.get(id) end + @doc """ + Gets all nodes of an episode as a tree. + + ## Examples + + iex> get_node_tree(123) + [%Node{}, %Node{}, ..] + """ + # episode_id = 2 + # Radiator.Outline.get_node_tree(episode_id) + def get_node_tree(episode_id) do + + node_tree_initial_query = + Node + |> where([n], is_nil(n.parent_id)) + |> where([n], n.episode_id == ^episode_id) + |> select([n], %{uuid: n.uuid, content: n.content, parent_id: n.parent_id, prev_id: n.prev_id, level: 0}) + + node_tree_recursion_query = from outline_node in "outline_nodes", + join: node_tree in "node_tree", on: outline_node.parent_id == node_tree.uuid, + select: [outline_node.uuid, outline_node.content, outline_node.parent_id, outline_node.prev_id, node_tree.level + 1] + + node_tree_query = + node_tree_initial_query + |> union_all(^node_tree_recursion_query) + + tree = + "node_tree" + |> recursive_ctes(true) + |> with_cte("node_tree", as: ^node_tree_query) + |> select([n], %{uuid: n.uuid, content: n.content, parent_id: n.parent_id, prev_id: n.prev_id, level: n.level}) + |> Repo.all() + |> Enum.map(fn %{uuid: uuid, content: content, parent_id: parent_id, prev_id: prev_id, level: level} -> + %Node{uuid: binaray_uuid_to_ecto_uuid(uuid), content: content, parent_id: binaray_uuid_to_ecto_uuid(parent_id), prev_id: binaray_uuid_to_ecto_uuid(prev_id), level: level} + end) + {:ok, tree} + end + + defp binaray_uuid_to_ecto_uuid(nil), do: nil + defp binaray_uuid_to_ecto_uuid(uuid) do + Ecto.UUID.load!(uuid) + end + + + @doc """ Creates a node. @@ -76,66 +121,72 @@ defmodule Radiator.Outline do """ def create_node(attrs \\ %{}) do %Node{} - |> Node.changeset(attrs) + |> Node.insert_changeset(attrs) |> Repo.insert() |> broadcast_node_action(:insert) end def create_node(attrs, %{id: id}) do %Node{creator_id: id} - |> Node.changeset(attrs) + |> Node.insert_changeset(attrs) |> Repo.insert() |> broadcast_node_action(:insert) end @doc """ - Updates a node. + Updates a nodes content. ## Examples - iex> update_node(node, %{field: new_value}) + iex> update_node_content(node, %{content: new_value}) {:ok, %Node{}} - iex> update_node(node, %{field: bad_value}) + iex> update_node_content(node, %{content: nil}) {:error, %Ecto.Changeset{}} """ - def update_node(%Node{} = node, attrs) do + def update_node_content(%Node{} = node, attrs) do node - |> Node.changeset(attrs) + |> Node.update_content_changeset(attrs) |> Repo.update() |> broadcast_node_action(:update) end @doc """ - Deletes a node. + Moves a nodes to another parent. ## Examples - iex> delete_node(node) + iex> move_node(node, %Node{uuid: new_parent_id}) {:ok, %Node{}} - iex> delete_node(node) + iex> move_node(node, nil) {:error, %Ecto.Changeset{}} """ - def delete_node(%Node{} = node) do + def move_node(%Node{} = node, %Node{} = parent_node) do node - |> Repo.delete() - |> broadcast_node_action(:delete) + |> Node.move_changeset(parent_node) + |> Repo.update() + |> broadcast_node_action(:update) end @doc """ - Returns an `%Ecto.Changeset{}` for tracking node changes. + Deletes a node. ## Examples - iex> change_node(node) - %Ecto.Changeset{data: %Node{}} + iex> delete_node(node) + {:ok, %Node{}} + + iex> delete_node(node) + {:error, %Ecto.Changeset{}} """ - def change_node(%Node{} = node, attrs \\ %{}) do - Node.changeset(node, attrs) + def delete_node(%Node{} = node) do + node + |> Repo.delete() + |> broadcast_node_action(:delete) end defp broadcast_node_action({:ok, node}, action) do diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index a078b009..42fc7265 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -10,38 +10,72 @@ defmodule Radiator.Outline.Node do @derive {Jason.Encoder, only: [:uuid, :content, :creator_id, :parent_id, :prev_id]} @primary_key {:uuid, :binary_id, autogenerate: true} + schema "outline_nodes" do field :content, :string field :creator_id, :integer field :parent_id, Ecto.UUID field :prev_id, Ecto.UUID + field :level, :integer, virtual: true belongs_to :episode, Episode timestamps(type: :utc_datetime) end - @required_fields [ - :content, - :episode_id - ] - - @optional_fields [ - :creator_id, - :parent_id, - :prev_id - ] + @doc """ + A changeset for inserting a new node + Work in progress. Since we currently ignore the tree structure, there is + no concept for a root node. + Also questionable wether a node really needs a content from beginning. So probably a root + doesnt have a content + Another issue might be we need to create the uuid upfront and pass it here + """ + def insert_changeset(node, attributes) do + node + |> cast(attributes, [:content, :episode_id, :creator_id, :parent_id, :prev_id]) + |> update_change(:content, &trim/1) + |> validate_required([:content, :episode_id]) + end - @all_fields @optional_fields ++ @required_fields + @doc """ + Changeset for moving a node + Only the parent_id is allowed and expected to be changed + """ + def move_changeset(node, new_parent_node) do + node + |> cast(%{parent_id: new_parent_node.uuid}, [:parent_id]) + |> validate_parent(new_parent_node) + end - @doc false - def changeset(node, attrs) do + @doc """ + Changeset for updating the content of a node + """ + def update_content_changeset(node, attrs) do node - |> cast(attrs, @all_fields) + |> cast(attrs, [:content]) |> update_change(:content, &trim/1) - |> validate_required(@required_fields) + |> validate_required([:content]) end defp trim(content) when is_binary(content), do: String.trim(content) defp trim(content), do: content + + defp validate_parent(changeset, nil), do: add_error(changeset, :parent_id, "must not be nil") + + defp validate_parent(changeset, parent_node) do + cond do + parent_node.uuid == changeset.data.uuid -> + add_error(changeset, :parent_id, "must not be the same as the node itself") + + parent_node.parent_id == changeset.data.uuid -> + add_error(changeset, :parent_id, "node is already parent of the parent node") + + parent_node.episode_id != changeset.data.episode_id -> + add_error(changeset, :parent_id, "nodes must be in the same episode") + + true -> + changeset + end + end end diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index 23622ba5..92d9ba74 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -64,7 +64,7 @@ defmodule RadiatorWeb.EpisodeLive.Index do case Outline.get_node(uuid) do nil -> nil - node -> Outline.update_node(node, attrs) + node -> Outline.update_node_content(node, attrs) end socket diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 03a5e3b0..d5e39037 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -19,7 +19,7 @@ alias Radiator.{Accounts, Outline, Podcast} {:ok, show} = Podcast.create_show(%{title: "Tech Weekly", network_id: network.id}) -{:ok, _episode} = +{:ok, past_episode} = Podcast.create_episode(%{title: "past episode", show_id: show.id}) {:ok, current_episode} = @@ -60,3 +60,7 @@ alias Radiator.{Accounts, Outline, Podcast} episode_id: current_episode.id, prev_id: node211.uuid }) + + + {:ok, past_parent_node} = + Outline.create_node(%{content: "Old Content", episode_id: past_episode.id}) diff --git a/test/radiator/outline_test.exs b/test/radiator/outline_test.exs index 505364ef..29220a46 100644 --- a/test/radiator/outline_test.exs +++ b/test/radiator/outline_test.exs @@ -2,26 +2,31 @@ defmodule Radiator.OutlineTest do use Radiator.DataCase alias Radiator.Outline + alias Radiator.Outline.Node + alias Radiator.PodcastFixtures + alias Radiator.Repo - describe "outline_nodes" do - alias Radiator.Outline.Node + import Radiator.OutlineFixtures + import Ecto.Query, warn: false - import Radiator.OutlineFixtures - alias Radiator.PodcastFixtures + @invalid_attrs %{content: nil} - @invalid_attrs %{content: nil} - - test "list_nodes/0 returns all nodes" do + describe "list_nodes/0" do + test "returns all nodes" do node = node_fixture() assert Outline.list_nodes() == [node] end + end - test "get_node!/1 returns the node with given id" do + describe "get_node!/1" do + test "returns the node with given id" do node = node_fixture() assert Outline.get_node!(node.uuid) == node end + end - test "create_node/1 with valid data creates a node" do + describe "create_node/1" do + test "with valid data creates a node" do episode = PodcastFixtures.episode_fixture() valid_attrs = %{content: "some content", episode_id: episode.id} @@ -29,7 +34,7 @@ defmodule Radiator.OutlineTest do assert node.content == "some content" end - test "create_node/1 trims whitespace from content" do + test "trims whitespace from content" do episode = PodcastFixtures.episode_fixture() valid_attrs = %{content: " some content ", episode_id: episode.id} @@ -37,7 +42,7 @@ defmodule Radiator.OutlineTest do assert node.content == "some content" end - test "create_node/1 can have a creator" do + test "can have a creator" do episode = PodcastFixtures.episode_fixture() user = %{id: 2} valid_attrs = %{content: "some content", episode_id: episode.id} @@ -47,33 +52,132 @@ defmodule Radiator.OutlineTest do assert node.creator_id == user.id end - test "create_node/1 with invalid data returns error changeset" do + test "with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = Outline.create_node(@invalid_attrs) end + end - test "update_node/2 with valid data updates the node" do + describe "update_node_content/2" do + test "with valid data updates the node" do node = node_fixture() update_attrs = %{content: "some updated content"} - assert {:ok, %Node{} = node} = Outline.update_node(node, update_attrs) + assert {:ok, %Node{} = node} = Outline.update_node_content(node, update_attrs) assert node.content == "some updated content" end - test "update_node/2 with invalid data returns error changeset" do + test "with invalid data returns error changeset" do node = node_fixture() - assert {:error, %Ecto.Changeset{}} = Outline.update_node(node, @invalid_attrs) + assert {:error, %Ecto.Changeset{}} = Outline.update_node_content(node, @invalid_attrs) assert node == Outline.get_node!(node.uuid) end + end - test "delete_node/1 deletes the node" do + # describe "move_node/2" do + # test "moves node to another parent" do + # node = node_fixture() + # new_parent = node_fixture(episode_id: node.episode_id) + + # assert {:ok, %Node{} = node} = Outline.move_node(node, new_parent) + # assert node.parent_id == new_parent.uuid + # end + + # test "update_node_content/2 with parent from another episode returns error changeset" do + # node = node_fixture() + # new_bad_parent = node_fixture() + # assert node.episode_id != new_bad_parent.episode_id + + # assert {:error, %Ecto.Changeset{}} = Outline.move_node(node, new_bad_parent) + # assert node == Outline.get_node!(node.uuid) + # end + # end + + # describe "sort_node/2" do + # setup :complex_node_fixture + + # test "moves node 6 to top", %{ + # node_1: node_1, + # node_2: node_2, + # node_3: node_3, + # node_4: node_4, + # node_5: node_5, + # node_6: node_6, + # parent: parent + # } do + # assert {:ok, %Node{} = node} = Outline.sort_node(node_6, nil) + # assert node.parent_id == new_parent.uuid + # end + # end + + describe "delete_node/1" do + test "deletes the node" do node = node_fixture() assert {:ok, %Node{}} = Outline.delete_node(node) assert_raise Ecto.NoResultsError, fn -> Outline.get_node!(node.uuid) end end + end - test "change_node/1 returns a node changeset" do - node = node_fixture() - assert %Ecto.Changeset{} = Outline.change_node(node) + describe "get_node_tree/1" do + setup :complex_node_fixture + + test "returns all nodes from a episode", %{ + # node_1: node_1, + # node_2: node_2, + # node_3: node_3, + # node_4: node_4, + # node_5: node_5, + # node_6: node_6, + # nested_node_1: nested_node_1, + # nested_node_2: nested_node_2, + parent: parent + } do + episode_id = parent.episode_id + assert {:ok, tree} = Outline.get_node_tree(episode_id) + + all_nodes = + Node + |> where([n], n.episode_id == ^episode_id) + |> Repo.all() + assert Enum.count(tree) == Enum.count(all_nodes) + Enum.each(tree, fn node -> + assert node.uuid == List.first(Enum.filter(all_nodes, fn n -> n.uuid == node.uuid end)).uuid + end) + end + + test "does not return a node not in this episode", %{ + parent: parent + } do + episode_id = parent.episode_id + other_node = node_fixture(parent_id: nil, prev_id: nil, content: "other content") + assert other_node.episode_id != episode_id + {:ok, tree} = Outline.get_node_tree(episode_id) + assert Enum.filter(tree, fn n -> n.uuid == other_node.uuid end) == [] end end + + defp complex_node_fixture(_) do + episode = PodcastFixtures.episode_fixture() + parent = node_fixture(episode_id: episode.id, parent_id: nil, prev_id: nil) + node_1 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: nil) + node_2 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_1.uuid) + node_3 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_2.uuid) + node_4 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_3.uuid) + node_5 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_4.uuid) + node_6 = node_fixture(episode_id: episode.id, parent_id: parent.uuid, prev_id: node_5.uuid) + + nested_node_1 = node_fixture(episode_id: episode.id, parent_id: node_3.uuid, prev_id: nil) + nested_node_2 = node_fixture(episode_id: episode.id, parent_id: node_3.uuid, prev_id: nested_node_1.uuid) + + %{ + node_1: node_1, + node_2: node_2, + node_3: node_3, + node_4: node_4, + node_5: node_5, + node_6: node_6, + nested_node_1: nested_node_1, + nested_node_2: nested_node_2, + parent: parent + } + end end