diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4dceab..0511fa64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Enhancements + - Adds page to define custom JS Hooks - Updated UI styling with StationUI - Unsaved changes will now be saved when publishing, instead of discarded - Adds Unpublish button to Page editor diff --git a/lib/beacon/live_admin/client/content.ex b/lib/beacon/live_admin/client/content.ex index ba3dde33..23c52bdd 100644 --- a/lib/beacon/live_admin/client/content.ex +++ b/lib/beacon/live_admin/client/content.ex @@ -290,4 +290,24 @@ defmodule Beacon.LiveAdmin.Client.Content do def delete_info_handler(site, info_handler) do call(site, Beacon.Content, :delete_info_handler, [info_handler]) end + + def change_js_hook(site, js_hook, attrs \\ %{}) do + call(site, Beacon.Content, :change_js_hook, [js_hook, attrs]) + end + + def list_js_hooks(site) do + call(site, Beacon.Content, :list_js_hooks, [site]) + end + + def create_js_hook(site, attrs) do + call(site, Beacon.Content, :create_js_hook, [attrs]) + end + + def update_js_hook(site, js_hook, attrs) do + call(site, Beacon.Content, :update_js_hook, [js_hook, attrs]) + end + + def delete_js_hook(site, js_hook) do + call(site, Beacon.Content, :delete_js_hook, [js_hook]) + end end diff --git a/lib/beacon/live_admin/components/station_ui/html/form.ex b/lib/beacon/live_admin/components/station_ui/html/form.ex index a18f5bb0..0d0a7b35 100644 --- a/lib/beacon/live_admin/components/station_ui/html/form.ex +++ b/lib/beacon/live_admin/components/station_ui/html/form.ex @@ -86,9 +86,9 @@ defmodule Beacon.LiveAdmin.StationUI.HTML.Form do # with our gettext backend as first argument. Translations are # available in the errors.po file (as we use the "errors" domain). if count = opts[:count] do - Gettext.dngettext(StationUI.Gettext, "errors", msg, msg, count, opts) + Gettext.dngettext(Beacon.LiveAdmin.Gettext, "errors", msg, msg, count, opts) else - Gettext.dgettext(StationUI.Gettext, "errors", msg, opts) + Gettext.dgettext(Beacon.LiveAdmin.Gettext, "errors", msg, opts) end end diff --git a/lib/beacon/live_admin/live/home_live.ex b/lib/beacon/live_admin/live/home_live.ex index fe66451e..7b72a77e 100644 --- a/lib/beacon/live_admin/live/home_live.ex +++ b/lib/beacon/live_admin/live/home_live.ex @@ -57,6 +57,9 @@ defmodule Beacon.LiveAdmin.HomeLive do <.link href={Router.beacon_live_admin_path(@socket, site, "/error_pages")} class={nav_class()}> Error Pages + <.link href={Router.beacon_live_admin_path(@socket, site, "/hooks")} class={nav_class()}> + JS Hooks + <% end %> diff --git a/lib/beacon/live_admin/live/js_hook_editor_live/index.ex b/lib/beacon/live_admin/live/js_hook_editor_live/index.ex new file mode 100644 index 00000000..21d44750 --- /dev/null +++ b/lib/beacon/live_admin/live/js_hook_editor_live/index.ex @@ -0,0 +1,335 @@ +defmodule Beacon.LiveAdmin.JSHookEditorLive.Index do + @moduledoc false + use Beacon.LiveAdmin.PageBuilder + + alias Beacon.LiveAdmin.Client.Content + + def menu_link(_, :index), do: {:root, "JS Hooks"} + + def handle_params(params, _uri, socket) do + %{beacon_page: %{site: site}} = socket.assigns + + socket = + socket + |> assign(page_title: "JS Hooks") + |> assign(unsaved_changes: false) + |> assign(show_create_modal: false) + |> assign(show_nav_modal: false) + |> assign(show_delete_modal: false) + |> assign(create_form: to_form(%{}, as: :js_hook)) + |> assign_new(:js_hooks, fn -> Content.list_js_hooks(site) end) + |> assign_selected(params["id"]) + |> assign_form() + + {:noreply, socket} + end + + def handle_event("select-" <> id, _, socket) do + %{beacon_page: %{site: site}} = socket.assigns + + path = beacon_live_admin_path(socket, site, "/hooks/#{id}") + + if socket.assigns.unsaved_changes do + {:noreply, assign(socket, show_nav_modal: true, confirm_nav_path: path)} + else + {:noreply, push_navigate(socket, to: path)} + end + end + + def handle_event("set_" <> key, %{"value" => code}, socket) do + %{selected: selected, beacon_page: %{site: site}, form: form} = socket.assigns + + params = Map.merge(form.params, %{key => code}) |> IO.inspect(label: "params") + changeset = Content.change_js_hook(site, selected, params) |> IO.inspect(label: "changeset") + + socket = + socket + |> assign_form(changeset) + |> assign(unsaved_changes: !(changeset.changes == %{})) + + {:noreply, socket} + end + + def handle_event("create_new", _, socket) do + {:noreply, assign(socket, show_create_modal: true)} + end + + def handle_event("save_new", params, socket) do + %{beacon_page: %{site: site}} = socket.assigns + %{"js_hook" => %{"name" => name}} = params + + attrs = %{ + "name" => name, + "site" => site + } + + socket = + case Content.create_js_hook(site, attrs) do + {:ok, %{id: js_hook_id}} -> + socket + |> assign(js_hooks: Content.list_js_hooks(site)) + |> assign_selected(js_hook_id) + |> assign(show_create_modal: false) + |> push_navigate(to: beacon_live_admin_path(socket, site, "/hooks/#{js_hook_id}")) + + {:error, changeset} -> + assign(socket, create_form: to_form(changeset)) + end + + {:noreply, socket} + end + + def handle_event("save_changes", %{"js_hook" => params}, socket) do + %{selected: selected, beacon_page: %{site: site}} = socket.assigns + + # TODO: validate js code + + socket = + case Content.update_js_hook(site, selected, params) do + {:ok, updated_js_hook} -> + socket + |> assign_js_hook_update(updated_js_hook) + |> assign_selected(selected.id) + |> assign_form() + |> assign(unsaved_changes: false) + |> put_flash(:info, "JS Hook updated successfully") + + {:error, changeset} -> + changeset = Map.put(changeset, :action, :update) + assign(socket, form: to_form(changeset)) + end + + {:noreply, socket} + end + + def handle_event("delete", _, socket) do + {:noreply, assign(socket, show_delete_modal: true)} + end + + def handle_event("delete_confirm", _, socket) do + %{selected: js_hook, beacon_page: %{site: site}} = socket.assigns + + {:ok, _} = Content.delete_js_hook(site, js_hook) + + socket = + socket + |> assign(js_hooks: Content.list_js_hooks(site)) + |> push_patch(to: beacon_live_admin_path(socket, site, "/hooks")) + + {:noreply, socket} + end + + def handle_event("delete_cancel", _, socket) do + {:noreply, assign(socket, show_delete_modal: false)} + end + + def handle_event("stay_here", _params, socket) do + {:noreply, assign(socket, show_nav_modal: false, confirm_nav_path: nil)} + end + + def handle_event("discard_changes", _params, socket) do + {:noreply, push_navigate(socket, to: socket.assigns.confirm_nav_path)} + end + + def handle_event("cancel_create", _params, socket) do + {:noreply, assign(socket, show_create_modal: false)} + end + + defp assign_selected(socket, nil) do + case socket.assigns.js_hooks do + [] -> assign(socket, selected: nil) + [hd | _] -> assign(socket, selected: hd) + end + end + + defp assign_selected(socket, id) when is_binary(id) do + selected = Enum.find(socket.assigns.js_hooks, &(&1.id == id)) + assign(socket, selected: selected) + end + + defp assign_form(socket) do + form = + case socket.assigns do + %{selected: nil} -> + nil + + %{selected: selected, beacon_page: %{site: site}} -> + site + |> Content.change_js_hook(selected) + |> to_form() + end + + assign(socket, form: form) + end + + defp assign_form(socket, changeset) do + assign(socket, form: to_form(changeset)) + end + + defp assign_js_hook_update(socket, updated_js_hook) do + %{id: js_hook_id} = updated_js_hook + + js_hooks = + Enum.map(socket.assigns.js_hooks, fn + %{id: ^js_hook_id} -> updated_js_hook + other -> other + end) + + assign(socket, js_hooks: js_hooks) + end + + def render(assigns) do + ~H""" +
+ <.header> + <%= @page_title %> + <:actions> + <.button type="button" id="new-js-hook-button" phx-click="create_new" class="sui-primary uppercase"> + New JS Hook + + + + + <.main_content> + <.modal :if={@show_nav_modal} id="confirm-nav" on_cancel={JS.push("stay_here")} show> +

