From 2294c2a3cd638e1ccae8015a3501e7c9f222808b Mon Sep 17 00:00:00 2001 From: sorax Date: Thu, 8 Feb 2024 17:23:50 +0100 Subject: [PATCH] refactor: outline-hook --- assets/js/hooks/index.ts | 123 +++++++++--------- assets/js/hooks/item.ts | 37 +++--- assets/js/hooks/node.ts | 19 +++ assets/js/hooks/types.ts | 10 +- lib/extension/phoenix/socket.ex | 1 + lib/radiator/outline.ex | 25 ++-- lib/radiator/outline/node.ex | 2 +- .../controllers/api/outline_controller.ex | 9 +- .../live/admin_live/index.html.heex | 8 +- lib/radiator_web/live/episode_live/index.ex | 36 ++--- .../live/episode_live/index.html.heex | 2 +- test/radiator/outline_test.exs | 26 ++-- test/radiator_web/live/episode_live_test.exs | 14 +- test/support/fixtures/outline_fixtures.ex | 6 +- 14 files changed, 177 insertions(+), 141 deletions(-) create mode 100644 assets/js/hooks/node.ts diff --git a/assets/js/hooks/index.ts b/assets/js/hooks/index.ts index d075db8e..d5ec1ccc 100644 --- a/assets/js/hooks/index.ts +++ b/assets/js/hooks/index.ts @@ -1,10 +1,11 @@ -import { createItem, updateItem, focusItem, getItemByEvent, getItemById, getNodeByItem, getNodeByEvent } from "./item" import { Node } from "./types" +import { createItem, updateItem, deleteItem, getItemByNode, focusItem } from "./item" +import { getNodeByEvent, getNodeByItem } from "./node" export const Hooks = { outline: { mounted() { - const container: HTMLElement = this.el + const container: HTMLOListElement = this.el container.addEventListener("focusin", (event: FocusEvent) => { const { uuid } = getNodeByEvent(event) @@ -30,31 +31,34 @@ export const Hooks = { const node = getNodeByEvent(event) - const item = getItemByEvent(event) - const prevItem = item.previousSibling - const nextItem = item.nextSibling + const item = getItemByNode(node)! + const prevItem = item.previousSibling as HTMLLIElement | null + const nextItem = item.nextSibling as HTMLLIElement | null + + const prevNode = prevItem && getNodeByItem(prevItem) + const nextNode = nextItem && getNodeByItem(nextItem) switch (event.key) { case "ArrowUp": if (selection?.anchorOffset != 0) return event.preventDefault() - if (!prevItem) return - // otherwise parentItem + if (!prevItem || !prevNode) return + // TODO: if no prevItem exists, try to select the parent item focusItem(prevItem) - this.pushEvent("set_focus", node.uuid) + this.pushEvent("set_focus", prevNode.uuid) break case "ArrowDown": if (selection?.anchorOffset != node.content.length) return event.preventDefault() - if (!nextItem) return - // otherwise firstChildItem + if (!nextItem || !nextNode) return + // TODO: if no nextItem exists, try to select the first child focusItem(nextItem) - this.pushEvent("set_focus", node.uuid) + this.pushEvent("set_focus", nextNode.uuid) break case "Enter": @@ -66,6 +70,7 @@ export const Hooks = { node.content = content?.substring(0, splitPos) updateItem(node, container) + this.pushEvent("update_node", node) const newNode: Node = { temp_id: self.crypto.randomUUID(), @@ -74,28 +79,25 @@ export const Hooks = { prev_id: node.uuid } - const newItem = createItem(newNode) - item.after(newItem) - - focusItem(newItem, false) - - this.pushEvent("update_node", node) - this.pushEvent("create_node", newNode) + this.pushEvent("create_node", newNode, (node: Node, _ref: Number) => { + const newItem = createItem(node) + item.after(newItem) + focusItem(newItem, false) + }) break case "Backspace": if (selection?.anchorOffset != 0) return event.preventDefault() - if (!prevItem) return + if (!prevItem || !prevNode) return - const prevNode = getNodeByItem(prevItem) prevNode.content += node.content updateItem(prevNode, container) - - item.parentNode?.removeChild(item) - focusItem(prevItem) + this.pushEvent("update_node", node) + + deleteItem(node) this.pushEvent("delete_node", node.uuid) break @@ -103,43 +105,42 @@ export const Hooks = { if (selection?.anchorOffset != node.content.length) return event.preventDefault() - if (!nextItem) return + if (!nextItem || !nextNode) return - const nextNode = getNodeByItem(nextItem) node.content += nextNode.content updateItem(node, container) - - nextItem.parentNode?.removeChild(nextItem) - focusItem(item) + this.pushEvent("update_node", node) + + deleteItem(nextNode) this.pushEvent("delete_node", nextNode.uuid) break - case "Tab": - event.preventDefault() - - if (event.shiftKey) { - if (node.parent_id) { - node.prev_id = node.parent_id - node.parent_id = undefined - - updateItem(node, container) - focusItem(item) - - this.pushEvent("update_node", node) - } - } else { - if (node.prev_id) { - node.parent_id = node.prev_id - node.prev_id = undefined - - updateItem(node, container) - focusItem(item) - - this.pushEvent("update_node", node) - } - } - break + // case "Tab": + // event.preventDefault() + + // if (event.shiftKey) { + // if (node.parent_id) { + // // outdent + // node.prev_id = node.parent_id + // node.parent_id = undefined + // updateItem(node, container) + + // focusItem(item) + // this.pushEvent("update_node", node) + // } + // } else { + // if (node.prev_id) { + // // indent + // node.parent_id = node.prev_id + // node.prev_id = undefined // TODO: prev_id should be the id of the last child of the parent node + // updateItem(node, container) + + // focusItem(item) + // this.pushEvent("update_node", node) + // } + // } + // break } }) @@ -147,12 +148,10 @@ export const Hooks = { // console.log("keyup", event) // }) - this.handleEvent("list", ({ nodes }) => { + this.handleEvent("list", ({ nodes }: { nodes: Node[] }) => { if ((nodes.length) == 0) { - nodes = [{ - temp_id: self.crypto.randomUUID(), - content: "", - }] + const node: Node = { temp_id: self.crypto.randomUUID(), content: "" } + nodes = [node] } // add all items @@ -177,13 +176,11 @@ export const Hooks = { // }) // this.handleEvent("update", (node: Node) => { - // // console.log(node) - // // updateItem(node) + // updateItem(node, container) // }) - // this.handleEvent("delete", ({ uuid }: Node) => { - // const item = getItemById(uuid!) - // item.parentNode!.removeChild(item) + // this.handleEvent("delete", (node: Node) => { + // deleteItem(node) // }) } } diff --git a/assets/js/hooks/item.ts b/assets/js/hooks/item.ts index 044a05b1..f9f5fc7a 100644 --- a/assets/js/hooks/item.ts +++ b/assets/js/hooks/item.ts @@ -3,8 +3,7 @@ import { Node } from "./types" export function createItem({ uuid, temp_id, content, parent_id, prev_id }: Node) { const input = document.createElement("div") input.textContent = content - // input.contentEditable = "plaintext-only" - input.contentEditable = "true" + input.contentEditable = "true" // firefox does not support "plaintext-only" const ol = document.createElement("ol") ol.className = "list-disc" @@ -24,8 +23,8 @@ export function createItem({ uuid, temp_id, content, parent_id, prev_id }: Node) return item } -export function updateItem({ uuid, temp_id, content, parent_id, prev_id }: Node, container: HTMLElement) { - const item = getItemById(temp_id || uuid!) +export function updateItem({ uuid, temp_id, content, parent_id, prev_id }: Node, container: HTMLOListElement) { + const item = getItemById(temp_id || uuid) if (!item) return temp_id && uuid && (item.id = "outline-node-" + uuid) @@ -48,36 +47,30 @@ export function updateItem({ uuid, temp_id, content, parent_id, prev_id }: Node, } } -export function getItemById(uuid: string | undefined) { - if (!uuid) return null +export function deleteItem({ uuid }: Node) { + const item = getItemById(uuid) + if (!item) return - return document.getElementById("outline-node-" + uuid) + item.parentNode!.removeChild(item) } -export function getNodeByEvent(event: Event): Node { - const item = getItemByEvent(event) +export function getItemByNode({ uuid, temp_id }: Node) { + return getItemById(temp_id || uuid) +} + +function getItemById(uuid: string | undefined) { + if (!uuid) return null - return getNodeByItem(item) + return document.getElementById("outline-node-" + uuid) as HTMLLIElement } export function getItemByEvent(event: Event): HTMLLIElement { - const target = event.target + const target = event.target const item = target.parentElement! return item } -export function getNodeByItem(item: HTMLLIElement): Node { - const uuid = item.id.split("outline-node-")[1] - const input = item.firstChild as HTMLDivElement - const content = input.textContent! - - const parent_id = item.getAttribute("data-parent") || undefined - const prev_id = item.getAttribute("data-prev") || undefined - - return { uuid, content, parent_id, prev_id } -} - export function focusItem(item: HTMLLIElement, toEnd: boolean = true) { const input = item.firstChild as HTMLDivElement input.focus() diff --git a/assets/js/hooks/node.ts b/assets/js/hooks/node.ts new file mode 100644 index 00000000..41168f40 --- /dev/null +++ b/assets/js/hooks/node.ts @@ -0,0 +1,19 @@ +import { UUID, Node } from "./types" +import { getItemByEvent } from "./item" + +export function getNodeByEvent(event: Event): Node { + const item = getItemByEvent(event) + + return getNodeByItem(item) +} + +export function getNodeByItem(item: HTMLLIElement): Node { + const uuid = item.id.split("outline-node-")[1] as UUID + const input = item.firstChild 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 + + return { uuid, content, parent_id, prev_id } +} diff --git a/assets/js/hooks/types.ts b/assets/js/hooks/types.ts index bb7d970d..87b9fb13 100644 --- a/assets/js/hooks/types.ts +++ b/assets/js/hooks/types.ts @@ -1,8 +1,10 @@ +export type UUID = `${string}-${string}-${string}-${string}-${string}` + export interface Node { - uuid?: string - temp_id?: string + uuid?: UUID + temp_id?: UUID content: string creator_id?: number - parent_id?: string - prev_id?: string + parent_id?: UUID + prev_id?: UUID } diff --git a/lib/extension/phoenix/socket.ex b/lib/extension/phoenix/socket.ex index af6bf7a9..722d54bc 100644 --- a/lib/extension/phoenix/socket.ex +++ b/lib/extension/phoenix/socket.ex @@ -10,4 +10,5 @@ defmodule Extension.Phoenix.Socket do """ def reply(socket, reply) when is_atom(reply), do: {reply, socket} + def reply(socket, reply, data) when is_atom(reply) and is_map(data), do: {reply, data, socket} end diff --git a/lib/radiator/outline.ex b/lib/radiator/outline.ex index 38d2a6e7..d50ca3fd 100644 --- a/lib/radiator/outline.ex +++ b/lib/radiator/outline.ex @@ -8,7 +8,7 @@ defmodule Radiator.Outline do alias Radiator.Outline.Node alias Radiator.Repo - @topic "outline" + @topic "outline-node" @doc """ Returns the list of nodes. @@ -24,6 +24,22 @@ defmodule Radiator.Outline do |> Repo.all() end + @doc """ + Returns the list of nodes for an episode. + + ## Examples + + iex> list_nodes(123) + [%Node{}, ...] + + """ + + def list_nodes_by_episode(episode_id) do + Node + |> where([p], p.episode_id == ^episode_id) + |> Repo.all() + end + @doc """ Gets a single node. @@ -81,13 +97,6 @@ defmodule Radiator.Outline do |> broadcast_node_action(:insert) end - def create_node(attrs, %{id: id}) do - %Node{creator_id: id} - |> Node.changeset(attrs) - |> Repo.insert() - |> broadcast_node_action(:insert) - end - @doc """ Updates a node. diff --git a/lib/radiator/outline/node.ex b/lib/radiator/outline/node.ex index a078b009..8fb151ac 100644 --- a/lib/radiator/outline/node.ex +++ b/lib/radiator/outline/node.ex @@ -22,11 +22,11 @@ defmodule Radiator.Outline.Node do end @required_fields [ - :content, :episode_id ] @optional_fields [ + :content, :creator_id, :parent_id, :prev_id diff --git a/lib/radiator_web/controllers/api/outline_controller.ex b/lib/radiator_web/controllers/api/outline_controller.ex index 795d2d75..0d9143cd 100644 --- a/lib/radiator_web/controllers/api/outline_controller.ex +++ b/lib/radiator_web/controllers/api/outline_controller.ex @@ -32,8 +32,13 @@ defmodule RadiatorWeb.Api.OutlineController do defp create_node(nil, _, _), do: {:error, :user} defp create_node(_, _, nil), do: {:error, :episode} - defp create_node(user, content, episode_id), - do: Outline.create_node(%{"content" => content, "episode_id" => episode_id}, user) + defp create_node(user, content, episode_id) do + Outline.create_node(%{ + "content" => content, + "creator_id" => user.id, + "episode_id" => episode_id + }) + end defp get_response({:ok, node}), do: {200, %{uuid: node.uuid}} defp get_response({:error, _}), do: {400, %{error: "params"}} diff --git a/lib/radiator_web/live/admin_live/index.html.heex b/lib/radiator_web/live/admin_live/index.html.heex index df306170..fe540809 100644 --- a/lib/radiator_web/live/admin_live/index.html.heex +++ b/lib/radiator_web/live/admin_live/index.html.heex @@ -37,9 +37,9 @@

Create Network

<.form :let={f} for={@form} id="network-form" phx-change="validate" phx-submit="save"> <.input field={f[:title]} type="text" label="Title" /> -
+
Cancel @@ -54,9 +54,9 @@ <.form :let={f} for={@form} id="show-form" phx-change="validate" phx-submit="save"> <.input field={f[:title]} type="text" label="Title" /> <.input field={f[:network_id]} type="hidden" /> -
+
Cancel diff --git a/lib/radiator_web/live/episode_live/index.ex b/lib/radiator_web/live/episode_live/index.ex index 23622ba5..bacc25ae 100644 --- a/lib/radiator_web/live/episode_live/index.ex +++ b/lib/radiator_web/live/episode_live/index.ex @@ -5,7 +5,7 @@ defmodule RadiatorWeb.EpisodeLive.Index do alias Radiator.Podcast alias RadiatorWeb.Endpoint - @topic "outline" + @topic "outline-node" @impl true def mount(%{"show" => show_id}, _session, socket) do @@ -26,10 +26,11 @@ defmodule RadiatorWeb.EpisodeLive.Index do @impl true def handle_params(params, _uri, socket) do episode = get_selected_episode(params) + nodes = get_nodes(episode) socket |> assign(:selected_episode, episode) - |> push_event("list", %{nodes: Outline.list_nodes()}) + |> push_event("list", %{nodes: nodes}) |> reply(:noreply) end @@ -46,17 +47,13 @@ defmodule RadiatorWeb.EpisodeLive.Index do def handle_event("create_node", %{"temp_id" => temp_id} = params, socket) do user = socket.assigns.current_user - episode_id = socket.assigns.selected_episode.id - attrs = Map.put(params, "episode_id", episode_id) + episode = socket.assigns.selected_episode + attrs = Map.merge(params, %{"creator_id" => user.id, "episode_id" => episode.id}) - socket = - case Outline.create_node(attrs, user) do - {:ok, node} -> socket |> push_event("update", Map.put(node, :temp_id, temp_id)) - _ -> socket - end - - socket - |> reply(:noreply) + case Outline.create_node(attrs) do + {:ok, node} -> socket |> reply(:reply, Map.put(node, :temp_id, temp_id)) + _ -> socket |> reply(:noreply) + end end def handle_event("update_node", %{"uuid" => uuid} = params, socket) do @@ -82,21 +79,21 @@ defmodule RadiatorWeb.EpisodeLive.Index do end @impl true - def handle_info({:insert, _node}, socket) do + def handle_info({:insert, node}, socket) do socket - # |> push_event("insert", node) + |> push_event("insert", node) |> reply(:noreply) end - def handle_info({:update, _node}, socket) do + def handle_info({:update, node}, socket) do socket - # |> push_event("update", node) + |> push_event("update", node) |> reply(:noreply) end - def handle_info({:delete, _node}, socket) do + def handle_info({:delete, node}, socket) do socket - # |> push_event("delete", node) + |> push_event("delete", node) |> reply(:noreply) end @@ -107,4 +104,7 @@ defmodule RadiatorWeb.EpisodeLive.Index do defp get_selected_episode(%{"show" => show_id}) do Podcast.get_current_episode_for_show(show_id) end + + defp get_nodes(%{id: id}), do: Outline.list_nodes_by_episode(id) + defp get_nodes(_), do: [] end diff --git a/lib/radiator_web/live/episode_live/index.html.heex b/lib/radiator_web/live/episode_live/index.html.heex index 44179dc2..295ae610 100644 --- a/lib/radiator_web/live/episode_live/index.html.heex +++ b/lib/radiator_web/live/episode_live/index.html.heex @@ -8,7 +8,7 @@ :for={{episode, i} <- Enum.with_index(@episodes)} class={[episode.id == @selected_episode.id && "bg-[#f0f4f4]"]} > - <.link patch={~p"/admin/podcast/#{@show.id}/#{episode.id}"} class="flex gap-4 my-4"> + <.link patch={~p"/admin/podcast/#{@show}/#{episode}"} class="flex gap-4 my-4"> <%= episode.number %> <%= episode.title %> diff --git a/test/radiator/outline_test.exs b/test/radiator/outline_test.exs index 505364ef..c44e6ea9 100644 --- a/test/radiator/outline_test.exs +++ b/test/radiator/outline_test.exs @@ -9,11 +9,21 @@ defmodule Radiator.OutlineTest do import Radiator.OutlineFixtures alias Radiator.PodcastFixtures - @invalid_attrs %{content: nil} + @invalid_attrs %{episode_id: nil} test "list_nodes/0 returns all nodes" do - node = node_fixture() - assert Outline.list_nodes() == [node] + node1 = node_fixture() + node2 = node_fixture() + + assert Outline.list_nodes() == [node1, node2] + end + + test "list_nodes/1 returns only nodes of this episode" do + node1 = node_fixture() + node2 = node_fixture() + + assert Outline.list_nodes_by_episode(node1.episode_id) == [node1] + assert Outline.list_nodes_by_episode(node2.episode_id) == [node2] end test "get_node!/1 returns the node with given id" do @@ -37,16 +47,6 @@ defmodule Radiator.OutlineTest do assert node.content == "some content" end - test "create_node/1 can have a creator" do - episode = PodcastFixtures.episode_fixture() - user = %{id: 2} - valid_attrs = %{content: "some content", episode_id: episode.id} - - assert {:ok, %Node{} = node} = Outline.create_node(valid_attrs, user) - assert node.content == "some content" - assert node.creator_id == user.id - end - test "create_node/1 with invalid data returns error changeset" do assert {:error, %Ecto.Changeset{}} = Outline.create_node(@invalid_attrs) end diff --git a/test/radiator_web/live/episode_live_test.exs b/test/radiator_web/live/episode_live_test.exs index 83b1479a..61ba441c 100644 --- a/test/radiator_web/live/episode_live_test.exs +++ b/test/radiator_web/live/episode_live_test.exs @@ -29,16 +29,22 @@ defmodule RadiatorWeb.EpisodeLiveTest do end end - describe "Episode page has an outline" do + describe "Episode page" do setup %{conn: conn} do user = user_fixture() show = show_fixture() - _episode = episode_fixture(%{show_id: show.id}) - node = node_fixture() + episode = episode_fixture(%{show_id: show.id}) + node = node_fixture(%{episode_id: episode.id}) %{conn: log_in_user(conn, user), show: show, node: node} end + test "has the title of the episode", %{conn: conn, show: show} do + {:ok, live, _html} = live(conn, ~p"/admin/podcast/#{show.id}") + + assert page_title(live) =~ show.title + end + test "lists all nodes", %{conn: conn, show: show, node: node} do {:ok, live, _html} = live(conn, ~p"/admin/podcast/#{show.id}") @@ -58,7 +64,7 @@ defmodule RadiatorWeb.EpisodeLiveTest do |> Enum.find(&(&1.content == "new node temp content")) |> Map.put(:temp_id, temp_id) - assert_push_event(live, "update", ^node) + assert_reply(live, ^node) end test "update node", %{conn: conn, show: show, node: node} do diff --git a/test/support/fixtures/outline_fixtures.ex b/test/support/fixtures/outline_fixtures.ex index 37257fb2..0bc54a54 100644 --- a/test/support/fixtures/outline_fixtures.ex +++ b/test/support/fixtures/outline_fixtures.ex @@ -3,13 +3,14 @@ defmodule Radiator.OutlineFixtures do This module defines test helpers for creating entities via the `Radiator.Outline` context. """ + alias Radiator.Podcast alias Radiator.PodcastFixtures @doc """ Generate a node. """ def node_fixture(attrs \\ %{}) do - episode = PodcastFixtures.episode_fixture() + episode = get_episode(attrs) {:ok, node} = attrs @@ -21,4 +22,7 @@ defmodule Radiator.OutlineFixtures do node end + + defp get_episode(%{episode_id: id}), do: Podcast.get_episode!(id) + defp get_episode(_), do: PodcastFixtures.episode_fixture() end