diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f9aba546..30bd0c8bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to ### Added +- Allow users to book for demo sessions + [PR#3035](https://github.com/OpenFn/lightning/pull/3035) - Allow workflow and project concurrency progress windows [#2995](https://github.com/OpenFn/lightning/issues/2995) diff --git a/lib/lightning/config.ex b/lib/lightning/config.ex index 8ad37def88..dd20fe4fa7 100644 --- a/lib/lightning/config.ex +++ b/lib/lightning/config.ex @@ -265,6 +265,25 @@ defmodule Lightning.Config do def credential_transfer_token_validity_in_days do 2 end + + @impl true + def book_demo_banner_enabled? do + Keyword.get(book_demo_banner_config(), :enabled, false) + end + + @impl true + def book_demo_calendly_url do + Keyword.get(book_demo_banner_config(), :calendly_url) + end + + @impl true + def book_demo_openfn_workflow_url do + Keyword.get(book_demo_banner_config(), :openfn_workflow_url) + end + + defp book_demo_banner_config do + Application.get_env(:lightning, :book_demo_banner, []) + end end @callback apollo(key :: atom() | nil) :: map() @@ -306,6 +325,9 @@ defmodule Lightning.Config do @callback worker_token_signer() :: Joken.Signer.t() @callback adaptor_registry() :: Keyword.t() @callback credential_transfer_token_validity_in_days() :: integer() + @callback book_demo_banner_enabled?() :: boolean() + @callback book_demo_calendly_url() :: String.t() + @callback book_demo_openfn_workflow_url() :: String.t() @doc """ Returns the configuration for the `Lightning.AdaptorRegistry` service @@ -484,6 +506,18 @@ defmodule Lightning.Config do impl().credential_transfer_token_validity_in_days() end + def book_demo_banner_enabled? do + impl().book_demo_banner_enabled?() + end + + def book_demo_calendly_url do + impl().book_demo_calendly_url() + end + + def book_demo_openfn_workflow_url do + impl().book_demo_openfn_workflow_url() + end + defp impl do Application.get_env(:lightning, __MODULE__, API) end diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex index 524fcbf885..d70f9ed627 100644 --- a/lib/lightning/config/bootstrap.ex +++ b/lib/lightning/config/bootstrap.ex @@ -634,6 +634,11 @@ defmodule Lightning.Config.Bootstrap do config :lightning, :ui_metrics_tracking, enabled: env!("UI_METRICS_ENABLED", &Utils.ensure_boolean/1, false) + config :lightning, :book_demo_banner, + enabled: false, + calendly_url: nil, + openfn_workflow_url: nil + # # ============================================================================== setup_storage() diff --git a/lib/lightning_web/init_assigns.ex b/lib/lightning_web/init_assigns.ex index 38751186e4..95cb15d15c 100644 --- a/lib/lightning_web/init_assigns.ex +++ b/lib/lightning_web/init_assigns.ex @@ -16,6 +16,15 @@ defmodule LightningWeb.InitAssigns do end) |> assign_new(:account_confirmation_required?, fn -> confirmation_required? + end) + |> assign_new(:banner, fn -> + if Lightning.Config.book_demo_banner_enabled?() and + is_nil(current_user.preferences["demo_banner.dismissed_at"]) do + %{ + function: &LightningWeb.LiveHelpers.book_demo_banner/1, + attrs: %{current_user: current_user} + } + end end)} end end diff --git a/lib/lightning_web/live/book_demo_banner.ex b/lib/lightning_web/live/book_demo_banner.ex new file mode 100644 index 0000000000..1389915eb9 --- /dev/null +++ b/lib/lightning_web/live/book_demo_banner.ex @@ -0,0 +1,196 @@ +defmodule LightningWeb.BookDemoBanner do + @moduledoc false + use LightningWeb, :live_component + alias Lightning.Accounts + alias Phoenix.LiveView.JS + + @impl true + def update(%{current_user: user} = assigns, socket) do + {:ok, + socket + |> assign(changeset: form_changeset(user, %{})) + |> assign(assigns)} + end + + defp form_changeset(user, params) do + data = %{ + name: "#{user.first_name} #{user.last_name}", + email: user.email, + message: nil + } + + types = %{name: :string, email: :string, message: :string} + + {data, types} + |> Ecto.Changeset.cast(params, Map.keys(types)) + |> Ecto.Changeset.validate_required(Map.keys(types)) + end + + defp dismiss_banner(current_user) do + timestamp = DateTime.utc_now() |> DateTime.to_unix() + + Accounts.update_user_preference( + current_user, + "demo_banner.dismissed_at", + timestamp + ) + end + + @impl true + def handle_event("dismiss_banner", _params, socket) do + {:ok, _} = dismiss_banner(socket.assigns.current_user) + + {:noreply, socket} + end + + def handle_event("validate", %{"demo" => params}, socket) do + changeset = + socket.assigns.current_user + |> form_changeset(params) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, changeset: changeset)} + end + + def handle_event("schedule-call", %{"demo" => params}, socket) do + changeset = + socket.assigns.current_user + |> form_changeset(params) + |> Map.put(:action, :validate) + + if changeset.valid? do + workflow_url = Lightning.Config.book_demo_openfn_workflow_url() + calendly_url = Lightning.Config.book_demo_calendly_url() + + redirect_url = + calendly_url + |> URI.parse() + |> URI.append_query(URI.encode_query(params)) + |> URI.to_string() + + {:ok, %{status: 200}} = Tesla.post(workflow_url, params) + dismiss_banner(socket.assigns.current_user) + {:noreply, redirect(socket, external: redirect_url)} + else + {:noreply, assign(socket, changeset: changeset)} + end + end + + @impl true + def render(assigns) do + ~H""" +
+
+ + +
+

