Skip to content

Commit

Permalink
feat(Live.TripPlanner): Add switch origin/destination button (#2317)
Browse files Browse the repository at this point in the history
* fix(autocomplete): add missing property and fix class attribute

* fixes(TripPlanner.ResultsSummary): more forgiving for incomplete params

* ui(TripPlanner.InputForm): add swap button

* feat(Live.TripPlanner): swap from/to

also refactor AlgoliaAutocomplete hook

* fix swap button positioning for both mobile and desktop

* fix(Live.TripPlanner): use now time for now

* Update lib/dotcom_web/components/trip_planner/input_form.ex

Co-authored-by: Josh Larson <[email protected]>

* Update lib/dotcom_web/components/trip_planner/input_form.ex

Co-authored-by: Josh Larson <[email protected]>

* Update lib/dotcom_web/components/trip_planner/input_form.ex

Co-authored-by: Josh Larson <[email protected]>

---------

Co-authored-by: Josh Larson <[email protected]>
  • Loading branch information
thecristen and joshlarson authored Jan 9, 2025
1 parent 2f1e8c1 commit 256920d
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 97 deletions.
4 changes: 2 additions & 2 deletions assets/js/test/trip-plan_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import testConfig from "../../ts/jest.config";
const { testURL } = testConfig;

const tripPlanForm = `<form id="plan">
<input class="location-input" data-autocomplete="true" id="from" name="plan[from]" placeholder="Ex: 10 Park Plaza" type="text" autocomplete="off">
<input data-autocomplete="true" id="from" name="plan[from]" placeholder="Ex: 10 Park Plaza" type="text" autocomplete="off">
<input type="hidden" id="from_latitude" name="plan[from_latitude]">
<input type="hidden" id="from_longitude" name="plan[from_longitude]">
<input type="hidden" id="from_stop_id" name="plan[from_stop_id]">
<input class="location-input" data-autocomplete="true" id="to" name="plan[to]" placeholder="Ex: Boston Children's Museum" type="text" autocomplete="off">
<input data-autocomplete="true" id="to" name="plan[to]" placeholder="Ex: Boston Children's Museum" type="text" autocomplete="off">
<div id="trip-plan-reverse-control"></div>
<div id="trip-plan__container--to"></div>
<div id="trip-plan__container--from"></div>
Expand Down
103 changes: 43 additions & 60 deletions assets/ts/phoenix-hooks/algolia-autocomplete.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,63 @@
/* eslint-disable no-param-reassign */
import { ViewHook } from "phoenix_live_view";
import setupAlgoliaAutocomplete from "../ui/autocomplete";
import {
Item,
LocationItem,
PopularItem,
StopItem
} from "../ui/autocomplete/__autocomplete";
import { Stop } from "../__v3api";

function valueFromData(data: Partial<Item>, fieldName: string): string {
if (fieldName === "name") {
return (
(data[fieldName as keyof Item] as string) ||
(data as LocationItem).formatted ||
(data as StopItem).stop?.name ||
""
);
}
return (
(data[fieldName as keyof Item] as string) ||
((data as StopItem).stop
? ((data as StopItem).stop![fieldName as keyof Stop] as string)
: "") ||
""
);
function valuesFromData(data: Partial<Item>): object {
const name =
(data["name" as keyof Item] as string) ||
(data as LocationItem).formatted ||
(data as StopItem).stop?.name ||
"";
const stop_id =
(data as StopItem).stop?.id || (data as PopularItem).stop_id || "";
const longitude = data.longitude || (data as StopItem).stop?.longitude || "";
const latitude = data.latitude || (data as StopItem).stop?.latitude || "";
return { name, stop_id, latitude, longitude };
}

function fieldNameFromInput(inputEl: HTMLInputElement): string | undefined {
return inputEl.name.match(/((name|latitude|longitude|stop_id)+)/g)?.at(-1);
}
const AlgoliaAutocomplete: Partial<ViewHook> = {
mounted() {
const hook = (this as unknown) as ViewHook;

if (hook.el) {
const locationInputs =
hook.el.parentElement?.querySelectorAll<HTMLInputElement>(
"input.location-input"
) || ([] as HTMLInputElement[]);

const pushToLiveView = (data: Partial<Item>): void => {
if (hook.el.querySelector("[data-config='trip-planner']")) {
hook.pushEvent("map_change", {
id: hook.el.id,
...data
});
if (this.el) {
let pushToLiveView: Function | undefined;
let initialState: Function | undefined;
let key: string | undefined;
const isTripPlanner = !!this.el?.querySelector(
"[data-config='trip-planner']"
);

locationInputs.forEach(inputEl => {
const fieldName = fieldNameFromInput(inputEl);
if (fieldName) {
inputEl.value = valueFromData(data, fieldName);
inputEl.dispatchEvent(new Event("change", { bubbles: true }));
if (isTripPlanner) {
key = this.el.id.replace("trip-planner-input-form--", ""); // "from"/"to"
pushToLiveView = (data: Partial<Item>): void => {
this.pushEvent!("input_form_change", {
input_form: {
[key!]: valuesFromData(data)
}
});
}
};
};
initialState = (): string =>
this.el
?.parentElement!.querySelector('input[name*="name"]')
?.getAttribute("value") || "";
}

const initialState = (): string => {
const inputValues = [...locationInputs].map(inputEl => {
if (inputEl.value) {
const fieldName = fieldNameFromInput(inputEl);
return [fieldName, inputEl.value];
}
return [];
});

if (inputValues) {
const data = Object.fromEntries(inputValues);
pushToLiveView(data); // needed for LV to sync with input state on initial load
return data.name || "";
}
const autocompleteWidget = setupAlgoliaAutocomplete(
this.el,
pushToLiveView,
initialState
);

return "";
};

setupAlgoliaAutocomplete(hook.el, pushToLiveView, initialState);
if (isTripPlanner && key) {
this.handleEvent!("set-query", values => {
// @ts-ignore
const name = values[key]?.name || "";
autocompleteWidget.setQuery(name);
});
}
}
}
};
Expand Down
1 change: 1 addition & 0 deletions assets/ts/ui/autocomplete/__autocomplete.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export type PopularItem = {
url: string;
state: string;
municipality: string;
stop_id?: string;
};

export type AutocompleteItem = RouteItem | StopItem | ContentItem;
Expand Down
12 changes: 9 additions & 3 deletions assets/ts/ui/autocomplete/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { autocomplete, AutocompleteOptions } from "@algolia/autocomplete-js";
import {
autocomplete,
AutocompleteApi,
AutocompleteOptions
} from "@algolia/autocomplete-js";
import configs from "./config";

/**
* Creates the Algolia Autocomplete instances for various search experiences on
* MBTA.com.
Expand All @@ -9,7 +12,8 @@ function setupAlgoliaAutocomplete(
wrapper: HTMLElement,
pushToLiveView?: Function,
initialState?: Function
): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): AutocompleteApi<any> {
const container = wrapper.querySelector<HTMLElement>(
".c-search-bar__autocomplete"
);
Expand Down Expand Up @@ -40,6 +44,8 @@ function setupAlgoliaAutocomplete(
document
.querySelector("[data-nav='veil']")
?.addEventListener("click", () => autocompleteWidget.setIsOpen(false));

return autocompleteWidget;
}

export default setupAlgoliaAutocomplete;
2 changes: 1 addition & 1 deletion assets/ts/ui/autocomplete/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const geolocationSource = (
templates: {
item({ item, html }) {
return html`
<span class="text-brand-primary">
<span className="text-brand-primary">
<i key=${item.value} className="fa fa-location-arrow fa-fw mr-xs"></i>
${item.value}
</span>
Expand Down
73 changes: 50 additions & 23 deletions lib/dotcom_web/components/trip_planner/input_form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,35 +25,36 @@ defmodule DotcomWeb.Components.TripPlanner.InputForm do
<section class={["rounded px-2 py-3 sm:px-8 sm:py-6 lg:px-12 lg:py-8 bg-charcoal-90", @class]}>
<.form
:let={f}
class="md:grid md:grid-cols-2 gap-x-8 gap-y-2"
class="flex flex-col md:grid md:grid-cols-[1fr_max-content_1fr] md:gap-x-lg gap-y-sm pt-md"
id="trip-planner-input-form"
for={@changeset}
method="get"
phx-change="input_form_change"
phx-submit="input_form_submit"
>
<fieldset :for={field <- [:from, :to]} id={"trip-planner-locations-#{field}"} class="mb-sm">
<legend class="text-charcoal-40 m-0 py-sm">{Phoenix.Naming.humanize(field)}</legend>
<.algolia_autocomplete
config_type="trip-planner"
placeholder={"Enter #{if(field == :from, do: "an origin", else: "a destination")} location"}
id={"trip-planner-input-form--#{field}"}
<.location_search_box
name="trip-planner-input-form--from"
field={f[:from]}
placeholder="Enter an origin location"
/>
<div class="-mb-[20px] md:-mt-md md:mb-0 self-end md:self-auto">
<div class="hidden md:block md:py-sm md:mb-[10px]">
&nbsp; <%!-- helps align the swap button on desktop--%>
</div>
<button
type="button"
phx-click="swap_direction"
class="px-xs bg-transparent fill-brand-primary hover:fill-black"
>
<.inputs_for :let={location_f} field={f[field]} skip_hidden={true}>
<input
:for={subfield <- InputForm.Location.fields()}
type="hidden"
class="location-input"
id={location_f[subfield].id}
value={location_f[subfield].value}
name={location_f[subfield].name}
/>
</.inputs_for>
<.error_container :for={{msg, _} <- f[field].errors}>
{msg}
</.error_container>
</.algolia_autocomplete>
</fieldset>
<span class="sr-only">Swap origin and destination locations</span>
<.icon class="h-6 w-6 rotate-90 md:rotate-0" name="right-left" />
</button>
</div>
<.location_search_box
name="trip-planner-input-form--to"
field={f[:to]}
placeholder="Enter a destination location"
/>
<fieldset class="mb-sm">
<legend class="text-charcoal-40 m-0 py-sm">When</legend>
<.input_group
Expand All @@ -77,7 +78,7 @@ defmodule DotcomWeb.Components.TripPlanner.InputForm do
{msg}
</.error_container>
</fieldset>
<div>
<div class="col-start-3">
<fieldset class="mb-sm">
<legend class="text-charcoal-40 m-0 py-sm">Modes</legend>
<.accordion variant="multiselect">
Expand Down Expand Up @@ -111,6 +112,32 @@ defmodule DotcomWeb.Components.TripPlanner.InputForm do
"""
end

defp location_search_box(assigns) do
assigns =
assigns
|> assign(:location_keys, InputForm.Location.fields())

~H"""
<fieldset class="mb-sm -mt-md" id={"#{@name}-wrapper"}>
<legend class="text-charcoal-40 m-0 py-sm">{Phoenix.Naming.humanize(@field.field)}</legend>
<.algolia_autocomplete config_type="trip-planner" placeholder={@placeholder} id={@name}>
<.inputs_for :let={location_f} field={@field} skip_hidden={true}>
<input
:for={subfield <- @location_keys}
type="hidden"
id={location_f[subfield].id}
value={location_f[subfield].value}
name={location_f[subfield].name}
/>
</.inputs_for>
<.feedback :for={{msg, _} <- @field.errors} :if={used_input?(@field)} kind={:error}>
{msg}
</.feedback>
</.algolia_autocomplete>
</fieldset>
"""
end

defp datepicker_config do
%{
default_date: Timex.now("America/New_York"),
Expand Down
13 changes: 9 additions & 4 deletions lib/dotcom_web/components/trip_planner/results_summary.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,19 @@ defmodule DotcomWeb.Components.TripPlanner.ResultsSummary do
"""
end

defp submission_summary(%{from: from, to: to}) do
"Trips from #{from.changes.name} to #{to.changes.name}"
defp submission_summary(%{
from: %{changes: %{name: from_name}},
to: %{changes: %{name: to_name}}
}) do
"Trips from #{from_name} to #{to_name}"
end

defp submission_summary(_), do: nil

defp time_summary(%{datetime: datetime, datetime_type: datetime_type}) do
preamble = if datetime_type == "arrive_by", do: "Arriving by ", else: "Leaving at "
defp time_summary(%{datetime: datetime} = params) do
preamble =
if Map.get(params, :datetime_type) == "arrive_by", do: "Arriving by ", else: "Leaving at "

time_description = Timex.format!(datetime, "{h12}:{m}{am}")
date_description = Timex.format!(datetime, "{WDfull}, {Mfull} ")
preamble <> time_description <> " on " <> date_description <> Inflex.ordinalize(datetime.day)
Expand Down
Loading

0 comments on commit 256920d

Please sign in to comment.