diff --git a/apps/content/lib/cms/static.ex b/apps/content/lib/cms/static.ex index 6f195338f3..ebf70fa65f 100644 --- a/apps/content/lib/cms/static.ex +++ b/apps/content/lib/cms/static.ex @@ -70,6 +70,10 @@ defmodule Content.CMS.Static do parse_json("cms/teasers_diversion.json") end + def teaser_empty_response do + [] + end + # Repositories of multiple, full-object responses (maximum data) def news_repo do @@ -375,6 +379,10 @@ defmodule Content.CMS.Static do {:ok, teaser_project_update_response()} end + def view("/cms/teasers", %{promoted: 0}) do + {:ok, teaser_empty_response()} + end + def view("/cms/teasers", %{type: :diversion}) do {:ok, teaser_diversion_response()} end diff --git a/apps/content/lib/paragraph/content_list.ex b/apps/content/lib/paragraph/content_list.ex index bf8c772075..c4b9cdfadd 100644 --- a/apps/content/lib/paragraph/content_list.ex +++ b/apps/content/lib/paragraph/content_list.ex @@ -4,7 +4,9 @@ defmodule Content.Paragraph.ContentList do This paragraph provides a formula for retreiving a dynamic list of content items from the CMS via the `/cms/teasers` API endpoint. """ - import Content.Helpers, only: [field_value: 2, int_or_string_to_int: 1, content_type: 1] + import Content.Helpers, + only: [field_value: 2, int_or_string_to_int: 1, content_type: 1, parse_link: 2] + import Content.Paragraph, only: [parse_header: 1] alias Content.{Paragraph.ColumnMultiHeader, Repo, Teaser} @@ -13,16 +15,20 @@ defmodule Content.Paragraph.ContentList do right_rail: false, ingredients: %{}, recipe: [], - teasers: [] + teasers: [], + cta: %{} @type order :: :DESC | :ASC + @type text_or_nil :: String.t() | nil + @type t :: %__MODULE__{ header: ColumnMultiHeader.t() | nil, right_rail: boolean(), ingredients: map(), recipe: Keyword.t(), - teasers: [Teaser.t()] + teasers: [Teaser.t()], + cta: map() } @spec from_api(map) :: t @@ -51,13 +57,22 @@ defmodule Content.Paragraph.ContentList do sort_order: data |> field_value("field_sorting_logic") |> order() } + cta_link = parse_link(data, "field_cta_link") + + cta = %{ + behavior: field_value(data, "field_cta_behavior"), + text: field_value(data, "field_cta_text"), + url: cta_link && Map.get(cta_link, :url) + } + recipe = combine(ingredients) %__MODULE__{ header: parse_header(data), right_rail: field_value(data, "field_right_rail"), ingredients: ingredients, - recipe: recipe + recipe: recipe, + cta: cta } end @@ -207,7 +222,7 @@ defmodule Content.Paragraph.ContentList do end # Convert order value strings to atoms - @spec order(String.t() | nil) :: order() + @spec order(text_or_nil) :: order() defp order("ASC"), do: :ASC defp order("DESC"), do: :DESC defp order(_), do: nil diff --git a/apps/site/lib/site_web/templates/content/_content_list.html.eex b/apps/site/lib/site_web/templates/content/_content_list.html.eex index f315b567df..513225440c 100644 --- a/apps/site/lib/site_web/templates/content/_content_list.html.eex +++ b/apps/site/lib/site_web/templates/content/_content_list.html.eex @@ -6,3 +6,11 @@ teasers: @content.teasers, type: Keyword.get(@content.recipe, :type, :generic), conn: @conn) %> + +<%= if list_cta?(@content.ingredients.type, @content.cta, @content.teasers, @conn.path_info) do %> + <% cta = setup_list_cta(@content, @conn.path_info) %> + +
+ <%= link cta.title, to: cms_static_page_path(@conn, cta.url), class: "c-call-to-action" %> +
+<% end %> diff --git a/apps/site/lib/site_web/templates/content/_teaser_list.html.eex b/apps/site/lib/site_web/templates/content/_teaser_list.html.eex index 96a223eb39..cae7ef6028 100644 --- a/apps/site/lib/site_web/templates/content/_teaser_list.html.eex +++ b/apps/site/lib/site_web/templates/content/_teaser_list.html.eex @@ -7,9 +7,9 @@ %> <%= link to: cms_static_page_path(@conn, teaser.path), id: teaser.id, - class: "c-content-teaser c-content-teaser--#{css_type} c-content-teaser--#{color}" do %> + class: "c-content-teaser c-content-teaser--#{css_type} c-content-teaser--#{color}" do %> <%= SiteWeb.Content.TeaserView.render("_#{@type}.html", teaser: teaser, conn: @conn) %> <% end %> <% end %> - \ No newline at end of file + diff --git a/apps/site/lib/site_web/views/content_view.ex b/apps/site/lib/site_web/views/content_view.ex index 10f6a83a5e..567b438381 100644 --- a/apps/site/lib/site_web/views/content_view.ex +++ b/apps/site/lib/site_web/views/content_view.ex @@ -6,9 +6,12 @@ defmodule SiteWeb.ContentView do import SiteWeb.TimeHelpers + alias Content.CMS alias Content.Field.{File, Image, Link} alias Content.Paragraph alias Content.Paragraph.{Callout, ColumnMulti, ContentList, FareCard} + alias Content.Teaser + alias Plug.Conn alias Site.ContentRewriter defdelegate fa_icon_for_file_type(mime), to: Site.FontAwesomeHelpers @@ -25,7 +28,7 @@ defmodule SiteWeb.ContentView do end @doc "Universal wrapper around all paragraph types" - @spec render_paragraph(Paragraph.t(), Plug.Conn.t()) :: Phoenix.HTML.safe() + @spec render_paragraph(Paragraph.t(), Conn.t()) :: Phoenix.HTML.safe() # Don't render Content List if list has no items def render_paragraph(%ContentList{teasers: []}, _), do: [] @@ -43,7 +46,7 @@ defmodule SiteWeb.ContentView do Intelligently choose and render paragraph template based on type, except for certain types which either have no template or require special values. """ - @spec render_content(Paragraph.t(), Plug.Conn.t()) :: Phoenix.HTML.safe() + @spec render_content(Paragraph.t(), Conn.t()) :: Phoenix.HTML.safe() def render_content(paragraph, conn) def render_content(%ColumnMulti{display_options: "grouped"} = paragraph, conn) do @@ -132,6 +135,48 @@ defmodule SiteWeb.ContentView do end end + @spec list_cta?(CMS.type(), map(), [Teaser.t()], [String.t()]) :: boolean() + # No results + def list_cta?(_type, _cta, [], _path) do + false + end + + # Is not requested (hide) + def list_cta?(_type, %{behavior: "hide"}, _teasers, _path) do + false + end + + # Is a list of project updates, AND is sitting on a valid project page + def list_cta?(:project_update, _cta, _teasers, ["projects", _]) do + true + end + + # Is requested (auto/overridden) BUT has no default (generic destination) AND user has not provided a link + def list_cta?(type, %{url: url, text: text}, _teasers, _path) + when type in [:project_update, :diversion, :page] and (is_nil(url) or is_nil(text)) do + false + end + + # Either has required link values or has a readily available default link path + def list_cta?(_type, _cta, _teasers, _path) do + true + end + + @spec setup_list_cta(ContentList.t(), [String.t()]) :: Link.t() + def setup_list_cta(list, conn_path) do + current_path = + ["" | conn_path] + |> Enum.join("/") + + case list.cta do + %{text: nil, url: nil} -> + default_list_cta(list.ingredients.type, current_path) + + link_parts -> + custom_list_cta(list.ingredients.type, link_parts, current_path) + end + end + defp maybe_shift_timezone(%NaiveDateTime{} = time) do time end @@ -191,4 +236,55 @@ defmodule SiteWeb.ContentView do @spec paragraph_classes(Paragraph.t()) :: iodata() defp paragraph_classes(%Callout{image: %Image{}}), do: ["c-callout--with-image"] defp paragraph_classes(_), do: [] + + # Automatically map each list to a destination page based on content type + @spec default_list_cta(CMS.type(), String.t()) :: Link.t() + defp default_list_cta(:project_update, current_path) do + %Link{ + title: "View all project updates", + url: "#{current_path}/updates" + } + end + + defp default_list_cta(:event, _current_path) do + %Link{ + title: "View all events", + url: "/events" + } + end + + defp default_list_cta(:news_entry, _current_path) do + %Link{ + title: "View all news", + url: "/news" + } + end + + defp default_list_cta(:project, _current_path) do + %Link{ + title: "View all projects", + url: "/projects" + } + end + + # Override one or both of the url/text values for the list CTA + @spec custom_list_cta(CMS.type(), map, String.t()) :: Link.t() + defp custom_list_cta(type, %{text: nil, url: url}, current_path) do + type + |> default_list_cta(current_path) + |> Map.put(:url, url) + end + + defp custom_list_cta(type, %{text: text, url: nil}, current_path) do + type + |> default_list_cta(current_path) + |> Map.put(:title, text) + end + + defp custom_list_cta(_type, %{text: text, url: url}, _current_path) do + %Link{ + title: text, + url: url + } + end end diff --git a/apps/site/test/site_web/views/content_view_test.exs b/apps/site/test/site_web/views/content_view_test.exs index aeb20ed5fa..5bec1198d4 100644 --- a/apps/site/test/site_web/views/content_view_test.exs +++ b/apps/site/test/site_web/views/content_view_test.exs @@ -100,7 +100,7 @@ defmodule SiteWeb.ContentViewTest do end test "renders a diversion as a generic page", %{diversion: diversion} do - fake_conn = %{request_path: "/"} + fake_conn = %{request_path: "/", path_info: ["diversions", "diversion-2"]} rendered = "page.html" @@ -389,9 +389,12 @@ defmodule SiteWeb.ContentViewTest do type: :project_update, date: "now", date_op: ">=" - ] + ], + cta: %{behavior: "default", text: nil, url: nil} }) + conn = Map.put(conn, :path_info, ["projects", "a-project"]) + rendered = paragraph |> render_paragraph(conn) @@ -401,6 +404,8 @@ defmodule SiteWeb.ContentViewTest do assert rendered =~ "c-teaser-list--project-update" assert rendered =~ "c-content-teaser--project-update" assert rendered =~ "Header copy" + assert rendered =~ "c-call-to-action" + assert rendered =~ "View all project updates" end test "does not render empty content lists", %{conn: conn} do @@ -881,4 +886,199 @@ defmodule SiteWeb.ContentViewTest do assert extend_width_if(false, :table, do: "foo") == "foo" end end + + describe "list_cta?/3" do + test "does not render CTA if there are no teaser results", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :event}, + recipe: [promoted: 0], + cta: %{behavior: "default", text: nil, url: nil} + }) + + assert [] = render_paragraph(paragraph, conn) + end + + test "does not render CTA if author has selected to hide the CTA", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :project_update}, + recipe: [type: :project_update], + cta: %{behavior: "hide", text: nil, url: nil} + }) + + conn = Map.put(conn, :path_info, ["projects", "a-project"]) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + refute rendered =~ "c-call-to-action" + end + + test "does not render CTA for project updates list if not on a project", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :project_update}, + recipe: [type: :project_update], + cta: %{behavior: "default", text: nil, url: nil} + }) + + conn = Map.put(conn, :path_info, ["not-projects", "not-a-project"]) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + refute rendered =~ "c-call-to-action" + end + + test "renders CTA for project updates list on non-project if overridden", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :project_update}, + recipe: [type: :project_update], + cta: %{behavior: "default", text: "Custom CTA", url: "/project/manually-linked/updates"} + }) + + conn = Map.put(conn, :path_info, ["not-projects", "not-a-project"]) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "Custom CTA" + assert rendered =~ "/project/manually-linked/updates" + end + + test "does not render CTA for types that have no generic destination", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :diversion}, + recipe: [type: :diversion], + cta: %{behavior: "default", text: nil, url: nil} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + refute rendered =~ "c-call-to-action" + end + + test "renders CTA for types without generic destination if overridden", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :diversion}, + recipe: [type: :diversion], + cta: %{behavior: "default", text: "Go here!", url: "/news"} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "Go here!" + assert rendered =~ "/news" + end + end + + describe "setup_list_cta/2" do + test "renders automatic CTA for news content lists", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :news_entry}, + recipe: [type: :news_entry], + cta: %{behavior: "default", text: nil, url: nil} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "View all news" + assert rendered =~ "/news" + end + + test "renders automatic CTA for event content lists", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :event}, + recipe: [type: :event], + cta: %{behavior: "default", text: nil, url: nil} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "View all events" + assert rendered =~ "/events" + end + + test "renders automatic CTA for project content lists", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :project}, + recipe: [type: :project], + cta: %{behavior: "default", text: nil, url: nil} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "View all projects" + assert rendered =~ "/projects" + end + + test "renders default CTA url but with overridden CTA text from author", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :event}, + recipe: [type: :event], + cta: %{behavior: "default", text: "More where that came from...", url: nil} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "More where that came from..." + assert rendered =~ "/events" + end + + test "renders default CTA text but with overridden CTA url from author", %{conn: conn} do + paragraph = + ContentList.fetch_teasers(%ContentList{ + ingredients: %{type: :event}, + recipe: [type: :event], + cta: %{behavior: "default", text: nil, url: "/special-events"} + }) + + rendered = + paragraph + |> render_paragraph(conn) + |> HTML.safe_to_string() + + assert rendered =~ "c-call-to-action" + assert rendered =~ "View all events" + assert rendered =~ "/special-events" + end + end end