+ What problem are you trying to solve with OpenFn? +

+ + Schedule a call + +
+
+ +
+
+ <.modal + id={"#{@id}-modal"} + show={false} + close_on_keydown={false} + close_on_click_away={false} + width="w-2/5" + > + <:title> +
+ Schedule a 1:1 automation session + +
+ + <.form + :let={f} + as={:demo} + id="book-demo-form" + for={@changeset} + phx-target={@myself} + phx-change="validate" + phx-submit="schedule-call" + > +
+ <.input type="text" field={f[:name]} label="Name" required={true} /> + <.input type="text" field={f[:email]} label="Email" required={true} /> + <.input + type="textarea" + field={f[:message]} + label="What problem are you trying to solve with OpenFn? + What specific task, process, or program would you like to automate?" + placeholder="E.g. Every time a new person is registered in my clinic system, I must initiate a mobile money payment to a caregiver. This takes time & money. I'd like to use OpenFn to automate the process." + rows="5" + required={true} + /> +
+
+ + +
+
+
+ + +
+ """ + end +end diff --git a/lib/lightning_web/live/credential_live/index.html.heex b/lib/lightning_web/live/credential_live/index.html.heex index d34ddf742f..fd097d0eb6 100644 --- a/lib/lightning_web/live/credential_live/index.html.heex +++ b/lib/lightning_web/live/credential_live/index.html.heex @@ -1,4 +1,11 @@ + <:banner> + + <:header> <:title>{@page_title} diff --git a/lib/lightning_web/live/dashboard_live/index.ex b/lib/lightning_web/live/dashboard_live/index.ex index 713b1b1fdc..28d72bcdc7 100644 --- a/lib/lightning_web/live/dashboard_live/index.ex +++ b/lib/lightning_web/live/dashboard_live/index.ex @@ -15,6 +15,13 @@ defmodule LightningWeb.DashboardLive.Index do def render(assigns) do ~H""" + <:banner> + + <:header> <:title>{@page_title} diff --git a/lib/lightning_web/live/live_helpers.ex b/lib/lightning_web/live/live_helpers.ex index 6b74e6c10e..2e9719379a 100644 --- a/lib/lightning_web/live/live_helpers.ex +++ b/lib/lightning_web/live/live_helpers.ex @@ -148,6 +148,20 @@ defmodule LightningWeb.LiveHelpers do """ end + attr :current_user, Lightning.Accounts.User, required: true + + def book_demo_banner(assigns) do + ~H""" +
+ <.live_component + id="book-demo-banner" + module={LightningWeb.BookDemoBanner} + current_user={@current_user} + /> +
+ """ + end + @spec display_short_uuid(binary()) :: binary() def display_short_uuid(uuid_string) do uuid_string |> String.slice(0..7) diff --git a/lib/lightning_web/live/profile_live/edit.html.heex b/lib/lightning_web/live/profile_live/edit.html.heex index 973176cc57..d46ef1ae9a 100644 --- a/lib/lightning_web/live/profile_live/edit.html.heex +++ b/lib/lightning_web/live/profile_live/edit.html.heex @@ -1,4 +1,11 @@ + <:banner> + + <:header> <:title>{@page_title} diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex index 7733e852d9..b87b9391b3 100644 --- a/lib/lightning_web/live/project_live/settings.html.heex +++ b/lib/lightning_web/live/project_live/settings.html.heex @@ -1,12 +1,10 @@ <:banner> - <%= if assigns[:banner] do %> - {Phoenix.LiveView.TagEngine.component( - @banner.function, - @banner.attrs, - {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} - )} - <% end %> + <:header> diff --git a/lib/lightning_web/live/run_live/index.html.heex b/lib/lightning_web/live/run_live/index.html.heex index f5fac60627..785b1ad2df 100644 --- a/lib/lightning_web/live/run_live/index.html.heex +++ b/lib/lightning_web/live/run_live/index.html.heex @@ -1,12 +1,10 @@ <:banner> - <%= if assigns[:banner] do %> - {Phoenix.LiveView.TagEngine.component( - @banner.function, - @banner.attrs, - {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} - )} - <% end %> + <:header> @@ -564,7 +562,7 @@ type="submit" form="run-search-form" class="rounded-md bg-indigo-600 px-5 py-2 text-sm font-semibold - text-white shadow-xs hover:bg-indigo-500 + text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 truncate" > diff --git a/lib/lightning_web/live/tokens_live/index.html.heex b/lib/lightning_web/live/tokens_live/index.html.heex index a2dc8676de..1a3f18f0c2 100644 --- a/lib/lightning_web/live/tokens_live/index.html.heex +++ b/lib/lightning_web/live/tokens_live/index.html.heex @@ -1,4 +1,11 @@ + <:banner> + + <:header> <:title>{@page_title} diff --git a/lib/lightning_web/live/workflow_live/index.ex b/lib/lightning_web/live/workflow_live/index.ex index 5db4bf47cc..08c2017037 100644 --- a/lib/lightning_web/live/workflow_live/index.ex +++ b/lib/lightning_web/live/workflow_live/index.ex @@ -14,7 +14,7 @@ defmodule LightningWeb.WorkflowLive.Index do alias LightningWeb.WorkflowLive.Helpers alias LightningWeb.WorkflowLive.NewWorkflowForm - alias Phoenix.LiveView.TagEngine + # alias Phoenix.LiveView.TagEngine on_mount {LightningWeb.Hooks, :project_scope} @@ -35,13 +35,11 @@ defmodule LightningWeb.WorkflowLive.Index do ~H""" <:banner> - <%= if assigns[:banner] do %> - {TagEngine.component( - @banner.function, - @banner.attrs, - {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} - )} - <% end %> + <:header> diff --git a/test/lightning_web/live/book_demo_banner_test.exs b/test/lightning_web/live/book_demo_banner_test.exs new file mode 100644 index 0000000000..977116cb68 --- /dev/null +++ b/test/lightning_web/live/book_demo_banner_test.exs @@ -0,0 +1,169 @@ +defmodule LightningWeb.BookDemoBannerTest do + use LightningWeb.ConnCase, async: true + + import Lightning.Factories + import Mox + import Phoenix.LiveViewTest + + setup :verify_on_exit! + setup :register_and_log_in_user + setup :create_project_for_current_user + + test "by default the banner is not shown", %{conn: conn, project: project} do + for route <- routes_with_banner(project) do + {:ok, view, html} = live(conn, route) + refute html =~ "What problem are you trying to solve with OpenFn?" + refute has_element?(view, "#book-demo-banner") + end + end + + test "the banner is shown when enabled in the config", %{ + conn: conn, + project: project + } do + stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn -> + true + end) + + for route <- routes_with_banner(project) do + {:ok, view, html} = live(conn, route) + assert has_element?(view, "#book-demo-banner") + assert html =~ "What problem are you trying to solve with OpenFn?" + end + + # banner is not shown in the workflow edit page + workflow = insert(:simple_workflow, project: project) + Lightning.Workflows.Snapshot.create(workflow) + {:ok, view, html} = live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}") + refute has_element?(view, "#book-demo-banner") + refute html =~ "What problem are you trying to solve with OpenFn?" + end + + test "the banner is not shown when the user has already dismissed it", %{ + conn: conn, + project: project, + user: user + } do + stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn -> + true + end) + + user + |> Ecto.Changeset.change(%{ + preferences: %{"demo_banner.dismissed_at" => 12_345_678} + }) + |> Lightning.Repo.update!() + + for route <- routes_with_banner(project) do + {:ok, view, html} = live(conn, route) + refute has_element?(view, "#book-demo-banner") + refute html =~ "What problem are you trying to solve with OpenFn?" + end + end + + test "user can dismiss the banner", %{ + conn: conn, + project: project, + user: user + } do + stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn -> + true + end) + + refute user.preferences["demo_banner.dismissed_at"] + + route = routes_with_banner(project) |> hd() + + {:ok, view, _html} = live(conn, route) + assert has_element?(view, "#book-demo-banner") + assert has_element?(view, "#dismiss-book-demo-banner") + + view |> element("#dismiss-book-demo-banner") |> render_click() + + updated_user = Lightning.Repo.reload(user) + assert updated_user.preferences["demo_banner.dismissed_at"] + end + + test "user can successfully book for demo", %{ + conn: conn, + project: project, + user: user + } do + stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn -> + true + end) + + workflow_url = "http://localhost:4001/i/1234" + calendly_url = "https://calendly.com" + + stub(Lightning.MockConfig, :book_demo_calendly_url, fn -> + calendly_url + end) + + stub(Lightning.MockConfig, :book_demo_openfn_workflow_url, fn -> + workflow_url + end) + + expected_message = "Hello world" + + expected_body = + %{ + "name" => "#{user.first_name} #{user.last_name}", + "email" => user.email, + "message" => expected_message + } + + expected_redirect_url = + calendly_url + |> URI.parse() + |> URI.append_query(URI.encode_query(expected_body)) + |> URI.to_string() + + expect( + Lightning.Tesla.Mock, + :call, + fn %{method: :post, url: ^workflow_url, body: ^expected_body}, _opts -> + {:ok, %Tesla.Env{status: 200}} + end + ) + + route = routes_with_banner(project) |> hd() + + {:ok, view, _html} = live(conn, route) + assert has_element?(view, "#book-demo-banner") + assert has_element?(view, "#book-demo-banner-modal") + + form = view |> form("#book-demo-banner-modal form") + + assert render_change(form, demo: %{email: nil, message: expected_message}) =~ + "This field can't be blank" + + assert render_submit(form) =~ "This field can't be blank" + + assert {:error, {:redirect, %{to: redirect_to}}} = + render_submit(form, + demo: %{email: user.email, message: expected_message} + ) + + assert redirect_to == expected_redirect_url + end + + defp routes_with_banner(project) do + [ + # projects page + ~p"/projects", + # workflows page + ~p"/projects/#{project.id}/w", + # history page + ~p"/projects/#{project.id}/history", + # settings page + ~p"/projects/#{project.id}/settings", + # profile page + ~p"/profile", + # personal access tokens page + ~p"/profile/tokens", + # credentials page + ~p"/credentials" + ] + end +end