You've made unsaved changes to this JS Hook!

+

Navigating to another hook without saving will cause these changes to be lost.

+ <.button type="button" phx-click="stay_here" class="sui-secondary"> + Stay here + + <.button type="button" phx-click="discard_changes" class="sui-primary-destructive"> + Discard changes + + + + <.modal :if={@show_create_modal} id="create-modal" on_cancel={JS.push("cancel_create")} show> + <:title>New JS Hook + <.form :let={f} for={@create_form} id="create-form" phx-submit="save_new" class="px-4"> + <.input field={f[:name]} type="text" label="Hook name:" /> + <.button class="sui-primary mt-4">Save + + + + <.modal :if={@show_delete_modal} id="delete-modal" on_cancel={JS.push("delete_cancel")} show> +

Are you sure you want to delete this JS Hook?

+ <.button type="button" id="confirm-delete-button" phx-click="delete_confirm" class="sui-primary-destructive"> + Delete + + <.button type="button" phx-click="delete_cancel" class="sui-secondary"> + Cancel + + + +
+
+ <.table id="js-hooks" rows={@js_hooks} row_click={fn row -> "select-#{row.id}" end}> + <:col :let={js_hook} label="name"> + <%= Map.fetch!(js_hook, :name) %> + + +
+ +
+ <.form :let={f} for={@form} id="js-hook-form" class="flex items-end gap-4 mb-2" phx-submit="save_changes"> + <.input label="Name" field={f[:name]} type="text" /> + + + + + + + + <.button phx-disable-with="Saving..." class="sui-primary ml-auto">Save Changes + <.button id="delete-js-hook-button" type="button" phx-click="delete" class="sui-primary-destructive">Delete + + +

