diff --git a/.tool-versions b/.tool-versions index 0e49cfcd..de9b652f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.16.2-otp-26 -erlang 26.0.2 +elixir 1.16.3-otp-26 +erlang 26.2.5 diff --git a/Dockerfile b/Dockerfile index e768f706..5c49c9fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,8 @@ # - https://pkgs.org/ - resource for finding needed packages # - Ex: hexpm/elixir:1.15.7-erlang-26.0.2-debian-bullseye-20231009-slim # -ARG ELIXIR_VERSION=1.16.2 -ARG OTP_VERSION=26.0.2 +ARG ELIXIR_VERSION=1.16.3 +ARG OTP_VERSION=26.2.5 ARG DEBIAN_VERSION=bullseye-20240513-slim ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" diff --git a/assets/js/hooks/events/handler.ts b/assets/js/hooks/events/handler.ts index cccd08b7..29dff401 100644 --- a/assets/js/hooks/events/handler.ts +++ b/assets/js/hooks/events/handler.ts @@ -6,7 +6,9 @@ import { deleteItem, focusItem, moveItem, + getItemById, } from "../item"; +import { getNodeByItem } from "../node"; export function handleList({ nodes }: { nodes: Node[] }) { const container: HTMLDivElement = this.el.querySelector(".children"); @@ -36,12 +38,23 @@ export function handleList({ nodes }: { nodes: Node[] }) { focusItem(lastItem); } -export function handleInsert({ node }: { node: Node }) { +export function handleInsert({ + node, + next_id, +}: { + node: Node; + next_id: string | undefined; +}) { const container: HTMLDivElement = this.el.querySelector(".children"); const item = createItem(node); container.append(item); moveItem(node, container); + const nextItem = getItemById(next_id) as HTMLDivElement; + const nextNode = getNodeByItem(nextItem); + nextNode.prev_id = node.uuid; + nextNode.dirty = false; + moveItem(nextNode, container); } export function handleContentChange({ node }: { node: Node }) { diff --git a/assets/js/hooks/events/listener.ts b/assets/js/hooks/events/listener.ts index d2eaf413..064a9a5a 100644 --- a/assets/js/hooks/events/listener.ts +++ b/assets/js/hooks/events/listener.ts @@ -7,6 +7,8 @@ import { getItemByNode, moveItem, setItemDirty, + setItemParent, + setItemPrev, } from "../item"; import { getNodeByEvent, getNodeByItem } from "../node"; @@ -92,6 +94,14 @@ export function keydown(event: KeyboardEvent) { const newItem = createItem(newNode); item.after(newItem); focusItem(newItem, false); + + const oldNextItem = newItem.nextSibling as HTMLDivElement | null; + if (oldNextItem) { + const nextNode = getNodeByItem(oldNextItem); + nextNode.prev_id = newNode.uuid; + nextNode.dirty = true; + setItemPrev(oldNextItem, newNode.uuid); + } break; case "Backspace": diff --git a/assets/js/hooks/item.ts b/assets/js/hooks/item.ts index 38489e36..7f70a2fe 100644 --- a/assets/js/hooks/item.ts +++ b/assets/js/hooks/item.ts @@ -15,26 +15,22 @@ export function createItem({ uuid, content, parent_id, prev_id, dirty }: Node) { const item = document.createElement("div"); item.id = `outline-node-${uuid}`; - item.className = "my-1 bg-gray-100 data-[dirty=true]:bg-red-100"; + item.className = "my-1 data-[dirty=true]:bg-red-100"; item.setAttribute("data-parent", parent_id || ""); item.setAttribute("data-prev", prev_id || ""); const link = document.createElement("a"); - link.className = "block float-left my-0.5 bg-gray-200 rounded-full"; + link.className = "block float-left my-0.5 rounded-full"; link.href = `#${uuid}`; link.innerHTML = ''; item.appendChild(link); - const contentWrap = document.createElement("div"); - contentWrap.className = "ml-5 bg-gray-300 content"; - contentWrap.contentEditable = "true"; - item.appendChild(contentWrap); - - const span = document.createElement("span"); - span.className = "bg-gray-400 innerContent"; - span.textContent = content || " "; - contentWrap.appendChild(span); + const input = document.createElement("div"); + input.className = "ml-5 content"; + input.contentEditable = "true"; + input.textContent = content || ""; + item.appendChild(input); const childContainer = document.createElement("div"); childContainer.className = "ml-5 children"; @@ -51,8 +47,8 @@ export function changeItemContent({ uuid, content, dirty }: Node) { const newContent = content || ""; - const span = item.querySelector(".innerContent") as HTMLSpanElement; - if (span.textContent != newContent) span.textContent = newContent; + const input = item.querySelector(".content") as HTMLDivElement; + if (input.textContent != newContent) input.textContent = newContent; setItemDirty(item, dirty); @@ -62,7 +58,7 @@ export function changeItemContent({ uuid, content, dirty }: Node) { export function moveItem( { uuid, parent_id, prev_id, dirty }: Node, container: HTMLDivElement, - force: boolean = false + force: boolean = false, ) { const item = getItemById(uuid); if (!item) return; @@ -75,8 +71,8 @@ export function moveItem( ) return; - item.setAttribute("data-parent", parent_id || ""); - item.setAttribute("data-prev", prev_id || ""); + setItemParent(item, parent_id); + setItemPrev(item, prev_id); const prevItem = getItemById(prev_id); const parentItem = getItemById(parent_id); @@ -94,6 +90,16 @@ export function moveItem( return item; } +export function setItemParent( + item: HTMLDivElement, + parent_id: string | undefined, +) { + item.setAttribute("data-parent", parent_id || ""); +} +export function setItemPrev(item: HTMLDivElement, prev_id: string | undefined) { + item.setAttribute("data-prev", prev_id || ""); +} + export function deleteItem({ uuid }: Node) { const item = getItemById(uuid); if (!item) return; @@ -112,7 +118,7 @@ export function getItemByNode({ uuid }: Node) { return getItemById(uuid); } -function getItemById(uuid: string | undefined) { +export function getItemById(uuid: string | undefined) { if (!uuid) return null; return document.getElementById(`outline-node-${uuid}`) as HTMLDivElement; @@ -126,12 +132,12 @@ export function getItemByEvent(event: Event): HTMLDivElement { } export function focusItem(item: HTMLDivElement, toEnd: boolean = true) { - const contentWrap = item.querySelector(".innerContent") as HTMLDivElement; - contentWrap.focus(); + const input = item.querySelector(".content") as HTMLDivElement; + input.focus(); if (toEnd) { const range = document.createRange(); - range.setStart(contentWrap, 0); + range.setStart(input, 0); range.collapse(true); const selection = window.getSelection(); diff --git a/assets/js/hooks/node.ts b/assets/js/hooks/node.ts index f19dcea5..41519e4d 100644 --- a/assets/js/hooks/node.ts +++ b/assets/js/hooks/node.ts @@ -9,8 +9,8 @@ export function getNodeByEvent(event: Event): Node { export function getNodeByItem(item: HTMLDivElement): Node { const uuid = item.id.split("outline-node-")[1] as UUID; - const span = item.querySelector(".innerContent") as HTMLSpanElement; - const content = span.textContent || ""; + const input = item.querySelector(".content") as HTMLDivElement; + const content = input.textContent || ""; const parent_id = (item.getAttribute("data-parent") as UUID) || undefined; const prev_id = (item.getAttribute("data-prev") as UUID) || undefined; diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index 1eb99f9b..4dac6fda 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -1,3 +1,16 @@ +defmodule Radiator.Outline.NodeRepoResult do + @moduledoc """ + Generic result structure for node operations. + """ + defstruct [ + :node, + :old_prev_id, + :old_next_id, + :next_id, + :children + ] +end + defmodule Radiator.Outline do @moduledoc """ The Outline context. @@ -6,6 +19,7 @@ defmodule Radiator.Outline do import Ecto.Query, warn: false alias Radiator.Outline.Node + alias Radiator.Outline.NodeRepoResult alias Radiator.Outline.NodeRepository alias Radiator.Repo @@ -17,7 +31,7 @@ defmodule Radiator.Outline do ## Examples iex> insert_node(%{content: 'foo'}) - {:ok, %Node{}} + {:ok, %NodeRepoResult{}} iex> insert_node(%{content: value}) {:error, :parent_and_prev_not_consistent} @@ -34,7 +48,7 @@ defmodule Radiator.Outline do parent_node_id = attrs["parent_id"] episode_id = attrs["episode_id"] # find Node which has been previously connected to prev_node - node_to_move = + next_node = Node |> where(episode_id: ^episode_id) |> where_prev_node_equals(prev_node_id) @@ -45,9 +59,9 @@ defmodule Radiator.Outline do prev_node <- NodeRepository.get_node_if(prev_node_id), true <- parent_and_prev_consistent?(parent_node, prev_node), {:ok, node} <- NodeRepository.create_node(attrs), - {:ok, _node_to_move} <- move_node_if(node_to_move, nil, node.uuid), + {:ok, _node_to_move} <- move_node_if(next_node, nil, node.uuid), {:ok, node} <- move_node_if(node, parent_node_id, prev_node_id) do - node + %NodeRepoResult{node: node, next_id: get_node_id(next_node)} else false -> Repo.rollback("Insert node failed. Parent and prev node are not consistent.") diff --git a/lib/radiator/outline/event/node_deleted_event.ex b/lib/radiator/outline/event/node_deleted_event.ex index 78e4ed8c..1bbf0f46 100644 --- a/lib/radiator/outline/event/node_deleted_event.ex +++ b/lib/radiator/outline/event/node_deleted_event.ex @@ -1,4 +1,4 @@ defmodule Radiator.Outline.Event.NodeDeletedEvent do @moduledoc false - defstruct [:event_id, :node_id, :user_id] + defstruct [:event_id, :node_id, :user_id, :children] end diff --git a/lib/radiator/outline/event/node_inserted_event.ex b/lib/radiator/outline/event/node_inserted_event.ex index 78e2a5a4..783063aa 100644 --- a/lib/radiator/outline/event/node_inserted_event.ex +++ b/lib/radiator/outline/event/node_inserted_event.ex @@ -1,5 +1,5 @@ defmodule Radiator.Outline.Event.NodeInsertedEvent do @moduledoc false - defstruct [:event_id, :node, :user_id] + defstruct [:event_id, :node, :user_id, :next_id] end diff --git a/lib/radiator/outline/event/node_moved_event.ex b/lib/radiator/outline/event/node_moved_event.ex index 41c6abe8..2e774cbc 100644 --- a/lib/radiator/outline/event/node_moved_event.ex +++ b/lib/radiator/outline/event/node_moved_event.ex @@ -1,4 +1,13 @@ defmodule Radiator.Outline.Event.NodeMovedEvent do @moduledoc false - defstruct [:event_id, :node_id, :parent_id, :prev_id, :user_id] + defstruct [ + :event_id, + :node_id, + :parent_id, + :prev_id, + :user_id, + :old_prev_id, + :old_next_id, + :next_id + ] end diff --git a/lib/radiator/outline/event_consumer.ex b/lib/radiator/outline/event_consumer.ex index 1d85a21b..acfa2125 100644 --- a/lib/radiator/outline/event_consumer.ex +++ b/lib/radiator/outline/event_consumer.ex @@ -5,6 +5,7 @@ defmodule Radiator.Outline.EventConsumer do alias Radiator.EventStore alias Radiator.Outline + alias Radiator.Outline.NodeRepoResult alias Radiator.Outline.Command.{ ChangeNodeContentCommand, @@ -80,8 +81,13 @@ defmodule Radiator.Outline.EventConsumer do :ok end - defp handle_insert_node_result({:ok, node}, command) do - %NodeInsertedEvent{node: node, event_id: command.event_id, user_id: command.user_id} + defp handle_insert_node_result({:ok, %NodeRepoResult{node: node, next_id: next_id}}, command) do + %NodeInsertedEvent{ + node: node, + event_id: command.event_id, + user_id: command.user_id, + next_id: next_id + } |> EventStore.persist_event() |> Dispatch.broadcast() diff --git a/lib/radiator_web/controllers/api/outline_controller.ex b/lib/radiator_web/controllers/api/outline_controller.ex index 87d7cde4..18c269ef 100644 --- a/lib/radiator_web/controllers/api/outline_controller.ex +++ b/lib/radiator_web/controllers/api/outline_controller.ex @@ -40,6 +40,6 @@ defmodule RadiatorWeb.Api.OutlineController do }) end - defp get_response({:ok, node}), do: {200, %{uuid: node.uuid}} + defp get_response({:ok, %{node: node}}), do: {200, %{uuid: node.uuid}} defp get_response({:error, _}), do: {400, %{error: "params"}} end diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index 8ce3c576..397525df 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -157,9 +157,9 @@ defmodule RadiatorWeb.EpisodeLive.Index do |> reply(:noreply) end - def handle_info(%NodeInsertedEvent{node: node}, socket) do + def handle_info(%NodeInsertedEvent{node: node, next_id: next_id}, socket) do socket - |> push_event("insert", %{node: node}) + |> push_event("insert", %{node: node, next_id: next_id}) |> reply(:noreply) end diff --git a/test/radiator/event_store_test.exs b/test/radiator/event_store_test.exs index bfc28f64..d3dddb48 100644 --- a/test/radiator/event_store_test.exs +++ b/test/radiator/event_store_test.exs @@ -2,26 +2,65 @@ defmodule Radiator.EventStoreTest do use Radiator.DataCase alias Radiator.EventStore + alias Radiator.EventStore.EventData - describe "event_data" do - alias Radiator.EventStore.EventData + alias Radiator.AccountsFixtures + import Radiator.EventStoreFixtures - alias Radiator.AccountsFixtures - import Radiator.EventStoreFixtures + describe "persist_event/1" do + test "persists node_inserted_event" do + user = AccountsFixtures.user_fixture() + event = node_inserted_event_fixture(user_id: user.id) - @invalid_attrs %{data: nil, uuid: nil, event_type: nil} + num_events = EventStore.list_event_data() |> length() + EventStore.persist_event(event) + assert EventStore.list_event_data() |> length() == num_events + 1 + end - test "list_event_data/0 returns all event_data" do + test "persists node_content_changed_event" do + user = AccountsFixtures.user_fixture() + event = node_content_changed_event_fixture(user_id: user.id) + + num_events = EventStore.list_event_data() |> length() + EventStore.persist_event(event) + assert EventStore.list_event_data() |> length() == num_events + 1 + end + + test "persists node_deleted_event" do + user = AccountsFixtures.user_fixture() + event = node_deleted_event_fixture(user_id: user.id) + + num_events = EventStore.list_event_data() |> length() + EventStore.persist_event(event) + assert EventStore.list_event_data() |> length() == num_events + 1 + end + + test "persists node_moved_event" do + user = AccountsFixtures.user_fixture() + event = node_moved_event_fixture(user_id: user.id) + + num_events = EventStore.list_event_data() |> length() + EventStore.persist_event(event) + assert EventStore.list_event_data() |> length() == num_events + 1 + end + end + + describe "list_event_data/0" do + test "returns all event_data" do event = event_data_fixture() assert EventStore.list_event_data() == [event] end + end - test "get_event!/1 returns the event_data with given id" do + describe "get_event_data!/1" do + test "returns the event_data with given id" do event = event_data_fixture() assert EventStore.get_event_data!(event.uuid) == event end + end - test "create_event/1 with valid data creates a event" do + describe "create_event_data/1" do + test " with valid data creates a event" do user = AccountsFixtures.user_fixture() valid_attrs = %{ @@ -38,8 +77,9 @@ defmodule Radiator.EventStoreTest do assert event.user_id == valid_attrs.user_id end - test "create_event/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = EventStore.create_event_data(@invalid_attrs) + test "with invalid data returns error changeset" do + invalid_attrs = %{data: nil, uuid: nil, event_type: nil} + assert {:error, %Ecto.Changeset{}} = EventStore.create_event_data(invalid_attrs) end end end diff --git a/test/radiator/outline_test.exs b/test/radiator/outline_test.exs index f6a20fba..64e71b39 100644 --- a/test/radiator/outline_test.exs +++ b/test/radiator/outline_test.exs @@ -67,7 +67,7 @@ defmodule Radiator.OutlineTest do "prev_id" => node_3.uuid } - assert {:ok, %Node{uuid: node3_1_uuid} = node} = Outline.insert_node(node_attrs) + assert {:ok, %{node: %Node{uuid: node3_1_uuid} = node}} = Outline.insert_node(node_attrs) assert node.parent_id == node_3.parent_id assert node.prev_id == node_3.uuid @@ -106,7 +106,7 @@ defmodule Radiator.OutlineTest do "prev_id" => nested_node_1.uuid } - {:ok, new_node} = Outline.insert_node(node_attrs) + {:ok, %{node: new_node}} = Outline.insert_node(node_attrs) assert new_node.parent_id == node_3.uuid end @@ -121,7 +121,7 @@ defmodule Radiator.OutlineTest do "prev_id" => nested_node_1.uuid } - {:ok, new_node} = Outline.insert_node(node_attrs) + {:ok, %{node: new_node}} = Outline.insert_node(node_attrs) assert new_node.prev_id == nested_node_1.uuid end @@ -137,7 +137,7 @@ defmodule Radiator.OutlineTest do "prev_id" => nested_node_1.uuid } - {:ok, new_node} = Outline.insert_node(node_attrs) + {:ok, %{node: new_node}} = Outline.insert_node(node_attrs) assert NodeRepository.get_node!(nested_node_2.uuid).prev_id == new_node.uuid assert new_node.prev_id == nested_node_1.uuid @@ -156,7 +156,7 @@ defmodule Radiator.OutlineTest do "prev_id" => nested_node_2.uuid } - {:ok, new_node} = Outline.insert_node(node_attrs) + {:ok, %{node: new_node}} = Outline.insert_node(node_attrs) assert NodeRepository.get_node!(nested_node_2.uuid).prev_id == nested_node_1.uuid assert new_node.prev_id == nested_node_2.uuid @@ -174,7 +174,7 @@ defmodule Radiator.OutlineTest do "parent_id" => node_3.uuid } - {:ok, new_node} = Outline.insert_node(node_attrs) + {:ok, %{node: new_node}} = Outline.insert_node(node_attrs) assert new_node.prev_id == nil assert NodeRepository.get_node!(nested_node_1.uuid).prev_id == new_node.uuid @@ -187,7 +187,7 @@ defmodule Radiator.OutlineTest do # another node in another episode without parent and prev node node_fixture(parent_id: nil, prev_id: nil) node_attrs = %{"content" => "new node", "episode_id" => parent_node.episode_id} - {:ok, new_node} = Outline.insert_node(node_attrs) + {:ok, %{node: new_node}} = Outline.insert_node(node_attrs) assert new_node.prev_id == nil assert new_node.parent_id == nil diff --git a/test/support/fixtures/event_store_fixtures.ex b/test/support/fixtures/event_store_fixtures.ex index eac8d4d9..22abf557 100644 --- a/test/support/fixtures/event_store_fixtures.ex +++ b/test/support/fixtures/event_store_fixtures.ex @@ -5,6 +5,15 @@ defmodule Radiator.EventStoreFixtures do """ alias Radiator.AccountsFixtures + alias Radiator.Outline.Event.{ + NodeContentChangedEvent, + NodeDeletedEvent, + NodeInsertedEvent, + NodeMovedEvent + } + + alias Radiator.OutlineFixtures + @doc """ Generate a event data. """ @@ -23,4 +32,49 @@ defmodule Radiator.EventStoreFixtures do event end + + def node_inserted_event_fixture(user_id: user_id) do + node = OutlineFixtures.node_fixture() + + %NodeInsertedEvent{ + node: node, + user_id: user_id, + event_id: Ecto.UUID.generate() + } + end + + def node_content_changed_event_fixture(user_id: user_id) do + node = OutlineFixtures.node_fixture() + + %NodeContentChangedEvent{ + node_id: node.uuid, + content: node.content, + user_id: user_id, + event_id: Ecto.UUID.generate() + } + end + + def node_deleted_event_fixture(user_id: user_id) do + node = OutlineFixtures.node_fixture() + + %NodeDeletedEvent{ + node_id: node.uuid, + user_id: user_id, + event_id: Ecto.UUID.generate() + } + end + + def node_moved_event_fixture(user_id: user_id) do + node = OutlineFixtures.node_fixture() + parent = OutlineFixtures.node_fixture(episode_id: node.episode_id) + prev = OutlineFixtures.node_fixture(episode_id: node.episode_id) + + %NodeMovedEvent{ + node_id: node.uuid, + user_id: user_id, + parent_id: parent.uuid, + prev_id: prev.uuid, + event_id: Ecto.UUID.generate() + } + end end