diff --git a/assets/css/app.css b/assets/css/app.css index 982f70d2..3e29bd9f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -12,3 +12,15 @@ @apply p-4 lg:p-6 xl:p-8; } } + +@layer utilities { + .min-h-dvh { + min-height: 100vh; + } + + @supports (min-height: 100dvh) { + .min-h-dvh { + min-height: 100dvh; + } + } +} diff --git a/assets/js/app.js b/assets/js/app.js index 2fd7c72d..99f12892 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -16,16 +16,12 @@ // // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. -import 'phoenix_html'; +import "phoenix_html"; // Establish Phoenix Socket and LiveView configuration. -import { Socket } from 'phoenix'; -import { LiveSocket } from 'phoenix_live_view'; -import topbar from '../vendor/topbar'; -import Sortable from '../vendor/sortable'; - -function playSound(url) { - new Audio(url).play(); -} +import { Socket } from "phoenix"; +import { LiveSocket } from "phoenix_live_view"; +import topbar from "../vendor/topbar"; +import Sortable from "../vendor/sortable"; let Hooks = {}; @@ -33,17 +29,22 @@ Hooks.LocaleTime = { mounted() { const dt = new Date(this.el.textContent); const locale = document.documentElement.lang; - const options = { year: 'numeric', month: 'long', day: 'numeric' }; + const options = { year: "numeric", month: "long", day: "numeric" }; this.el.textContent = dt.toLocaleString(locale, options); }, }; +let correctSound = new Audio("/audios/correct.mp3"); +let incorrectSound = new Audio("/audios/incorrect.mp3"); + +function playSound(isCorrect) { + const sound = isCorrect ? correctSound : incorrectSound; + sound.play(); +} + Hooks.LessonSoundEffect = { mounted() { - this.handleEvent('option-selected', ({ isCorrect }) => { - const fileName = isCorrect ? '/correct.mp3' : '/incorrect.mp3'; - playSound('/audios' + fileName); - }); + this.handleEvent("option-selected", ({ isCorrect }) => playSound(isCorrect)); }, }; @@ -51,16 +52,13 @@ Hooks.Sortable = { mounted() { let group = this.el.dataset.group; let isDragging = false; - this.el.addEventListener( - 'focusout', - (e) => isDragging && e.stopImmediatePropagation() - ); + this.el.addEventListener("focusout", (e) => isDragging && e.stopImmediatePropagation()); let sorter = new Sortable(this.el, { group: group ? { name: group, pull: true, put: true } : undefined, animation: 150, - filter: '.filtered', - dragClass: 'drag-item', - ghostClass: 'drag-ghost', + filter: ".filtered", + dragClass: "drag-item", + ghostClass: "drag-ghost", onStart: (e) => (isDragging = true), // prevent phx-blur from firing while dragging onEnd: (e) => { isDragging = false; @@ -70,11 +68,7 @@ Hooks.Sortable = { to: e.to.dataset, ...e.item.dataset, }; - this.pushEventTo( - this.el, - this.el.dataset['drop'] || 'reposition', - params - ); + this.pushEventTo(this.el, this.el.dataset["drop"] || "reposition", params); }, }); }, @@ -85,13 +79,10 @@ Hooks.ClearFlash = { const kind = this.el.dataset.kind; const delay = 5000; - setTimeout(() => this.el.classList.add('opacity-0'), delay); + setTimeout(() => this.el.classList.add("opacity-0"), delay); // Make sure we also clear the flash. Otherwise, it will be displayed for other items too. - setTimeout( - () => this.pushEventTo('#' + this.el.id, 'lv:clear-flash', { key: kind }), - delay + 1000 - ); + setTimeout(() => this.pushEventTo("#" + this.el.id, "lv:clear-flash", { key: kind }), delay + 1000); }, }; @@ -103,13 +94,10 @@ Uploaders.S3 = function (entries, onViewError) { onViewError(() => xhr.abort()); - xhr.onload = () => - xhr.status >= 200 && xhr.status < 300 - ? entry.progress(100) - : entry.error(); + xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? entry.progress(100) : entry.error()); xhr.onerror = () => entry.error(); - xhr.upload.addEventListener('progress', (event) => { + xhr.upload.addEventListener("progress", (event) => { if (event.lengthComputable) { let percent = Math.round((event.loaded / event.total) * 100); if (percent < 100) { @@ -118,25 +106,23 @@ Uploaders.S3 = function (entries, onViewError) { } }); - xhr.open('PUT', url, true); - xhr.setRequestHeader('credentials', 'same-origin parameter'); + xhr.open("PUT", url, true); + xhr.setRequestHeader("credentials", "same-origin parameter"); xhr.send(entry.file); }); }; -let csrfToken = document - .querySelector("meta[name='csrf-token']") - .getAttribute('content'); -let liveSocket = new LiveSocket('/live', Socket, { +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); +let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, uploaders: Uploaders, params: { _csrf_token: csrfToken }, }); // Show progress bar on live navigation and form submits -topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }); -window.addEventListener('phx:page-loading-start', (_info) => topbar.show(1000)); -window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide()); +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }); +window.addEventListener("phx:page-loading-start", (_info) => topbar.show(1000)); +window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide()); // connect if there are any LiveViews on the page liveSocket.connect(); diff --git a/lib/components/modal.ex b/lib/components/modal.ex index 6dbed7f0..e621aae5 100644 --- a/lib/components/modal.ex +++ b/lib/components/modal.ex @@ -47,7 +47,7 @@ defmodule UneebeeWeb.Components.Modal do phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} phx-key="escape" phx-click-away={JS.exec("data-cancel", to: "##{@id}")} - class="shadow-gray-700/10 ring-gray-700/10 relative hidden h-screen bg-white p-8 shadow-lg ring-1 transition lg:h-auto lg:rounded-2xl" + class="shadow-gray-700/10 ring-gray-700/10 relative hidden min-h-dvh bg-white p-8 shadow-lg ring-1 transition lg:h-auto lg:rounded-2xl" >
+
+
+ +
+ +
+ +
diff --git a/lib/gamification/medal_live/medal_list_component.ex b/lib/gamification/medal_live/medal_list_component.ex index 8b65b475..50b80250 100644 --- a/lib/gamification/medal_live/medal_list_component.ex +++ b/lib/gamification/medal_live/medal_list_component.ex @@ -15,7 +15,7 @@ defmodule UneebeeWeb.Components.MedalList do <%= list_title(@kind, total_medals(@medals)) %> -
+
<.medal_badge :for={user_medal <- @medals} id={"medal-#{user_medal.reason}"} diff --git a/lib/layouts/layouts.ex b/lib/layouts/layouts.ex index 0ab0fc2d..37efef32 100644 --- a/lib/layouts/layouts.ex +++ b/lib/layouts/layouts.ex @@ -10,6 +10,7 @@ defmodule UneebeeWeb.Layouts do import UneebeeWeb.Layouts.MenuUtils alias Uneebee.Organizations.School + alias Uneebee.Organizations.SchoolUser alias UneebeeWeb.Components.Layouts.CourseSelect alias UneebeeWeb.Components.Layouts.LessonSelect @@ -22,4 +23,12 @@ defmodule UneebeeWeb.Layouts do @spec page_title(String.t() | nil, School.t() | nil) :: String.t() def page_title(nil, school), do: school_name(school) def page_title(title, _school), do: title + + @spec plausible_domain(School.t()) :: String.t() | nil + def plausible_domain(%School{} = host_school), do: host_school.custom_domain + def plausible_domain(_school), do: nil + + @spec enable_plausible?(SchoolUser.t() | nil) :: boolean() + def enable_plausible?(%SchoolUser{} = school_user), do: school_user.analytics? + def enable_plausible?(nil), do: false end diff --git a/lib/layouts/templates/app.html.heex b/lib/layouts/templates/app.html.heex index d06ec019..fc2ab79a 100644 --- a/lib/layouts/templates/app.html.heex +++ b/lib/layouts/templates/app.html.heex @@ -6,7 +6,7 @@ <.app_menu active_page={@active_page} user_role={@user_role} course={@course} lessons={@lessons} lesson={@lesson} first_lesson_id={@first_lesson_id} /> -
+
+ + diff --git a/lib/organizations/organizations_context.ex b/lib/organizations/organizations_context.ex index 683e584f..9dd7f56e 100644 --- a/lib/organizations/organizations_context.ex +++ b/lib/organizations/organizations_context.ex @@ -142,17 +142,31 @@ defmodule Uneebee.Organizations do ## Examples - iex> update_school_user(school_user, %{role: :student}) + iex> update_school_user(school_user_id, %{role: :student}) {:ok, %SchoolUser{}} - iex> update_school_user(school_user, %{role: :invalid}) + iex> update_school_user(school_user_id, %{role: :invalid}) {:error, %Ecto.Changeset{}} """ - @spec update_school_user(SchoolUser.t(), map()) :: school_user_changeset() - def update_school_user(%SchoolUser{} = school_user, attrs \\ %{}) do - school_user |> SchoolUser.changeset(attrs) |> Repo.update() + @spec update_school_user(non_neg_integer(), map()) :: school_user_changeset() + def update_school_user(school_user_id, attrs \\ %{}) do + school_user_id |> get_school_user!() |> SchoolUser.changeset(attrs) |> Repo.update() end + @doc """ + Get a school user by id. + + ## Examples + + iex> get_school_user!(123) + %SchoolUser{} + + iex> get_school_user!(456) + ** (Ecto.NoResultsError) + """ + @spec get_school_user!(non_neg_integer()) :: SchoolUser.t() + def get_school_user!(id), do: Repo.get!(SchoolUser, id) + @doc """ Get a user from a school given their usernames. diff --git a/lib/organizations/school_user_schema.ex b/lib/organizations/school_user_schema.ex index 5b3320b0..3b51c76e 100644 --- a/lib/organizations/school_user_schema.ex +++ b/lib/organizations/school_user_schema.ex @@ -14,6 +14,7 @@ defmodule Uneebee.Organizations.SchoolUser do schema "school_users" do field :role, Ecto.Enum, values: [:manager, :teacher, :student], default: :student + field :analytics?, :boolean, default: true field :approved?, :boolean, default: false field :approved_at, :utc_datetime_usec belongs_to :approved_by, User @@ -28,8 +29,8 @@ defmodule Uneebee.Organizations.SchoolUser do @spec changeset(Ecto.Schema.t(), map()) :: Ecto.Changeset.t() def changeset(school_user, attrs \\ %{}) do school_user - |> cast(attrs, [:approved?, :approved_at, :approved_by_id, :role, :school_id, :user_id]) - |> validate_required([:role, :school_id, :user_id]) + |> cast(attrs, [:analytics?, :approved?, :approved_at, :approved_by_id, :role, :school_id, :user_id]) + |> validate_required([:analytics?, :role, :school_id, :user_id]) |> unique_constraint([:school_id, :user_id]) end end diff --git a/lib/router.ex b/lib/router.ex index 2c47d11c..6c1ff3c6 100644 --- a/lib/router.ex +++ b/lib/router.ex @@ -16,7 +16,12 @@ defmodule UneebeeWeb.Router do plug :fetch_live_flash plug :put_root_layout, html: {UneebeeWeb.Layouts, :root} plug :protect_from_forgery - plug :put_secure_browser_headers, %{"content-security-policy" => "default-src 'self'; connect-src 'self' #{@csp_connect_src}; img-src 'self' #{@cdn_url} data: blob:;"} + + plug :put_secure_browser_headers, %{ + "content-security-policy" => + "default-src 'self'; script-src-elem 'self' https://plausible.io; connect-src 'self' https://plausible.io #{@csp_connect_src}; img-src 'self' #{@cdn_url} data: blob:;" + } + plug :fetch_current_user plug :fetch_school plug :check_school_setup diff --git a/lib/shared/utilities.ex b/lib/shared/utilities.ex new file mode 100644 index 00000000..ab9bce1d --- /dev/null +++ b/lib/shared/utilities.ex @@ -0,0 +1,24 @@ +defmodule UneebeeWeb.Shared.Utilities do + @moduledoc """ + Shared utilities for common use cases. + """ + + @doc """ + Convert a string into a boolean. + + ## Examples + + iex> string_to_boolean("true") + true + + iex> string_to_boolean("false") + false + + iex> string_to_boolean("anything else") + true + """ + @spec string_to_boolean(String.t()) :: boolean() + def string_to_boolean("true"), do: true + def string_to_boolean("false"), do: false + def string_to_boolean(_str), do: true +end diff --git a/lib/translate/translate_plug.ex b/lib/translate/translate_plug.ex index 28204093..7f6b07b4 100644 --- a/lib/translate/translate_plug.ex +++ b/lib/translate/translate_plug.ex @@ -72,7 +72,10 @@ defmodule UneebeeWeb.Plugs.Translate do def get_browser_locale(conn) do locale = extract_locale(get_req_header(conn, "accept-language")) - if Enum.member?(supported_locales(), String.to_existing_atom(locale)), do: locale, else: @default_locale + # Converted supported locales to string + supported = Enum.map(supported_locales(), fn locale -> Atom.to_string(locale) end) + + if Enum.member?(supported, locale), do: locale, else: @default_locale end # Parse the `accept-language` header and extract the first locale there. diff --git a/priv/repo/migrations/20231112083229_add_analytics_to_school_users.exs b/priv/repo/migrations/20231112083229_add_analytics_to_school_users.exs new file mode 100644 index 00000000..c20b6f66 --- /dev/null +++ b/priv/repo/migrations/20231112083229_add_analytics_to_school_users.exs @@ -0,0 +1,9 @@ +defmodule Uneebee.Repo.Migrations.AddAnalyticsToSchoolUsers do + use Ecto.Migration + + def change do + alter table(:school_users) do + add :analytics?, :boolean, default: true, null: false + end + end +end diff --git a/test/accounts/user_live/user_registration_live_test.exs b/test/accounts/user_live/user_registration_live_test.exs index 2ce9a0e5..b76521aa 100644 --- a/test/accounts/user_live/user_registration_live_test.exs +++ b/test/accounts/user_live/user_registration_live_test.exs @@ -51,6 +51,15 @@ defmodule UneebeeWeb.UserRegistrationLiveTest do assert html =~ ~s' element("button", "Disable analytics") + |> render_click() + |> follow_redirect(conn, ~p"/dashboard/managers") + + assert {:ok, _updated_lv, _html} = + updated_lv + |> element("button", "Enable analytics") + |> render_click() + |> follow_redirect(conn, ~p"/dashboard/managers") + end + + test "hides the analytics toggle if the school has a parent school", %{conn: conn, school: school} do + parent_school = school_fixture(%{name: "Parent School"}) + Organizations.update_school(school, %{school_id: parent_school.id}) + + {:ok, lv, _html} = live(conn, ~p"/dashboard/managers") + + refute has_element?(lv, ~s|button:fl-icontains("disable analytics")|) + refute has_element?(lv, ~s|button:fl-icontains("enable analytics")|) + end + test "adds a user using their email address", %{conn: conn} do user = user_fixture(%{first_name: "Albert", email: "alb@example.com"}) diff --git a/test/organizations/organizations_context_test.exs b/test/organizations/organizations_context_test.exs index 67d6264d..8b2164d9 100644 --- a/test/organizations/organizations_context_test.exs +++ b/test/organizations/organizations_context_test.exs @@ -310,7 +310,7 @@ defmodule Uneebee.OrganizationsTest do user = user_fixture() school_user = school_user_fixture(%{school: school, user: user, role: :teacher}) - assert {:ok, %SchoolUser{} = updated_school_user} = Organizations.update_school_user(school_user, %{role: :manager}) + assert {:ok, %SchoolUser{} = updated_school_user} = Organizations.update_school_user(school_user.id, %{role: :manager}) assert updated_school_user.role == :manager assert updated_school_user.school_id == school.id @@ -322,7 +322,20 @@ defmodule Uneebee.OrganizationsTest do user = user_fixture() school_user = school_user_fixture(%{school: school, user: user}) - assert {:error, %Ecto.Changeset{}} = Organizations.update_school_user(school_user, %{role: :invalid}) + assert {:error, %Ecto.Changeset{}} = Organizations.update_school_user(school_user.id, %{role: :invalid}) + end + end + + describe "get_school_user!/1" do + test "returns a school user" do + school = school_fixture(%{slug: "user-#{System.unique_integer()}"}) + school_user = school_user_fixture(%{school: school, preload: :user}) + + assert Organizations.get_school_user!(school_user.id) + end + + test "raises if the school user doesn't exist" do + assert_raise Ecto.NoResultsError, fn -> Organizations.get_school_user!(0) end end end