Skip to content

Commit

Permalink
refactor: upgrade AutocompleteJS to v1 & update related code (Top Nav…
Browse files Browse the repository at this point in the history
…igation Desktop Only) (#1781)

* deps(npm): install autocomplete and friends

and remove unused algoliasearch lib

* fix extra snapshot

* rename _new_header.html.heex

* feat(Algolia.Query): build query params on backend

- checks for valid index
- adjusts query params based on index

* feat(SiteWeb.Components): algolia_autocomplete component

* feat: Promise-compatible debounce function

* feat: autocomplete v1 search in header

* css: adapt the default autocomplete theme

* refactor: use React instead of default Preact renderer

* deps(npm): remove no longer needed Preact dep

* fixup: rename newly-exported function

* fixup: shift padding down one element

This will increase the clickable area of links and buttons.

* feedback: remove unused helper
  • Loading branch information
thecristen authored Nov 1, 2023
1 parent c30fd6f commit ee26428
Show file tree
Hide file tree
Showing 34 changed files with 1,622 additions and 73 deletions.
19 changes: 19 additions & 0 deletions apps/algolia/lib/algolia/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,32 @@ defmodule Algolia.Query do
When this value is set to false, Algolia will not record that query in
its analytics, so this should only be set to true in Prod.
"""
alias Algolia.Query.Request

@doc "Algolia indexes available to query"
def valid_indexes() do
suffix = Application.get_env(:algolia, :index_suffix, "")
["routes", "stops", "drupal"] |> Enum.map(&{String.to_atom(&1), &1 <> suffix})
end

@spec build(map) :: String.t()
def build(%{"requests" => queries}) do
%{"requests" => Enum.map(queries, &build_query/1)}
|> Poison.encode!()
end

def build(%{"algoliaQuery" => query, "algoliaIndexes" => indexes}) do
requests =
Enum.map(indexes, fn idx ->
idx
|> Request.new(query)
|> Request.encode()
end)

%{"requests" => requests}
|> Poison.encode!()
end

@spec build_query(map) :: map
defp build_query(%{"indexName" => index, "params" => params, "query" => query}) do
%{
Expand Down
89 changes: 89 additions & 0 deletions apps/algolia/lib/algolia/request.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
defmodule Algolia.Query.Request do
@moduledoc """
Algolia's REST API expects this format for each request
https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-preset-algolia/getAlgoliaResults/#param-queries-2
This module has some helpers to create requests for our indexes with some
specific default parameters.
"""
@supported_indexes Algolia.Query.valid_indexes()
@supported_index_keys Algolia.Query.valid_indexes() |> Keyword.keys() |> Enum.map(&to_string/1)

defstruct indexName: "",
query: "",
params: %{
"hitsPerPage" => 5,
"clickAnalytics" => true,
"facets" => ["*"],
"facetFilters" => [[]]
},
attributesToHighlight: ""

@type t :: %__MODULE__{
indexName: String.t(),
query: String.t(),
params: map(),
attributesToHighlight: String.t() | [String.t()]
}
@spec new(String.t(), String.t()) :: t()
def new(indexName, query) when indexName in @supported_index_keys do
algoliaIndex = Keyword.fetch!(@supported_indexes, String.to_atom(indexName))

%__MODULE__{
indexName: algoliaIndex,
query: query,
attributesToHighlight: highlight(indexName)
}
|> with_hit_size(indexName)
|> with_facet_filters(indexName)
end

defp highlight("routes"), do: ["route.name", "route.long_name"]
defp highlight("stops"), do: "stop.name"
defp highlight("drupal"), do: "content_title"

defp with_hit_size(request, indexName) do
%__MODULE__{
request
| params: %{request.params | "hitsPerPage" => hit_size(indexName)}
}
end

defp hit_size("routes"), do: 5
defp hit_size("stops"), do: 2
defp hit_size("drupal"), do: 2

@spec with_facet_filters(t(), String.t()) :: t()
defp with_facet_filters(request, "drupal") do
%__MODULE__{
request
| params: %{
request.params
| "facetFilters" => [
[
"_content_type:page",
"_content_type:search_result",
"_content_type:diversion",
"_content_type:landing_page",
"_content_type:person",
"_content_type:project",
"_content_type:project_update"
]
]
}
}
end

defp with_facet_filters(request, _), do: request

def encode(%__MODULE__{} = request) do
request
|> Map.from_struct()
|> Map.update!(:params, &Algolia.Query.encode_params/1)
|> Map.new(fn {k, v} -> {to_string(k), v} end)
# The highlight tag values are needed for compatibility with Algolia's
# autocomplete.js library v1+
|> Map.put_new("highlightPreTag", "__aa-highlight__")
|> Map.put_new("highlightPostTag", "__/aa-highlight__")
end
end
21 changes: 21 additions & 0 deletions apps/algolia/test/query_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,26 @@ defmodule Algolia.QueryTest do

assert params == Enum.join(["analytics=false" | @encoded_query_params], "&")
end

test "can construct and encode a query" do
encoded =
Query.build(%{
"algoliaQuery" => "b",
"algoliaIndexes" => ["stops", "routes"]
})

assert {:ok, %{"requests" => multiple_queries}} = Poison.decode(encoded)
assert length(multiple_queries) == 2

assert %{
"indexName" => "stops_test",
"query" => "b",
"attributesToHighlight" => "stop.name",
"params" => params
} = List.first(multiple_queries)

assert params ==
"analytics=false&clickAnalytics=true&facetFilters=%5B%5B%5D%5D&facets=%5B%22*%22%5D&hitsPerPage=2"
end
end
end
50 changes: 50 additions & 0 deletions apps/algolia/test/request_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
defmodule Algolia.Query.RequestTest do
@moduledoc false
use ExUnit.Case, async: true
alias Algolia.Query.Request

describe "new/2" do
test "doesn't work for invalid index" do
assert Request.new("drupal", "question")
assert_raise FunctionClauseError, fn -> Request.new("fakeindex", "question") end
end

test "includes query" do
search_string = "asfdsagfsadgsad"
assert %Request{query: ^search_string} = Request.new("drupal", search_string)
end

test "changes hitsPerPage param based on index" do
assert %Request{params: %{"hitsPerPage" => 5}} = Request.new("routes", "")
assert %Request{params: %{"hitsPerPage" => 2}} = Request.new("drupal", "")
end

test "changes attributesToHighlight param based on index" do
assert %Request{attributesToHighlight: "stop.name"} = Request.new("stops", "")

assert %Request{attributesToHighlight: ["route.name", "route.long_name"]} =
Request.new("routes", "")
end

test "changes facetFilters param based on index" do
assert %Request{params: %{"facetFilters" => [[]]}} = Request.new("routes", "")
assert %Request{params: %{"facetFilters" => facetFilters}} = Request.new("drupal", "")
refute facetFilters == [[]]
end
end

test "encode/1 returns a JSON-encodable map with encoded params" do
request = Request.new("drupal", "some special search")
encoded = Request.encode(request)

assert encoded == %{
"attributesToHighlight" => "content_title",
"highlightPostTag" => "__/aa-highlight__",
"highlightPreTag" => "__aa-highlight__",
"indexName" => "drupal_test",
"params" =>
"analytics=false&clickAnalytics=true&facetFilters=%5B%5B%22_content_type%3Apage%22%2C%22_content_type%3Asearch_result%22%2C%22_content_type%3Adiversion%22%2C%22_content_type%3Alanding_page%22%2C%22_content_type%3Aperson%22%2C%22_content_type%3Aproject%22%2C%22_content_type%3Aproject_update%22%5D%5D&facets=%5B%22*%22%5D&hitsPerPage=2",
"query" => "some special search"
}
end
end
131 changes: 131 additions & 0 deletions apps/site/assets/css/_autocomplete-theme.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
@import '@algolia/autocomplete-theme-classic';

// right now #header-desktop is the only search using the new library, so it's
// the only one styled here. the #header-desktop can be removed once we migrate
// the rest over
.c-search-bar__autocomplete#header-desktop {
.aa-Autocomplete {
--aa-icon-color-rgb: 22, 92, 150; // $brand-primary;
--aa-primary-color-rgb: 22, 92, 150; // $brand-primary;
--aa-search-input-height: 2.25rem;
}

.aa-Item {
@include icon-size-inline(1em);
border-bottom: $border;
border-radius: 0;
font-weight: normal;

&:hover {
background-color: $brand-primary-lightest;
}

em {
font-style: inherit;
font-weight: bold;
}

> a,
> button {
color: currentColor;
display: flex;
font-weight: inherit;
gap: .25rem;
min-width: 0;
padding: calc(#{$base-spacing} / 2) $base-spacing;
}

a:hover {
text-decoration: none;
}
}

.aa-ItemContent,
.aa-ItemContentBody {
display: unset;
}

.aa-ItemContent {
mark {
padding: 0;
}
> * {
margin-right: .25rem;
}
}

.aa-ItemContentTitle {
display: unset;
margin: unset;
overflow: visible;
text-overflow: unset;
white-space: normal;
}

.aa-PanelLayout {
padding: unset;
}

.aa-Panel {
// stylelint-disable-next-line declaration-no-important
top: 3.25rem !important;
z-index: var(--aa-base-z-index);
}

.aa-Label {
margin-bottom: unset;
}

.aa-Input {
// stylelint-disable-next-line declaration-no-important
height: var(--aa-search-input-height) !important;
}

.aa-InputWrapperPrefix {
order: 3; // move search icon to end.
}

.aa-InputWrapper {
order: 1;
padding-left: 1rem;
}

.aa-InputWrapperSuffix {
order: 2;
}

.aa-Form {
border: 3px solid $brand-primary;
border-radius: .5rem;

&:focus-within {
border-color: $brand-primary-lightest;
box-shadow: unset;
}
}

.aa-LoadingIndicator,
.aa-SubmitButton {
padding-left: var(--aa-spacing-half);
width: calc(var(--aa-spacing) * 1.25 + var(--aa-icon-size) - 1px);
}

.aa-ClearButton {
@include fa-icon-solid($fa-var-times-circle);
color: rgba(var(--aa-icon-color-rgb), var(--aa-icon-color-alpha));
padding-right: var(--aa-spacing-half);
// hide default icon
.aa-ClearIcon { display: none; }
}

.aa-SubmitButton {
@include fa-icon-solid($fa-var-search);
color: rgba(var(--aa-icon-color-rgb), var(--aa-icon-color-alpha));
// hide default icon
.aa-SubmitIcon { display: none; }
}

.aa-GradientBottom,
.aa-GradientTop { all: unset; }
}

1 change: 0 additions & 1 deletion apps/site/assets/css/_header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,6 @@
.c-form__input-container {
border-radius: 8px;
height: 44px;
margin-left: auto;
}
button.c-form__submit-btn {
background-color: unset;
Expand Down
1 change: 1 addition & 0 deletions apps/site/assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
@import 'flex';
@import 'pill';
@import 'amenity';
@import 'autocomplete-theme';

////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////
Expand Down
6 changes: 3 additions & 3 deletions apps/site/assets/js/algolia-result.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ function _fileIcon(hit) {
}
}

function _contentIcon(hit) {
export function contentIcon(hit) {
let icon;

if (hit.search_api_datasource === "entity:file") {
Expand Down Expand Up @@ -238,7 +238,7 @@ function getPopularIcon(icon) {
export function getIcon(hit, type) {
switch (type) {
case "locations":
return _contentIcon({ ...hit, _content_type: "locations" });
return contentIcon({ ...hit, _content_type: "locations" });
case "stops":
return _getStopOrStationIcon(hit);

Expand All @@ -257,7 +257,7 @@ export function getIcon(hit, type) {
case "documents":
case "events":
case "news":
return _contentIcon(hit);
return contentIcon(hit);

case "usemylocation":
return "";
Expand Down
6 changes: 6 additions & 0 deletions apps/site/assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import pslPageSetup from "./psl-page-setup.js";
import tabbedNav from "./tabbed-nav.js";
import { accordionInit } from "../ts/ui/accordion";
import initializeSentry from "../ts/sentry";
import setupAlgoliaAutocomplete from "../ts/ui/autocomplete/index";

// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix";
Expand All @@ -56,6 +57,11 @@ let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
let Hooks = {};
Hooks.AlgoliaAutocomplete = {
mounted() {
setupAlgoliaAutocomplete(this.el);
}
};
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks
Expand Down
Loading

0 comments on commit ee26428

Please sign in to comment.