diff --git a/apps/transport/client/stylesheets/app.scss b/apps/transport/client/stylesheets/app.scss index 719e1b0823..59a248e296 100644 --- a/apps/transport/client/stylesheets/app.scss +++ b/apps/transport/client/stylesheets/app.scss @@ -33,6 +33,7 @@ @import 'components/stats'; @import 'components/tooltip'; @import 'components/validation'; +@import 'components/feedback'; // States: microinteractions... @import 'states'; diff --git a/apps/transport/client/stylesheets/components/_feedback.scss b/apps/transport/client/stylesheets/components/_feedback.scss new file mode 100644 index 0000000000..e4d27de832 --- /dev/null +++ b/apps/transport/client/stylesheets/components/_feedback.scss @@ -0,0 +1,40 @@ +.feedback-selector { + input { + display: none; + } + + label { + cursor: pointer; + display: inline-block; + margin-right: 12px; + filter: brightness(1.8) grayscale(1) opacity(0.7); + } + + label:hover { + filter: brightness(1.2) grayscale(0.5) opacity(0.9); + } + + input:checked + label { + filter: none; + color: $primary; + } + + .feedback-emojis { + font-size: 48px; + } +} + +.feedback-form { + #full-feedback-form.hidden { + display: none; + } + + .form__group + #full-feedback-form { + margin-top: var(--space-l); + } + + .form-special-field { + // hide field used as honey-pot + display: none; + } +} diff --git a/apps/transport/lib/mailjet/email_sender.ex b/apps/transport/lib/mailjet/email_sender.ex index db8fc7d2fe..0e915c45a6 100644 --- a/apps/transport/lib/mailjet/email_sender.ex +++ b/apps/transport/lib/mailjet/email_sender.ex @@ -24,7 +24,8 @@ defmodule Transport.EmailSender.Dummy do A development-time implementation which just happens to log to console """ - def send_mail(_from_name, from_email, to_email, _reply_to, subject, _text_body, _html_body) do - Logger.info("Would send email: from #{from_email} to #{to_email}, subject '#{subject}'") + def send_mail(_from_name, from_email, to_email, _reply_to, subject, text_body, _html_body) do + Logger.info("Would send email: from #{from_email} to #{to_email}, subject '#{subject}'\n#{text_body}") + {:ok, "dummy email sent"} end end diff --git a/apps/transport/lib/transport_web/controllers/contact_controller.ex b/apps/transport/lib/transport_web/controllers/contact_controller.ex index 5ba2e8ab23..e76ef74f66 100644 --- a/apps/transport/lib/transport_web/controllers/contact_controller.ex +++ b/apps/transport/lib/transport_web/controllers/contact_controller.ex @@ -14,6 +14,9 @@ defmodule TransportWeb.ContactController do end def send_mail(conn, %{"email" => email, "topic" => subject, "question" => question} = params) do + %{email: email, subject: subject, question: question} = + sanitize_inputs(%{email: email, subject: subject, question: question}) + contact_email = TransportWeb.ContactEmail.contact(email, subject, question) case Transport.Mailer.deliver(contact_email) do @@ -30,12 +33,14 @@ defmodule TransportWeb.ContactController do end def send_mail(conn, params) do - Logger.error("Bad parameters for sending email #{params}") + Logger.error("Bad parameters for sending email #{inspect(params)}") conn |> put_flash(:error, gettext("There has been an error, try again later")) |> redirect(to: params["redirect_path"] || page_path(conn, :index)) end + + defp sanitize_inputs(map), do: Map.new(map, fn {k, v} -> {k, v |> String.trim() |> HtmlSanitizeEx.strip_tags()} end) end defmodule TransportWeb.ContactEmail do @@ -49,4 +54,31 @@ defmodule TransportWeb.ContactEmail do |> subject(subject) |> text_body(question) end + + def feedback(rating, explanation, email, feature) do + rating_t = %{"like" => "j’aime", "neutral" => "neutre", "dislike" => "mécontent"} + + reply_email = + if email == "" do + Application.fetch_env!(:transport, :contact_email) + else + email + end + + feedback_content = """ + Vous avez un nouvel avis sur le PAN. + Fonctionnalité : #{feature} + Notation : #{rating_t[rating]} + Adresse e-mail : #{email} + + Explication : #{explanation} + """ + + new() + |> from({"Formulaire feedback", Application.fetch_env!(:transport, :contact_email)}) + |> to(Application.fetch_env!(:transport, :contact_email)) + |> reply_to(reply_email) + |> subject("Nouvel avis pour #{feature} : #{rating_t[rating]}") + |> text_body(feedback_content) + end end diff --git a/apps/transport/lib/transport_web/live/feedback_live.ex b/apps/transport/lib/transport_web/live/feedback_live.ex new file mode 100644 index 0000000000..cf8ef5636b --- /dev/null +++ b/apps/transport/lib/transport_web/live/feedback_live.ex @@ -0,0 +1,67 @@ +defmodule TransportWeb.Live.FeedbackLive do + use Phoenix.LiveView + use TransportWeb.InputHelpers + import TransportWeb.InputHelpers + import TransportWeb.Gettext + require Logger + + @moduledoc """ + A reusable module to display a feedback form for a given feature, you can display inside a normal view or a live view. + In case of normal view, don’t forget to add the app.js script with LiveView js inside the page as if it is not included in the general layout. + If you add feedback for a new feature, add it to the list of features. + """ + + @feedback_rating_values ["like", "neutral", "dislike"] + @feedback_features ["gtfs-stops", "on-demand-validation", "gbfs-validation"] + + def mount(_params, %{"feature" => feature, "locale" => locale} = session, socket) + when feature in @feedback_features do + current_email = session |> get_in(["current_user", "email"]) + + socket = + socket + |> assign( + feature: feature, + current_email: current_email, + feedback_sent: false, + feedback_error: false + ) + + Gettext.put_locale(locale) + + {:ok, socket} + end + + def handle_event("submit", %{"feedback" => %{"name" => name, "email" => email}}, socket) when name !== "" do + # someone filled the honeypot field ("name") => discard as spam + Logger.info("Feedback coming from #{email} has been discarded because it filled the feedback form honeypot") + # spammer get a little fox emoji in their flash message, useful for testing purpose + {:noreply, socket |> assign(:feedback_sent, true)} + end + + def handle_event( + "submit", + %{"feedback" => %{"rating" => rating, "explanation" => explanation, "email" => email, "feature" => feature}}, + socket + ) + when rating in @feedback_rating_values and feature in @feedback_features do + %{email: email, explanation: explanation} = sanitize_inputs(%{email: email, explanation: explanation}) + + feedback_email = TransportWeb.ContactEmail.feedback(rating, explanation, email, feature) + + case Transport.Mailer.deliver(feedback_email) do + {:ok, _} -> + {:noreply, socket |> assign(:feedback_sent, true)} + + {:error, _} -> + {:noreply, socket |> assign(:feedback_error, true)} + end + end + + def handle_event("submit", session, socket) do + Logger.error("Bad parameters for feedback #{inspect(session)}") + {:noreply, socket |> assign(:feedback_error, true)} + end + + defp sanitize_inputs(map), do: Map.new(map, fn {k, v} -> {k, v |> String.trim() |> HtmlSanitizeEx.strip_tags()} end) +end diff --git a/apps/transport/lib/transport_web/live/feedback_live.html.heex b/apps/transport/lib/transport_web/live/feedback_live.html.heex new file mode 100644 index 0000000000..a29b3ba5b5 --- /dev/null +++ b/apps/transport/lib/transport_web/live/feedback_live.html.heex @@ -0,0 +1,59 @@ +
+