mounted()

+ <%= template_error(@form[:mounted]) %> +
+
+ "javascript"})} + /> +
+
+ +

beforeUpdate()

+ <%= template_error(@form[:beforeUpdate]) %> +
+
+ "javascript"})} + /> +
+
+ +

updated()

+ <%= template_error(@form[:updated]) %> +
+
+ "javascript"})} + /> +
+
+ +

destroyed()

+ <%= template_error(@form[:destroyed]) %> +
+
+ "javascript"})} + /> +
+
+ +

disconnected()

+ <%= template_error(@form[:disconnected]) %> +
+
+ "javascript"})} + /> +
+
+ +

reconnected()

+ <%= template_error(@form[:reconnected]) %> +
+
+ "javascript"})} + /> +
+
+
+
+ +
+ """ + end +end diff --git a/lib/beacon/live_admin/live/page_live.ex b/lib/beacon/live_admin/live/page_live.ex index 3d00f071..4c04d1bd 100644 --- a/lib/beacon/live_admin/live/page_live.ex +++ b/lib/beacon/live_admin/live/page_live.ex @@ -190,6 +190,8 @@ defmodule Beacon.LiveAdmin.PageLive do {_, "/info_handlers"} -> false {"/error_pages", _} -> true {_, "/error_pages"} -> false + {"/hooks", _} -> true + {_, "/hooks"} -> false {a, b} -> a <= b end end) diff --git a/lib/beacon/live_admin/router.ex b/lib/beacon/live_admin/router.ex index 60aa4d63..57398465 100644 --- a/lib/beacon/live_admin/router.ex +++ b/lib/beacon/live_admin/router.ex @@ -167,7 +167,10 @@ defmodule Beacon.LiveAdmin.Router do {"/info_handlers/:handler_id", Beacon.LiveAdmin.InfoHandlerEditorLive.Index, :index, %{}}, # error pages {"/error_pages", Beacon.LiveAdmin.ErrorPageEditorLive.Index, :index, %{}}, - {"/error_pages/:status", Beacon.LiveAdmin.ErrorPageEditorLive.Index, :index, %{}} + {"/error_pages/:status", Beacon.LiveAdmin.ErrorPageEditorLive.Index, :index, %{}}, + # js hooks + {"/hooks", Beacon.LiveAdmin.JSHookEditorLive.Index, :index, %{}}, + {"/hooks/:id", Beacon.LiveAdmin.JSHookEditorLive.Index, :index, %{}} ] |> Enum.concat(additional_pages) |> Enum.map(fn {path, module, live_action, opts} -> diff --git a/mix.exs b/mix.exs index de1c6cd6..191ef6e2 100644 --- a/mix.exs +++ b/mix.exs @@ -56,7 +56,7 @@ defmodule Beacon.LiveAdmin.MixProject do # Overridable override_dep(:phoenix, "~> 1.7", "PHOENIX_VERSION", "PHOENIX_PATH"), override_dep(:phoenix_live_view, "~> 0.20 or ~> 1.0", "PHOENIX_LIVE_VIEW_VERSION", "PHOENIX_LIVE_VIEW_PATH"), - override_dep(:live_monaco_editor, "~> 0.1", "LIVE_MONACO_EDITOR_VERSION", "LIVE_MONACO_EDITOR_PATH"), + override_dep(:live_monaco_editor, "~> 0.2", "LIVE_MONACO_EDITOR_VERSION", "LIVE_MONACO_EDITOR_PATH"), beacon_dep(), # Runtime diff --git a/test/beacon/live_admin/live/js_hook_editor_live/index_test.exs b/test/beacon/live_admin/live/js_hook_editor_live/index_test.exs new file mode 100644 index 00000000..d7555865 --- /dev/null +++ b/test/beacon/live_admin/live/js_hook_editor_live/index_test.exs @@ -0,0 +1,83 @@ +defmodule Beacon.LiveAdmin.JSHookEditorLive.IndexTest do + use Beacon.LiveAdmin.ConnCase, async: false + + import Beacon.LiveAdminTest.Cluster, only: [rpc: 4] + + setup do + on_exit(fn -> + rpc(node1(), MyApp.Repo, :delete_all, [Beacon.Content.JSHook, [log: false]]) + end) + + :ok + end + + test "select js hook via path", %{conn: conn} do + foo_hook = js_hook_fixture(node1(), name: "FooHook") + bar_hook = js_hook_fixture(node1(), name: "BarHook") + + {:ok, view, _html} = live(conn, "/admin/site_a/hooks/#{foo_hook.id}") + assert has_element?(view, "input[name='js_hook[name]'][value=FooHook]") + + {:ok, view, _html} = live(conn, "/admin/site_a/hooks/#{bar_hook.id}") + assert has_element?(view, "input[name='js_hook[name]'][value=BarHook]") + end + + test "create a new js hook", %{conn: conn} do + {:ok, view, _html} = live(conn, "/admin/site_a/hooks") + + view |> element("#new-js-hook-button") |> render_click() + + assert has_element?(view, "#create-modal") + + {:ok, view, _html} = + view + |> form("#create-form", %{js_hook: %{name: "MyTestHook"}}) + |> render_submit() + |> follow_redirect(conn) + + refute has_element?(view, "#create-modal") + assert has_element?(view, "input[name='js_hook[name]'][value=MyTestHook]") + end + + test "update a js hook", %{conn: conn} do + js_hook = js_hook_fixture(node1(), name: "InitHook") + {:ok, view, _html} = live(conn, "/admin/site_a/hooks/#{js_hook.id}") + + assert has_element?(view, "input[name='js_hook[name]'][value=InitHook]") + + view + |> form("#js-hook-form", js_hook: %{name: "ChangedHook"}) + |> render_submit() + + assert has_element?(view, "p", "JS Hook updated successfully") + + refute has_element?(view, "input[name='js_hook[name]'][value=InitHook]") + assert has_element?(view, "input[name='js_hook[name]'][value=ChangedHook]") + end + + test "delete event handler", %{conn: conn} do + js_hook = js_hook_fixture(node1(), name: "LegacyHook") + {:ok, view, _html} = live(conn, "/admin/site_a/hooks/#{js_hook.id}") + + assert has_element?(view, "span", "LegacyHook") + + view |> element("#delete-js-hook-button") |> render_click() + + assert has_element?(view, "#delete-modal") + + view |> element("#confirm-delete-button") |> render_click() + + refute has_element?(view, "#delete-modal") + refute has_element?(view, "span", "LegacyHook") + end + + test "display a site selector", %{conn: conn} do + {:ok, live, _html} = live(conn, "/admin/site_a/hooks") + + live + |> element("#site-selector-form") + |> render_change(%{site: "site_c"}) + + assert_redirected(live, "/admin/site_c/hooks") + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index e63010ee..8c4ec89c 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -134,4 +134,20 @@ defmodule Beacon.LiveAdmin.Fixtures do rpc(node, Beacon.Content, :create_info_handler!, [attrs]) end + + def js_hook_fixture(node \\ node1(), attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + site: "site_a", + name: "FooHook", + code: ~S| + mounted() { + + } + | + }) + + {:ok, js_hook} = rpc(node, Beacon.Content, :create_js_hook, [attrs]) + js_hook + end end