<%= dgettext("feedback", "Leave your feedback") %>

+ <.form :let={f} :if={!@feedback_sent} for={%{}} as={:feedback} phx-submit="submit" class="feedback-form no-margin"> +
+
+ <%= dgettext("feedback", "What's your feedback on this page?") %> + + <%= radio_button(f, :rating, "like", id: "like") %> + <%= label f, "like", class: "label-inline", for: "like" do %> + + <% end %> + <%= radio_button(f, :rating, "neutral", id: "neutral") %> + <%= label f, "neutral", class: "label-inline", for: "neutral" do %> + + <% end %> + <%= radio_button(f, :rating, "dislike", id: "dislike") %> + <%= label f, "like", class: "label-inline", for: "dislike" do %> + + <% end %> +
+
+ + + + +

<%= dgettext("feedback", "Thanks for your feedback!") %>

+

<%= gettext("There has been an error, try again later") %>

+ + +
diff --git a/apps/transport/lib/transport_web/live/on_demand_validation_live.html.heex b/apps/transport/lib/transport_web/live/on_demand_validation_live.html.heex index 31284b41aa..dc3ceb1ac6 100644 --- a/apps/transport/lib/transport_web/live/on_demand_validation_live.html.heex +++ b/apps/transport/lib/transport_web/live/on_demand_validation_live.html.heex @@ -168,5 +168,11 @@ +
+ <%= live_render(@socket, TransportWeb.Live.FeedbackLive, + id: "feedback-form", + session: %{"feature" => "on-demand-validation"} + ) %> +
diff --git a/apps/transport/lib/transport_web/live/on_demand_validation_select_live.html.heex b/apps/transport/lib/transport_web/live/on_demand_validation_select_live.html.heex index b1357677fc..23aa63ba84 100644 --- a/apps/transport/lib/transport_web/live/on_demand_validation_select_live.html.heex +++ b/apps/transport/lib/transport_web/live/on_demand_validation_select_live.html.heex @@ -63,4 +63,12 @@ + +
+ <%= live_render(@socket, TransportWeb.Live.FeedbackLive, + id: "feedback-form", + session: %{"feature" => "on-demand-validation"} + ) %> +
+ + + diff --git a/apps/transport/lib/transport_web/templates/gbfs_analyzer/index.html.heex b/apps/transport/lib/transport_web/templates/gbfs_analyzer/index.html.heex index dc7832a868..a5db6dc90a 100644 --- a/apps/transport/lib/transport_web/templates/gbfs_analyzer/index.html.heex +++ b/apps/transport/lib/transport_web/templates/gbfs_analyzer/index.html.heex @@ -175,9 +175,16 @@ +
+ <%= live_render(@conn, TransportWeb.Live.FeedbackLive, session: %{"feature" => "gbfs-validation"}) %> +
+ + + diff --git a/apps/transport/lib/transport_web/templates/validation/show.html.heex b/apps/transport/lib/transport_web/templates/validation/show.html.heex index 22158da60f..5c389c6564 100644 --- a/apps/transport/lib/transport_web/templates/validation/show.html.heex +++ b/apps/transport/lib/transport_web/templates/validation/show.html.heex @@ -57,3 +57,10 @@ + +
+ <%= live_render(@conn, TransportWeb.Live.FeedbackLive, session: %{"feature" => "on-demand-validation"}) %> +
+ + diff --git a/apps/transport/priv/gettext/en/LC_MESSAGES/feedback.po b/apps/transport/priv/gettext/en/LC_MESSAGES/feedback.po new file mode 100644 index 0000000000..4924c9cc2f --- /dev/null +++ b/apps/transport/priv/gettext/en/LC_MESSAGES/feedback.po @@ -0,0 +1,36 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#, elixir-autogen, elixir-format +msgid "Send the feedback" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Why?" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Your email (optional)" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "What's your feedback on this page?" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Leave your feedback" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Thanks for your feedback!" +msgstr "" diff --git a/apps/transport/priv/gettext/feedback.pot b/apps/transport/priv/gettext/feedback.pot new file mode 100644 index 0000000000..24b75cf023 --- /dev/null +++ b/apps/transport/priv/gettext/feedback.pot @@ -0,0 +1,36 @@ +## This file is a PO Template file. +## +## "msgid"s here are often extracted from source code. +## Add new messages manually only if they're dynamic +## messages that can't be statically extracted. +## +## Run "mix gettext.extract" to bring this file up to +## date. Leave "msgstr"s empty as changing them here has no +## effect: edit them in PO (.po) files instead. +# +msgid "" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Send the feedback" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Why?" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Your email (optional)" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "What's your feedback on this page?" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Leave your feedback" +msgstr "" + +#, elixir-autogen, elixir-format +msgid "Thanks for your feedback!" +msgstr "" diff --git a/apps/transport/priv/gettext/fr/LC_MESSAGES/feedback.po b/apps/transport/priv/gettext/fr/LC_MESSAGES/feedback.po new file mode 100644 index 0000000000..d091584fb6 --- /dev/null +++ b/apps/transport/priv/gettext/fr/LC_MESSAGES/feedback.po @@ -0,0 +1,36 @@ +## "msgid"s in this file come from POT (.pot) files. +### +### Do not add, change, or remove "msgid"s manually here as +### they're tied to the ones in the corresponding POT file +### (with the same domain). +### +### Use "mix gettext.extract --merge" or "mix gettext.merge" +### to merge POT files into PO files. +msgid "" +msgstr "" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n>1);\n" + +#, elixir-autogen, elixir-format +msgid "Send the feedback" +msgstr "Envoyer l’avis" + +#, elixir-autogen, elixir-format +msgid "Why?" +msgstr "Pourquoi ?" + +#, elixir-autogen, elixir-format +msgid "Your email (optional)" +msgstr "Votre adresse e-mail (facultatif)" + +#, elixir-autogen, elixir-format +msgid "What's your feedback on this page?" +msgstr "Qu’avez-vous pensé de cette page ?" + +#, elixir-autogen, elixir-format +msgid "Leave your feedback" +msgstr "Laissez-nous votre avis" + +#, elixir-autogen, elixir-format +msgid "Thanks for your feedback!" +msgstr "Merci d’avoir laissé votre avis !" diff --git a/apps/transport/test/transport_web/controllers/contact_controller_test.exs b/apps/transport/test/transport_web/controllers/contact_controller_test.exs index 829a71105e..5f3007ecc3 100644 --- a/apps/transport/test/transport_web/controllers/contact_controller_test.exs +++ b/apps/transport/test/transport_web/controllers/contact_controller_test.exs @@ -1,8 +1,6 @@ defmodule TransportWeb.ContactControllerTest do use TransportWeb.ConnCase, async: true - import Mox import Swoosh.TestAssertions - setup :verify_on_exit! test "Post contact form with honey pot filled", %{conn: conn} do conn @@ -21,10 +19,8 @@ defmodule TransportWeb.ContactControllerTest do contact_path(conn, :send_mail, %{email: "human@user.fr", topic: "dataset", question: "where is my dataset?"}) ) |> get_flash(:info) - |> case do - nil -> assert true - msg -> refute msg =~ "🦊" - end + |> Kernel.=~("🦊") + |> refute assert_email_sent( from: {"PAN, Formulaire Contact", "contact@transport.beta.gouv.fr"}, diff --git a/apps/transport/test/transport_web/controllers/explore_controller_test.exs b/apps/transport/test/transport_web/controllers/explore_controller_test.exs index 32c8e06c26..4d816e7fd5 100644 --- a/apps/transport/test/transport_web/controllers/explore_controller_test.exs +++ b/apps/transport/test/transport_web/controllers/explore_controller_test.exs @@ -48,9 +48,8 @@ defmodule TransportWeb.ExploreControllerTest do doc = Floki.parse_document!(html) [{"title", _, [title]}] = Floki.find(doc, "title") - [{"h2", _, [h2]}] = Floki.find(doc, "h2") assert title == "Carte consolidée des arrêts GTFS (beta)" - assert h2 == "Carte consolidée des arrêts GTFS (beta)" + assert html =~ "

Carte consolidée des arrêts GTFS (beta)

" end end diff --git a/apps/transport/test/transport_web/live_views/feedback_live_test.exs b/apps/transport/test/transport_web/live_views/feedback_live_test.exs new file mode 100644 index 0000000000..3f47d977ec --- /dev/null +++ b/apps/transport/test/transport_web/live_views/feedback_live_test.exs @@ -0,0 +1,91 @@ +defmodule TransportWeb.FeedbackLiveTest do + use TransportWeb.ConnCase, async: true + import Phoenix.LiveViewTest + import Swoosh.TestAssertions + import ExUnit.CaptureLog + import Mox + + setup :verify_on_exit! + + @endpoint TransportWeb.Endpoint + + test "Render the feedback component", %{conn: conn} do + {:ok, _view, html} = + live_isolated(conn, TransportWeb.Live.FeedbackLive, + session: %{"feature" => "on-demand-validation", "locale" => "fr"} + ) + + assert html =~ "Qu’avez-vous pensé de cette page ?" + end + + test "Post feedback form with honey pot filled", %{conn: conn} do + {:ok, view, _html} = + live_isolated(conn, TransportWeb.Live.FeedbackLive, + session: %{"feature" => "on-demand-validation", "locale" => "fr"} + ) + + view + |> element("form") + |> render_submit(%{feedback: %{email: "spammer@internet.com", name: "John Doe"}}) + |> Kernel.=~("Merci d’avoir laissé votre avis !") + |> assert + + assert_no_email_sent() + end + + test "Post feedback form without honey pot", %{conn: conn} do + {:ok, view, _html} = + live_isolated(conn, TransportWeb.Live.FeedbackLive, + session: %{"feature" => "on-demand-validation", "locale" => "fr"} + ) + + view + |> element("form") + |> render_submit(%{ + feedback: %{ + email: "", + feature: "on-demand-validation", + rating: "like", + explanation: "so useful for my GTFS files" + } + }) + |> Kernel.=~("Merci d’avoir laissé votre avis !") + |> assert + + assert_email_sent( + from: {"Formulaire feedback", "contact@transport.beta.gouv.fr"}, + to: "contact@transport.beta.gouv.fr", + subject: "Nouvel avis pour on-demand-validation : j’aime", + text_body: + "Vous avez un nouvel avis sur le PAN.\nFonctionnalité : on-demand-validation\nNotation : j’aime\nAdresse e-mail : \n\nExplication : so useful for my GTFS files\n", + html_body: nil, + reply_to: "contact@transport.beta.gouv.fr" + ) + end + + test "Post invalid parameters in feedback form and check it doesn’t crash", %{conn: conn} do + {:ok, view, _html} = + live_isolated(conn, TransportWeb.Live.FeedbackLive, + session: %{"feature" => "on-demand-validation", "locale" => "fr"} + ) + + {view, logs} = + with_log(fn -> + view + |> element("form") + |> render_submit(%{topic: "question", demande: "where is my dataset?"}) + end) + + assert view =~ "Il y a eu une erreur réessayez." + + assert logs =~ "Bad parameters for feedback" + + assert_no_email_sent() + end + + test "Is correctly included in the validation Liveview", %{conn: conn} do + Transport.Shared.Schemas.Mock |> expect(:transport_schemas, 2, fn -> %{} end) + {:ok, _view, html} = conn |> live(live_path(conn, TransportWeb.Live.OnDemandValidationSelectLive)) + assert html =~ "

Laissez-nous votre avis

" + end +end