From ca2f7a5b92de14ce9993065bd1288d42951fb4f7 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 30 Nov 2023 13:53:45 -0300 Subject: [PATCH 1/8] feat: added create strand overlay in strands view - adjusted `<.input>` component to not render the label at all (instead of rendering it empty) when n o label attr is present - added support to label in `CurriculumItemSearchComponent` - created `LantternWeb.SharedLive.MultiSelectComponent` as an alternative to multi select fields, but with different UX using badges - replaced `LantternWeb.Admin.StrandLive.FormComponent` with `LantternWeb.StrandLive.StrandFormComponent` --- lib/lanttern/learning_context/strand.ex | 2 + .../components/form_components.ex | 3 + lib/lanttern_web/helpers/notify_helpers.ex | 2 +- .../live/admin/strand_live/index.ex | 2 +- .../live/admin/strand_live/index.html.heex | 12 +- .../live/admin/strand_live/show.html.heex | 11 +- .../curriculum_item_search_component.ex | 4 + .../class_filter_form_component.ex | 2 +- .../shared_live/multi_select_component.ex | 132 ++++++++++++++++++ lib/lanttern_web/live/strand_live/list.ex | 61 ++------ .../live/strand_live/list.html.heex | 35 ++++- .../strand_form_component.ex} | 120 +++++++++------- lib/lanttern_web/router.ex | 1 + .../live/admin/strand_live_test.exs | 12 +- 14 files changed, 279 insertions(+), 120 deletions(-) create mode 100644 lib/lanttern_web/live/shared_live/multi_select_component.ex rename lib/lanttern_web/live/{admin/strand_live/form_component.ex => strand_live/strand_form_component.ex} (58%) diff --git a/lib/lanttern/learning_context/strand.ex b/lib/lanttern/learning_context/strand.ex index bbee64cb..418bf15e 100644 --- a/lib/lanttern/learning_context/strand.ex +++ b/lib/lanttern/learning_context/strand.ex @@ -7,7 +7,9 @@ defmodule Lanttern.LearningContext.Strand do schema "strands" do field :name, :string field :description, :string + field :subject_id, :id, virtual: true field :subjects_ids, {:array, :id}, virtual: true + field :year_id, :id, virtual: true field :years_ids, {:array, :id}, virtual: true has_many :curriculum_items, Lanttern.Curricula.StrandCurriculumItem, diff --git a/lib/lanttern_web/components/form_components.ex b/lib/lanttern_web/components/form_components.ex index f4b55e44..cc3b2c95 100644 --- a/lib/lanttern_web/components/form_components.ex +++ b/lib/lanttern_web/components/form_components.ex @@ -158,6 +158,7 @@ defmodule LantternWeb.FormComponents do ~H"""
<.label + :if={@label || @custom_label != []} for={@id} show_optional={@show_optional} custom={if @custom_label == [], do: false, else: true} @@ -201,6 +202,7 @@ defmodule LantternWeb.FormComponents do ~H"""
<.label + :if={@label || @custom_label != []} for={@id} show_optional={@show_optional} custom={if @custom_label == [], do: false, else: true} @@ -218,6 +220,7 @@ defmodule LantternWeb.FormComponents do ~H"""
<.label + :if={@label || @custom_label != []} for={@id} show_optional={@show_optional} custom={if @custom_label == [], do: false, else: true} diff --git a/lib/lanttern_web/helpers/notify_helpers.ex b/lib/lanttern_web/helpers/notify_helpers.ex index 7444aa47..159258f8 100644 --- a/lib/lanttern_web/helpers/notify_helpers.ex +++ b/lib/lanttern_web/helpers/notify_helpers.ex @@ -1,4 +1,4 @@ -defmodule LantternWeb.Helpers.NotifyHelpers do +defmodule LantternWeb.NotifyHelpers do def notify_parent(module, msg, %{notify_parent: true}), do: send(self(), {module, msg}) diff --git a/lib/lanttern_web/live/admin/strand_live/index.ex b/lib/lanttern_web/live/admin/strand_live/index.ex index f8e809bb..93ff0ae2 100644 --- a/lib/lanttern_web/live/admin/strand_live/index.ex +++ b/lib/lanttern_web/live/admin/strand_live/index.ex @@ -45,7 +45,7 @@ defmodule LantternWeb.Admin.StrandLive.Index do end @impl true - def handle_info({LantternWeb.Admin.StrandLive.FormComponent, {:saved, strand}}, socket) do + def handle_info({LantternWeb.StrandLive.StrandFormComponent, {:saved, strand}}, socket) do {:noreply, stream_insert(socket, :strands, strand)} end diff --git a/lib/lanttern_web/live/admin/strand_live/index.html.heex b/lib/lanttern_web/live/admin/strand_live/index.html.heex index eef2cb48..14acac9b 100644 --- a/lib/lanttern_web/live/admin/strand_live/index.html.heex +++ b/lib/lanttern_web/live/admin/strand_live/index.html.heex @@ -51,12 +51,18 @@ show on_cancel={JS.patch(~p"/admin/strands")} > + <.header> + <%= @page_title %> + <:subtitle>Use this form to manage strand records in your database. + <.live_component - module={LantternWeb.Admin.StrandLive.FormComponent} + module={LantternWeb.StrandLive.StrandFormComponent} id={@strand.id || :new} - title={@page_title} - action={@live_action} strand={@strand} + action={@live_action} patch={~p"/admin/strands"} + notify_parent + show_actions + class="mt-6" /> diff --git a/lib/lanttern_web/live/admin/strand_live/show.html.heex b/lib/lanttern_web/live/admin/strand_live/show.html.heex index cb8ee18e..a0819892 100644 --- a/lib/lanttern_web/live/admin/strand_live/show.html.heex +++ b/lib/lanttern_web/live/admin/strand_live/show.html.heex @@ -36,12 +36,17 @@ show on_cancel={JS.patch(~p"/admin/strands/#{@strand}")} > + <.header> + <%= @page_title %> + <:subtitle>Use this form to manage strand records in your database. + <.live_component - module={LantternWeb.Admin.StrandLive.FormComponent} + module={LantternWeb.StrandLive.StrandFormComponent} id={@strand.id} - title={@page_title} - action={@live_action} strand={@strand} + action={@live_action} patch={~p"/admin/strands/#{@strand}"} + notify_parent + show_actions /> diff --git a/lib/lanttern_web/live/curriculum_live/curriculum_item_search_component.ex b/lib/lanttern_web/live/curriculum_live/curriculum_item_search_component.ex index ef561efa..8078e399 100644 --- a/lib/lanttern_web/live/curriculum_live/curriculum_item_search_component.ex +++ b/lib/lanttern_web/live/curriculum_live/curriculum_item_search_component.ex @@ -6,6 +6,9 @@ defmodule LantternWeb.CurriculumLive.CurriculumItemSearchComponent do def render(assigns) do ~H"""
+ <.label :if={@label} for={@id}> + <%= @label %> +

You can search by id adding # before the id <.inline_code> @@ -91,6 +94,7 @@ defmodule LantternWeb.CurriculumLive.CurriculumItemSearchComponent do def mount(socket) do socket = socket + |> assign(:label, nil) |> assign(:class, nil) |> assign(:refocus_on_select, "false") |> stream(:results, []) diff --git a/lib/lanttern_web/live/school_live/class_filter_form_component.ex b/lib/lanttern_web/live/school_live/class_filter_form_component.ex index 0d152a1b..e306189a 100644 --- a/lib/lanttern_web/live/school_live/class_filter_form_component.ex +++ b/lib/lanttern_web/live/school_live/class_filter_form_component.ex @@ -8,7 +8,7 @@ defmodule LantternWeb.SchoolLive.ClassFilterFormComponent do use LantternWeb, :live_component alias Lanttern.Schools - import LantternWeb.Helpers.NotifyHelpers + import LantternWeb.NotifyHelpers def render(assigns) do ~H""" diff --git a/lib/lanttern_web/live/shared_live/multi_select_component.ex b/lib/lanttern_web/live/shared_live/multi_select_component.ex new file mode 100644 index 00000000..56ab49fa --- /dev/null +++ b/lib/lanttern_web/live/shared_live/multi_select_component.ex @@ -0,0 +1,132 @@ +defmodule LantternWeb.SharedLive.MultiSelectComponent do + use LantternWeb, :live_component + + import LantternWeb.NotifyHelpers + + @impl true + def render(assigns) do + ~H""" +

+ <.input + field={@field} + type="select" + label={@label} + options={@options} + prompt={@prompt} + phx-change="selected" + phx-target={@myself} + show_optional={@show_optional} + /> +
+ <.badge :if={length(@selected_options) == 0}> + <%= @empty_message %> + + <.badge + :for={{name, id} <- @selected_options} + id={"#{name}-#{id}"} + theme="cyan" + show_remove + phx-click={JS.push("remove", value: %{id: id})} + phx-target={@myself} + > + <%= name %> + +
+
+ """ + end + + # lifecycle + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:class, nil) + |> assign(:show_optional, false) + |> assign(:selected_options, [])} + end + + @impl true + def update(%{selected_ids: selected_ids, options: options} = assigns, socket) do + selected_options = + selected_ids + |> Enum.reduce([], fn id, selected_options -> + selected = extract_from_options(options, id) + + selected_options + |> merge_with_selected(selected) + end) + + {:ok, + socket + |> assign(assigns) + |> assign(:selected_options, selected_options)} + end + + # event handlers + + @impl true + def handle_event("selected", params, socket) do + field = socket.assigns.field.field |> Atom.to_string() + + case params[socket.assigns.field.form.name][field] do + "" -> + {:noreply, socket} + + id -> + id = String.to_integer(id) + selected = extract_from_options(socket.assigns.options, id) + + selected_options = + socket.assigns.selected_options + |> merge_with_selected(selected) + + notify_component( + __MODULE__, + {:change, socket.assigns.multi_field, options_to_ids(selected_options)}, + socket.assigns + ) + + {:noreply, socket} + end + end + + def handle_event("remove", %{"id" => id}, socket) do + selected_options = + socket.assigns.selected_options + |> remove_from_selected(id) + + notify_component( + __MODULE__, + {:change, socket.assigns.multi_field, options_to_ids(selected_options)}, + socket.assigns + ) + + {:noreply, socket} + end + + # helpers + + defp extract_from_options(options, id) do + Enum.find( + options, + fn {_key, value} -> value == id end + ) + end + + defp merge_with_selected(selected, new) do + (selected ++ [new]) + |> Enum.uniq() + end + + defp remove_from_selected(selected, id) do + Enum.filter( + selected, + fn {_key, value} -> value != id end + ) + end + + defp options_to_ids(options), + do: Enum.map(options, fn {_key, value} -> value end) +end diff --git a/lib/lanttern_web/live/strand_live/list.ex b/lib/lanttern_web/live/strand_live/list.ex index 69e2c7c2..df8f7828 100644 --- a/lib/lanttern_web/live/strand_live/list.ex +++ b/lib/lanttern_web/live/strand_live/list.ex @@ -2,9 +2,14 @@ defmodule LantternWeb.StrandLive.List do use LantternWeb, :live_view alias Lanttern.LearningContext + alias Lanttern.LearningContext.Strand + + # live components + alias LantternWeb.StrandLive.StrandFormComponent # lifecycle + @impl true def mount(_params, _session, socket) do strands = LearningContext.list_strands(preloads: [:subjects, :years]) @@ -13,53 +18,17 @@ defmodule LantternWeb.StrandLive.List do |> stream(:strands, strands)} end - # def handle_params(params, _url, socket) do - # {:noreply, apply_action(socket, socket.assigns.live_action, params)} - # end - - # defp apply_action(socket, :edit, %{"id" => id}) do - # socket - # |> assign(:overlay_title, "Edit rubric") - # |> assign(:rubric, Rubrics.get_rubric!(id, preloads: :descriptors)) - # end - - # defp apply_action(socket, :new, _params) do - # socket - # |> assign(:overlay_title, "Create Rubric") - # |> assign(:rubric, %Rubric{}) - # end - - # defp apply_action(socket, :index, _params), do: socket - - # # event handlers - - # def handle_event("delete", %{"id" => id}, socket) do - # rubric = Rubrics.get_rubric!(id) - # {:ok, _} = Rubrics.delete_rubric(rubric) + # event handlers - # socket = - # socket - # |> stream_delete(:rubrics, rubric) - # |> update(:results, &(&1 - 1)) - - # {:noreply, socket} - # end - - # # info handlers - - # def handle_info({LantternWeb.RubricsLive.FormComponent, {:created, rubric}}, socket) do - # rubric = Rubrics.get_full_rubric!(rubric.id) - - # socket = - # socket - # |> stream_insert(:rubrics, rubric) - # |> update(:results, &(&1 + 1)) + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end - # {:noreply, socket} - # end + # info handlers - # def handle_info({LantternWeb.RubricsLive.FormComponent, {:updated, rubric}}, socket) do - # rubric = Rubrics.get_full_rubric!(rubric.id) - # {:noreply, stream_insert(socket, :rubrics, rubric)} - # end + @impl true + def handle_info({StrandFormComponent, {:saved, strand}}, socket) do + {:noreply, stream_insert(socket, :strands, strand)} + end end diff --git a/lib/lanttern_web/live/strand_live/list.html.heex b/lib/lanttern_web/live/strand_live/list.html.heex index df193f47..57f188cd 100644 --- a/lib/lanttern_web/live/strand_live/list.html.heex +++ b/lib/lanttern_web/live/strand_live/list.html.heex @@ -5,9 +5,12 @@ I want to explore strands in
all subjects and all years

-

+ <.link + class="shrink-0 flex items-center gap-2 font-display text-base underline hover:text-ltrn-primary" + patch={~p"/strands/new"} + > Create new strand <.icon name="hero-plus-circle" class="w-6 h-6 text-ltrn-primary" /> -

+
+ <.slide_over + :if={@live_action in [:new, :edit]} + id="strand-form-overlay" + show={true} + on_cancel={JS.patch(~p"/strands")} + > + <:title>New strand + <.live_component + module={StrandFormComponent} + id={:new} + strand={%Strand{curriculum_items: [], subjects: [], years: []}} + action={:new} + patch={~p"/strands"} + notify_parent + /> + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#strand-form-overlay")} + > + Cancel + + <.button type="submit" form="strand-form"> + Save + + +
diff --git a/lib/lanttern_web/live/admin/strand_live/form_component.ex b/lib/lanttern_web/live/strand_live/strand_form_component.ex similarity index 58% rename from lib/lanttern_web/live/admin/strand_live/form_component.ex rename to lib/lanttern_web/live/strand_live/strand_form_component.ex index 8cb9e431..266f6246 100644 --- a/lib/lanttern_web/live/admin/strand_live/form_component.ex +++ b/lib/lanttern_web/live/strand_live/strand_form_component.ex @@ -1,54 +1,62 @@ -defmodule LantternWeb.Admin.StrandLive.FormComponent do +defmodule LantternWeb.StrandLive.StrandFormComponent do use LantternWeb, :live_component alias Lanttern.LearningContext - alias LantternWeb.CurriculumLive.CurriculumItemSearchComponent import LantternWeb.TaxonomyHelpers + import LantternWeb.NotifyHelpers + + # live components + alias LantternWeb.CurriculumLive.CurriculumItemSearchComponent + alias LantternWeb.SharedLive.MultiSelectComponent @impl true def render(assigns) do ~H""" -
- <.header> - <%= @title %> - <:subtitle>Use this form to manage strand records in your database. - - - <.simple_form - for={@form} - id="strand-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input field={@form[:name]} type="text" label="Name" /> - <.input field={@form[:description]} type="textarea" label="Description" /> - <.input - field={@form[:subjects_ids]} - type="select" - label="Subjects" - prompt="Select subjects" +
+ <.form for={@form} id="strand-form" phx-target={@myself} phx-change="validate" phx-submit="save"> + <.error_block :if={@form.source.action == :insert} class="mb-6"> + Oops, something went wrong! Please check the errors below. + + <.input field={@form[:name]} type="text" label="Name" class="mb-6" /> + <.input field={@form[:description]} type="textarea" label="Description" class="mb-1" /> + <.markdown_supported class="mb-6" /> + <.live_component + module={MultiSelectComponent} + id="strand-subjects-select" + field={@form[:subject_id]} + multi_field={:subjects_ids} options={@subject_options} - multiple + selected_ids={@selected_subjects_ids} + label="Subjects" + prompt="Select subject" + empty_message="No subject selected" + class="mb-6" + notify_component={@myself} /> - <.input - field={@form[:years_ids]} - type="select" - label="Years" - prompt="Select years" + <.live_component + module={MultiSelectComponent} + id="strand-years-select" + field={@form[:year_id]} + multi_field={:years_ids} options={@year_options} - multiple + selected_ids={@selected_years_ids} + label="Years" + prompt="Select year" + empty_message="No year selected" + class="mb-6" + notify_component={@myself} /> <.live_component module={CurriculumItemSearchComponent} id="curriculum-item-search" notify_component={@myself} refocus_on_select="true" + label="Curriculum" />
@@ -65,10 +73,10 @@ defmodule LantternWeb.Admin.StrandLive.FormComponent do <.icon name="hero-x-mark" />
- <:actions> - <.button phx-disable-with="Saving...">Save Strand - - +
+ <.button type="submit" phx-disable-with="Saving...">Save Strand +
+
""" end @@ -79,41 +87,43 @@ defmodule LantternWeb.Admin.StrandLive.FormComponent do def mount(socket) do {:ok, socket + |> assign(:class, nil) |> assign(:curriculum_items, []) + |> assign(:show_actions, false) |> assign(:subject_options, generate_subject_options()) |> assign(:year_options, generate_year_options())} end @impl true def update(%{strand: strand} = assigns, socket) do - changeset = - strand - |> set_virtual_fields() - |> LearningContext.change_strand() + selected_subjects_ids = strand.subjects |> Enum.map(& &1.id) + selected_years_ids = strand.years |> Enum.map(& &1.id) + curriculum_items = strand.curriculum_items |> Enum.map(& &1.curriculum_item) + changeset = LearningContext.change_strand(strand) {:ok, socket |> assign(assigns) - |> assign( - :curriculum_items, - strand.curriculum_items - |> Enum.map(& &1.curriculum_item) - ) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:selected_years_ids, selected_years_ids) + |> assign(:curriculum_items, curriculum_items) |> assign_form(changeset)} end + def update(%{action: {MultiSelectComponent, {:change, :subjects_ids, ids}}}, socket) do + {:ok, assign(socket, :selected_subjects_ids, ids)} + end + + def update(%{action: {MultiSelectComponent, {:change, :years_ids, ids}}}, socket) do + {:ok, assign(socket, :selected_years_ids, ids)} + end + def update(%{action: {CurriculumItemSearchComponent, {:selected, curriculum_item}}}, socket) do {:ok, socket |> update(:curriculum_items, &(&1 ++ [curriculum_item]))} end - defp set_virtual_fields(strand) do - strand - |> Map.put(:subjects_ids, strand.subjects |> Enum.map(& &1.id)) - |> Map.put(:years_ids, strand.years |> Enum.map(& &1.id)) - end - # event handlers @impl true @@ -133,9 +143,11 @@ defmodule LantternWeb.Admin.StrandLive.FormComponent do end def handle_event("save", %{"strand" => strand_params}, socket) do - # add curriculum_items to params + # add curriculum_items, subjects_ids, and years_ids to params strand_params = strand_params + |> Map.put("subjects_ids", socket.assigns.selected_subjects_ids) + |> Map.put("years_ids", socket.assigns.selected_years_ids) |> Map.put( "curriculum_items", socket.assigns.curriculum_items @@ -150,7 +162,7 @@ defmodule LantternWeb.Admin.StrandLive.FormComponent do preloads: [curriculum_items: :curriculum_item] ) do {:ok, strand} -> - notify_parent({:saved, strand}) + notify_parent(__MODULE__, {:saved, strand}, socket.assigns) {:noreply, socket @@ -167,7 +179,7 @@ defmodule LantternWeb.Admin.StrandLive.FormComponent do preloads: [:subjects, :years, curriculum_items: :curriculum_item] ) do {:ok, strand} -> - notify_parent({:saved, strand}) + notify_parent(__MODULE__, {:saved, strand}, socket.assigns) {:noreply, socket @@ -179,9 +191,9 @@ defmodule LantternWeb.Admin.StrandLive.FormComponent do end end + # helpers + defp assign_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, :form, to_form(changeset)) end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) end diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 8bc35e64..76bf3fb1 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -67,6 +67,7 @@ defmodule LantternWeb.Router do :feedback live "/strands", StrandLive.List, :index + live "/strands/new", StrandLive.List, :new live "/strands/:id", StrandLive.Details, :show live "/strands/activity/:id", StrandLive.Activity, :show diff --git a/test/lanttern_web/live/admin/strand_live_test.exs b/test/lanttern_web/live/admin/strand_live_test.exs index 1d2c29b9..f369155e 100644 --- a/test/lanttern_web/live/admin/strand_live_test.exs +++ b/test/lanttern_web/live/admin/strand_live_test.exs @@ -6,23 +6,17 @@ defmodule LantternWeb.Admin.StrandLiveTest do @create_attrs %{ name: "some name", - description: "some description", - subjects_ids: [], - years_ids: [] + description: "some description" } @update_attrs %{ name: "some updated name", - description: "some updated description", - subjects_ids: [], - years_ids: [] + description: "some updated description" } @invalid_attrs %{ name: nil, - description: nil, - subjects_ids: [], - years_ids: [] + description: nil } defp create_strand(_) do From 03869140c5b0f5a319f8b46d9848a84bd878abb0 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 30 Nov 2023 14:34:39 -0300 Subject: [PATCH 2/8] chore: created `<.collection_action>` to DRY --- .../components/core_components.ex | 36 +++++++++++++++++++ .../assessment_point_live/explorer.html.heex | 9 ++--- .../live/dashboard_live/index.html.heex | 10 +++--- .../live/rubrics_live/explorer.html.heex | 9 ++--- .../activity_tabs/assessment_component.ex | 19 +++++----- .../live/strand_live/list.html.heex | 9 ++--- 6 files changed, 63 insertions(+), 29 deletions(-) diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index e1353db7..256b7c90 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -926,6 +926,42 @@ defmodule LantternWeb.CoreComponents do """ end + @doc """ + Renders a ` + """ + end + + def collection_action(%{type: "link"} = assigns) do + ~H""" + <.link patch={@patch} class={[collection_action_styles(), @class]}> + <%= render_slot(@inner_block) %> + <.icon :if={@icon_name} name={@icon_name} class="w-6 h-6 text-ltrn-primary" /> + + """ + end + + defp collection_action_styles(), + do: + "shrink-0 flex items-center gap-2 font-display text-sm text-ltrn-dark hover:text-ltrn-subtle" + @doc """ Highlights entring (mounting) elements in DOM. diff --git a/lib/lanttern_web/live/assessment_point_live/explorer.html.heex b/lib/lanttern_web/live/assessment_point_live/explorer.html.heex index a15ce88e..e384054f 100644 --- a/lib/lanttern_web/live/assessment_point_live/explorer.html.heex +++ b/lib/lanttern_web/live/assessment_point_live/explorer.html.heex @@ -15,12 +15,13 @@ Showing <%= length(@assessment_points) %> results

- <.link - class="shrink-0 flex items-center gap-2 text-sm text-ltrn-subtle hover:underline" + <.collection_action + type="link" patch={~p"/assessment_points/new"} + icon_name="hero-plus-circle" > - Create assessment point <.icon name="hero-plus-mini" class="text-ltrn-primary" /> - + Create assessment point +
filter views - <.link - type="button" - class="shrink-0 flex items-center gap-2 font-display text-base underline hover:text-ltrn-primary" + <.collection_action + type="link" patch={~p"/dashboard/filter_view/new"} + icon_name="hero-plus-circle" > - Create new view <.icon name="hero-plus-circle" class="w-6 h-6 text-ltrn-primary" /> - + Create new view +
<%= if @filter_view_count > 0 do %>
results

- <.link - class="shrink-0 flex items-center gap-2 text-sm text-ltrn-subtle hover:underline" - patch={~p"/rubrics/new"} - > - Create rubric <.icon name="hero-plus-mini" class="text-ltrn-primary" /> - + <.collection_action type="link" patch={~p"/rubrics/new"} icon_name="hero-plus-circle"> + Create rubric +
<% end %> -
- - <.link patch={~p"/strands/activity/#{@activity}/assessment_point/new"} class="flex gap-2"> Create assessment point - <.icon name="hero-plus-circle" class="w-6 h-6 text-ltrn-primary" /> - +
<%!-- if no assessment points, render empty state --%> diff --git a/lib/lanttern_web/live/strand_live/list.html.heex b/lib/lanttern_web/live/strand_live/list.html.heex index 57f188cd..b084921e 100644 --- a/lib/lanttern_web/live/strand_live/list.html.heex +++ b/lib/lanttern_web/live/strand_live/list.html.heex @@ -5,12 +5,9 @@ I want to explore strands in
all subjects and all years

- <.link - class="shrink-0 flex items-center gap-2 font-display text-base underline hover:text-ltrn-primary" - patch={~p"/strands/new"} - > - Create new strand <.icon name="hero-plus-circle" class="w-6 h-6 text-ltrn-primary" /> - + <.collection_action type="link" patch={~p"/strands/new"} icon_name="hero-plus-circle"> + Create new strand +
Date: Thu, 30 Nov 2023 14:48:43 -0300 Subject: [PATCH 3/8] test: added create strand test --- .../live/strand_live/list_test.exs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/lanttern_web/live/strand_live/list_test.exs b/test/lanttern_web/live/strand_live/list_test.exs index 4392a1b2..5abb815e 100644 --- a/test/lanttern_web/live/strand_live/list_test.exs +++ b/test/lanttern_web/live/strand_live/list_test.exs @@ -40,5 +40,43 @@ defmodule LantternWeb.StrandLive.ListTest do assert_redirect(view, "#{@live_view_path}/#{strand.id}") end + + test "create strand", %{conn: conn} do + subject = TaxonomyFixtures.subject_fixture(%{name: "subject abc"}) + year = TaxonomyFixtures.year_fixture(%{name: "year abc"}) + + {:ok, view, _html} = live(conn, @live_view_path) + + # open create strand overlay + view |> element("a", "Create new strand") |> render_click() + assert_patch(view, "/strands/new") + assert view |> has_element?("h2", "New strand") + + # add subject + view + |> element("#strand-form #strand_subject_id") + |> render_change(%{"strand" => %{"subject_id" => subject.id}}) + + # add year + view + |> element("#strand-form #strand_year_id") + |> render_change(%{"strand" => %{"year_id" => year.id}}) + + # submit form with valid field + view + |> element("#strand-form") + |> render_submit(%{ + "strand" => %{ + "name" => "strand name abc", + "description" => "description abc" + } + }) + + assert_patch(view, "/strands") + + assert view |> has_element?("a", "strand name abc") + assert view |> has_element?("span", subject.name) + assert view |> has_element?("span", year.name) + end end end From 2eacfd641af18c8b5cf14acd50b854767bb036ef Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 1 Dec 2023 09:03:04 -0300 Subject: [PATCH 4/8] feat: added support to edit and delete strands in strand details view - adjusted `activities_strand_id_fkey` constraint to prevent delete cascade activities when deleting strands --- lib/lanttern/assessments/assessment_point.ex | 2 +- lib/lanttern/curricula.ex | 7 +- lib/lanttern/learning_context.ex | 4 +- lib/lanttern/learning_context/strand.ex | 10 ++ .../components/overlay_components.ex | 1 - .../live/admin/strand_live/index.ex | 6 +- .../live/admin/strand_live/index.html.heex | 4 +- .../live/admin/strand_live/show.ex | 2 +- .../live/admin/strand_live/show.html.heex | 4 +- lib/lanttern_web/live/strand_live/details.ex | 44 ++++++-- .../live/strand_live/details.html.heex | 102 +++++++++++++----- .../details_tabs/about_component.ex | 20 +++- .../live/strand_live/list.html.heex | 2 +- .../live/strand_live/strand_form_component.ex | 9 +- lib/lanttern_web/router.ex | 1 + ...es_strand_id_fkey_on_delete_to_nothing.exs | 18 ++++ .../live/strand_live/details_test.exs | 52 ++++++++- .../live/strand_live/list_test.exs | 2 + 18 files changed, 234 insertions(+), 56 deletions(-) create mode 100644 priv/repo/migrations/20231130220104_set_activities_strand_id_fkey_on_delete_to_nothing.exs diff --git a/lib/lanttern/assessments/assessment_point.ex b/lib/lanttern/assessments/assessment_point.ex index 3e449cbe..8b069434 100644 --- a/lib/lanttern/assessments/assessment_point.ex +++ b/lib/lanttern/assessments/assessment_point.ex @@ -200,7 +200,7 @@ defmodule Lanttern.Assessments.AssessmentPoint do |> foreign_key_constraint( :id, name: :assessment_point_entries_assessment_point_id_fkey, - message: "This jfalskflsealfesal." + message: "Assessment point has linked entries." ) end end diff --git a/lib/lanttern/curricula.ex b/lib/lanttern/curricula.ex index 260620d8..c6dcdf2f 100644 --- a/lib/lanttern/curricula.ex +++ b/lib/lanttern/curricula.ex @@ -237,13 +237,17 @@ defmodule Lanttern.Curricula do @doc """ Returns the list of curriculum items linked to the given strand. + ## Options: + + - `:preloads` – preloads associated data + ## Examples iex> list_strand_curriculum_items(1) [%CurriculumItem{}, ...] """ - def list_strand_curriculum_items(strand_id) do + def list_strand_curriculum_items(strand_id, opts \\ []) do from( ci in CurriculumItem, join: sci in assoc(ci, :strand_links), @@ -251,6 +255,7 @@ defmodule Lanttern.Curricula do order_by: sci.position ) |> Repo.all() + |> maybe_preload(opts) end @doc """ diff --git a/lib/lanttern/learning_context.ex b/lib/lanttern/learning_context.ex index 4fe61401..db751ce5 100644 --- a/lib/lanttern/learning_context.ex +++ b/lib/lanttern/learning_context.ex @@ -119,7 +119,9 @@ defmodule Lanttern.LearningContext do """ def delete_strand(%Strand{} = strand) do - Repo.delete(strand) + strand + |> Strand.delete_changeset() + |> Repo.delete() end @doc """ diff --git a/lib/lanttern/learning_context/strand.ex b/lib/lanttern/learning_context/strand.ex index 418bf15e..f2af1d4c 100644 --- a/lib/lanttern/learning_context/strand.ex +++ b/lib/lanttern/learning_context/strand.ex @@ -36,4 +36,14 @@ defmodule Lanttern.LearningContext.Strand do |> put_subjects() |> put_years() end + + def delete_changeset(strand) do + strand + |> cast(%{}, []) + |> foreign_key_constraint( + :id, + name: :activities_strand_id_fkey, + message: "Strand has linked activities." + ) + end end diff --git a/lib/lanttern_web/components/overlay_components.ex b/lib/lanttern_web/components/overlay_components.ex index 6a9f1bc6..c0979585 100644 --- a/lib/lanttern_web/components/overlay_components.ex +++ b/lib/lanttern_web/components/overlay_components.ex @@ -446,7 +446,6 @@ defmodule LantternWeb.OverlayComponents do def menu_button_item(assigns) do ~H""" -
<.slide_over - :if={@live_action in [:new, :edit]} + :if={@live_action == :new} id="strand-form-overlay" show={true} on_cancel={JS.patch(~p"/strands")} diff --git a/lib/lanttern_web/live/strand_live/strand_form_component.ex b/lib/lanttern_web/live/strand_live/strand_form_component.ex index 266f6246..96bd1d74 100644 --- a/lib/lanttern_web/live/strand_live/strand_form_component.ex +++ b/lib/lanttern_web/live/strand_live/strand_form_component.ex @@ -2,6 +2,7 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do use LantternWeb, :live_component alias Lanttern.LearningContext + alias Lanttern.Curricula import LantternWeb.TaxonomyHelpers import LantternWeb.NotifyHelpers @@ -98,7 +99,13 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do def update(%{strand: strand} = assigns, socket) do selected_subjects_ids = strand.subjects |> Enum.map(& &1.id) selected_years_ids = strand.years |> Enum.map(& &1.id) - curriculum_items = strand.curriculum_items |> Enum.map(& &1.curriculum_item) + + curriculum_items = + case strand.id do + nil -> [] + id -> Curricula.list_strand_curriculum_items(id, preloads: :curriculum_component) + end + changeset = LearningContext.change_strand(strand) {:ok, diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 76bf3fb1..12982703 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -69,6 +69,7 @@ defmodule LantternWeb.Router do live "/strands", StrandLive.List, :index live "/strands/new", StrandLive.List, :new live "/strands/:id", StrandLive.Details, :show + live "/strands/:id/edit", StrandLive.Details, :edit live "/strands/activity/:id", StrandLive.Activity, :show live "/strands/activity/:id/assessment_point/new", diff --git a/priv/repo/migrations/20231130220104_set_activities_strand_id_fkey_on_delete_to_nothing.exs b/priv/repo/migrations/20231130220104_set_activities_strand_id_fkey_on_delete_to_nothing.exs new file mode 100644 index 00000000..dd7a4967 --- /dev/null +++ b/priv/repo/migrations/20231130220104_set_activities_strand_id_fkey_on_delete_to_nothing.exs @@ -0,0 +1,18 @@ +defmodule Lanttern.Repo.Migrations.SetActivitiesStrandIdFkeyOnDeleteToNothing do + use Ecto.Migration + + def change do + execute """ + ALTER TABLE activities + DROP CONSTRAINT activities_strand_id_fkey, + ADD CONSTRAINT activities_strand_id_fkey FOREIGN KEY (strand_id) + REFERENCES strands (id); + """, + """ + ALTER TABLE activities + DROP CONSTRAINT activities_strand_id_fkey, + ADD CONSTRAINT activities_strand_id_fkey FOREIGN KEY (strand_id) + REFERENCES strands (id) ON DELETE CASCADE; + """ + end +end diff --git a/test/lanttern_web/live/strand_live/details_test.exs b/test/lanttern_web/live/strand_live/details_test.exs index cce3f458..e90dc415 100644 --- a/test/lanttern_web/live/strand_live/details_test.exs +++ b/test/lanttern_web/live/strand_live/details_test.exs @@ -9,7 +9,7 @@ defmodule LantternWeb.StrandLive.DetailsTest do setup [:register_and_log_in_user] - describe "Strands live view basic navigation" do + describe "Strand details live view basic navigation" do test "disconnected and connected mount", %{conn: conn} do strand = LearningContextFixtures.strand_fixture(%{name: "strand abc"}) conn = get(conn, "#{@live_view_base_path}/#{strand.id}") @@ -91,4 +91,54 @@ defmodule LantternWeb.StrandLive.DetailsTest do assert view |> has_element?("p", "strand description abc") end end + + describe "Strand management" do + test "edit strand", %{conn: conn} do + strand = LearningContextFixtures.strand_fixture(%{name: "strand abc"}) + subject = TaxonomyFixtures.subject_fixture(%{name: "subject abc"}) + year = TaxonomyFixtures.year_fixture(%{name: "year abc"}) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{strand.id}/edit") + + assert view + |> has_element?("h2", "Edit strand") + + # add subject + view + |> element("#strand-form #strand_subject_id") + |> render_change(%{"strand" => %{"subject_id" => subject.id}}) + + # add year + view + |> element("#strand-form #strand_year_id") + |> render_change(%{"strand" => %{"year_id" => year.id}}) + + # submit form with valid field + view + |> element("#strand-form") + |> render_submit(%{ + "strand" => %{ + "name" => "strand name xyz" + } + }) + + assert_patch(view, "#{@live_view_base_path}/#{strand.id}") + + assert view |> has_element?("h1", "strand name xyz") + assert view |> has_element?("span", subject.name) + assert view |> has_element?("span", year.name) + end + + test "delete strand", %{conn: conn} do + strand = LearningContextFixtures.strand_fixture() + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{strand.id}") + + view + |> element("button#remove-strand-#{strand.id}") + |> render_click() + + assert_redirect(view, "/strands") + end + end end diff --git a/test/lanttern_web/live/strand_live/list_test.exs b/test/lanttern_web/live/strand_live/list_test.exs index 5abb815e..fd5f4c81 100644 --- a/test/lanttern_web/live/strand_live/list_test.exs +++ b/test/lanttern_web/live/strand_live/list_test.exs @@ -40,7 +40,9 @@ defmodule LantternWeb.StrandLive.ListTest do assert_redirect(view, "#{@live_view_path}/#{strand.id}") end + end + describe "Strand management" do test "create strand", %{conn: conn} do subject = TaxonomyFixtures.subject_fixture(%{name: "subject abc"}) year = TaxonomyFixtures.year_fixture(%{name: "year abc"}) From ae6e3476c6e4468faddb694646410708718ce571 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 1 Dec 2023 10:01:09 -0300 Subject: [PATCH 5/8] feat: added support to sort curriculum items order in strand form component - added `:size` and `:rounded` attrs to `<.button>` --- .../components/core_components.ex | 18 ++- .../activity_tabs/assessment_component.ex | 10 +- .../live/strand_live/strand_form_component.ex | 104 ++++++++++++++---- 3 files changed, 105 insertions(+), 27 deletions(-) diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index 256b7c90..5ebc064f 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -156,6 +156,8 @@ defmodule LantternWeb.CoreComponents do attr :type, :string, default: nil attr :class, :any, default: nil attr :theme, :string, default: "default", doc: "default | ghost" + attr :size, :string, default: "normal", doc: "sm | normal" + attr :rounded, :boolean, default: false attr :icon_name, :string, default: nil attr :rest, :global, include: ~w(disabled form name value) @@ -166,7 +168,7 @@ defmodule LantternWeb.CoreComponents do """ @@ -216,9 +222,11 @@ defmodule LantternWeb.CoreComponents do <.link patch={~p"/somepath"} class={[get_button_styles()]}>Link """ - def get_button_styles(theme \\ "default") do + def get_button_styles(theme \\ "default", size \\ "normal", rounded \\ false) do [ - "inline-flex items-center gap-1 rounded-sm py-2 px-2 font-display text-sm font-bold", + "inline-flex items-center gap-1 font-display text-sm font-bold", + if(size == "sm", do: "p-1", else: "p-2"), + if(rounded, do: "rounded-full", else: "rounded-sm"), "phx-submit-loading:opacity-50 phx-click-loading:opacity-50 phx-click-loading:pointer-events-none", button_theme(theme) ] diff --git a/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex b/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex index 7fba5241..e11ff8b5 100644 --- a/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex +++ b/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex @@ -219,16 +219,22 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do
<.icon_button type="button" - name="hero-arrow-down-mini" + sr_text="Move assessment point down" + name="hero-chevron-down-mini" theme="ghost" + rounded + size="sm" disabled={i + 1 == @assessment_points_count} phx-click={JS.push("assessment_point_position_inc", value: %{index: i})} phx-target={@myself} /> <.icon_button type="button" - name="hero-arrow-up-mini" + sr_text="Move assessment point up" + name="hero-chevron-up-mini" theme="ghost" + rounded + size="sm" disabled={i == 0} phx-click={JS.push("assessment_point_position_dec", value: %{index: i})} phx-target={@myself} diff --git a/lib/lanttern_web/live/strand_live/strand_form_component.ex b/lib/lanttern_web/live/strand_live/strand_form_component.ex index 96bd1d74..032f5e05 100644 --- a/lib/lanttern_web/live/strand_live/strand_form_component.ex +++ b/lib/lanttern_web/live/strand_live/strand_form_component.ex @@ -55,24 +55,52 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do label="Curriculum" />
-
- - <%= "##{curriculum_item.id} #{curriculum_item.curriculum_component.name}" %> - - <%= curriculum_item.name %> +
+
+ + <%= "##{curriculum_item.id} #{curriculum_item.curriculum_component.name}" %> + + <%= curriculum_item.name %> +
+ <.icon_button + type="button" + sr_text="Remove curriculum item" + name="hero-x-mark-mini" + theme="ghost" + rounded + size="sm" + phx-click={JS.push("remove_curriculum_item", value: %{id: curriculum_item.id})} + phx-target={@myself} + /> +
+
+ <.icon_button + type="button" + sr_text="Move curriculum item up" + name="hero-chevron-up-mini" + theme="ghost" + rounded + size="sm" + disabled={i == 0} + phx-click={JS.push("curriculum_item_position", value: %{from: i, to: i - 1})} + phx-target={@myself} + /> + <.icon_button + type="button" + sr_text="Move curriculum item down" + name="hero-chevron-down-mini" + theme="ghost" + rounded + size="sm" + disabled={i + 1 == length(@curriculum_items)} + phx-click={JS.push("curriculum_item_position", value: %{from: i, to: i + 1})} + phx-target={@myself} + />
-
<.button type="submit" phx-disable-with="Saving...">Save Strand @@ -102,8 +130,12 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do curriculum_items = case strand.id do - nil -> [] - id -> Curricula.list_strand_curriculum_items(id, preloads: :curriculum_component) + nil -> + [] + + id -> + Curricula.list_strand_curriculum_items(id, preloads: :curriculum_component) + |> Enum.with_index() end changeset = LearningContext.change_strand(strand) @@ -126,9 +158,21 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do end def update(%{action: {CurriculumItemSearchComponent, {:selected, curriculum_item}}}, socket) do + curriculum_items = + socket.assigns.curriculum_items + |> Enum.find(fn {ci, _i} -> ci.id == curriculum_item.id end) + |> case do + nil -> + socket.assigns.curriculum_items ++ + [{curriculum_item, length(socket.assigns.curriculum_items)}] + + _ -> + socket.assigns.curriculum_items + end + {:ok, socket - |> update(:curriculum_items, &(&1 ++ [curriculum_item]))} + |> assign(:curriculum_items, curriculum_items)} end # event handlers @@ -137,7 +181,17 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do def handle_event("remove_curriculum_item", %{"id" => id}, socket) do {:noreply, socket - |> update(:curriculum_items, &Enum.filter(&1, fn ci -> ci.id != id end))} + |> update(:curriculum_items, &Enum.filter(&1, fn {ci, _i} -> ci.id != id end))} + end + + def handle_event("curriculum_item_position", %{"from" => i, "to" => j}, socket) do + curriculum_items = + socket.assigns.curriculum_items + |> Enum.map(fn {ap, _i} -> ap end) + |> swap(i, j) + |> Enum.with_index() + + {:noreply, assign(socket, :curriculum_items, curriculum_items)} end def handle_event("validate", %{"strand" => strand_params}, socket) do @@ -158,7 +212,7 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do |> Map.put( "curriculum_items", socket.assigns.curriculum_items - |> Enum.map(&%{curriculum_item_id: &1.id}) + |> Enum.map(fn {ci, _i} -> %{curriculum_item_id: ci.id} end) ) save_strand(socket, socket.assigns.action, strand_params) @@ -203,4 +257,14 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do defp assign_form(socket, %Ecto.Changeset{} = changeset) do assign(socket, :form, to_form(changeset)) end + + # https://elixirforum.com/t/swap-elements-in-a-list/34471/4 + defp swap(a, i1, i2) do + e1 = Enum.at(a, i1) + e2 = Enum.at(a, i2) + + a + |> List.replace_at(i1, e2) + |> List.replace_at(i2, e1) + end end From 14eb7e0201dc8d886109b8c041c89bd81edfb22b Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 1 Dec 2023 13:03:00 -0300 Subject: [PATCH 6/8] feat: create activity from strand details view - created `LantternWeb.LiveComponentHelpers` module with 3 initial utilities: `notify_parent/3`, `notify_component/3`, and `handle_navigation/2` - redesigned `LantternWeb.Admin.ActivityLive.FormComponent` into `LantternWeb.StrandLive.ActivityFormComponent` - removed `LantternWeb.NotifyHelpers` imports (notify helpers are loaded in all live components through `LiveComponentHelpers`) --- lib/lanttern/learning_context.ex | 26 +++ lib/lanttern/taxonomy.ex | 21 +++ lib/lanttern_web.ex | 2 + .../components/form_components.ex | 2 +- .../helpers/live_component_helpers.ex | 43 +++++ lib/lanttern_web/helpers/notify_helpers.ex | 11 -- .../admin/activity_live/form_component.ex | 131 ------------- .../live/admin/activity_live/index.ex | 2 +- .../live/admin/activity_live/index.html.heex | 11 +- .../live/admin/activity_live/show.html.heex | 9 +- .../class_filter_form_component.ex | 1 - .../shared_live/multi_select_component.ex | 2 - .../strand_live/activity_form_component.ex | 172 ++++++++++++++++++ lib/lanttern_web/live/strand_live/details.ex | 11 +- .../live/strand_live/details.html.heex | 1 + .../details_tabs/activities_component.ex | 99 ++++++++-- .../live/strand_live/strand_form_component.ex | 1 - lib/lanttern_web/router.ex | 1 + test/lanttern/taxonomy_test.exs | 17 +- .../live/strand_live/details_test.exs | 46 +++++ 20 files changed, 438 insertions(+), 171 deletions(-) create mode 100644 lib/lanttern_web/helpers/live_component_helpers.ex delete mode 100644 lib/lanttern_web/helpers/notify_helpers.ex delete mode 100644 lib/lanttern_web/live/admin/activity_live/form_component.ex create mode 100644 lib/lanttern_web/live/strand_live/activity_form_component.ex diff --git a/lib/lanttern/learning_context.ex b/lib/lanttern/learning_context.ex index db751ce5..cd33adc3 100644 --- a/lib/lanttern/learning_context.ex +++ b/lib/lanttern/learning_context.ex @@ -214,12 +214,38 @@ defmodule Lanttern.LearningContext do """ def create_activity(attrs \\ %{}, opts \\ []) do + attrs = set_activity_position_attr(attrs) + %Activity{} |> Activity.changeset(attrs) |> Repo.insert() |> maybe_preload(opts) end + defp set_activity_position_attr(%{"position" => _} = attrs), do: attrs + + defp set_activity_position_attr(%{position: _} = attrs), do: attrs + + defp set_activity_position_attr(attrs) do + strand_id = attrs[:strand_id] || attrs["strand_id"] + + position = + from( + a in Activity, + where: a.strand_id == ^strand_id, + select: count() + ) + |> Repo.one() + + cond do + not is_nil(attrs[:strand_id]) -> + Map.put(attrs, :position, position) + + not is_nil(attrs["strand_id"]) -> + Map.put(attrs, "position", position) + end + end + @doc """ Updates a activity. diff --git a/lib/lanttern/taxonomy.ex b/lib/lanttern/taxonomy.ex index fbcf1918..8b7ce88c 100644 --- a/lib/lanttern/taxonomy.ex +++ b/lib/lanttern/taxonomy.ex @@ -9,6 +9,8 @@ defmodule Lanttern.Taxonomy do alias Lanttern.Taxonomy.Subject alias Lanttern.Taxonomy.Year + alias Lanttern.LearningContext.Strand + @years [ {"k0", "Kindergarten 0"}, {"k1", "Kindergarten 1"}, @@ -147,6 +149,25 @@ defmodule Lanttern.Taxonomy do } end + @doc """ + Returns the list of strand subjects ordered alphabetically. + + ## Examples + + iex> list_strand_subjects(1) + {[%Subject{}, ...], [%Subject{}, ...]} + + """ + def list_strand_subjects(strand_id) do + from(st in Strand, + join: sub in assoc(st, :subjects), + where: st.id == ^strand_id, + order_by: sub.name, + select: sub + ) + |> Repo.all() + end + @doc """ Gets a single subject. diff --git a/lib/lanttern_web.ex b/lib/lanttern_web.ex index 08fe716c..83cbf009 100644 --- a/lib/lanttern_web.ex +++ b/lib/lanttern_web.ex @@ -64,6 +64,8 @@ defmodule LantternWeb do quote do use Phoenix.LiveComponent + import LantternWeb.LiveComponentHelpers + unquote(html_helpers()) end end diff --git a/lib/lanttern_web/components/form_components.ex b/lib/lanttern_web/components/form_components.ex index cc3b2c95..7406fa66 100644 --- a/lib/lanttern_web/components/form_components.ex +++ b/lib/lanttern_web/components/form_components.ex @@ -318,7 +318,7 @@ defmodule LantternWeb.FormComponents do id={@id} name={@name} class={[ - "block w-full min-h-[6rem] rounded-sm border-0 shadow-sm ring-1 sm:text-sm sm:leading-6", + "block w-full min-h-[10rem] rounded-sm border-0 shadow-sm ring-1 sm:text-sm sm:leading-6", "focus:ring-2 focus:ring-inset", "phx-no-feedback:ring-ltrn-lighter phx-no-feedback:focus:ring-ltrn-primary", @errors == [] && "ring-ltrn-lighter focus:ring-ltrn-primary", diff --git a/lib/lanttern_web/helpers/live_component_helpers.ex b/lib/lanttern_web/helpers/live_component_helpers.ex new file mode 100644 index 00000000..08c0e1d5 --- /dev/null +++ b/lib/lanttern_web/helpers/live_component_helpers.ex @@ -0,0 +1,43 @@ +defmodule LantternWeb.LiveComponentHelpers do + @moduledoc """ + Set of reusable helpers for live components + """ + + @doc """ + Send notification to parent based on `:notify_parent` assign. + """ + def notify_parent(module, msg, %{notify_parent: true}), + do: send(self(), {module, msg}) + + def notify_parent(_module, _msg, _assigns), do: nil + + @doc """ + Send update to component based on `:notify_component` assign. + """ + def notify_component(module, msg, %{notify_component: %Phoenix.LiveComponent.CID{} = cid}), + do: Phoenix.LiveView.send_update(cid, action: {module, msg}) + + def notify_component(_module, _msg, _assigns), do: nil + + @doc """ + Handles navigation based on socket assigns. + + Usually used to navigate after successful actions. + """ + def handle_navigation(socket, arg \\ %{}) + + def handle_navigation(%{assigns: %{patch: patch}} = socket, arg) when is_function(patch), + do: Phoenix.LiveView.push_patch(socket, to: patch.(arg)) + + def handle_navigation(%{assigns: %{patch: patch}} = socket, _arg), + do: Phoenix.LiveView.push_patch(socket, to: patch) + + def handle_navigation(%{assigns: %{navigate: navigate}} = socket, arg) + when is_function(navigate), + do: Phoenix.LiveView.push_navigate(socket, to: navigate.(arg)) + + def handle_navigation(%{assigns: %{navigate: navigate}} = socket, _arg), + do: Phoenix.LiveView.push_navigate(socket, to: navigate) + + def handle_navigation(socket, _arg), do: socket +end diff --git a/lib/lanttern_web/helpers/notify_helpers.ex b/lib/lanttern_web/helpers/notify_helpers.ex deleted file mode 100644 index 159258f8..00000000 --- a/lib/lanttern_web/helpers/notify_helpers.ex +++ /dev/null @@ -1,11 +0,0 @@ -defmodule LantternWeb.NotifyHelpers do - def notify_parent(module, msg, %{notify_parent: true}), - do: send(self(), {module, msg}) - - def notify_parent(_module, _msg, _assigns), do: nil - - def notify_component(module, msg, %{notify_component: %Phoenix.LiveComponent.CID{} = cid}), - do: Phoenix.LiveView.send_update(cid, action: {module, msg}) - - def notify_component(_module, _msg, _assigns), do: nil -end diff --git a/lib/lanttern_web/live/admin/activity_live/form_component.ex b/lib/lanttern_web/live/admin/activity_live/form_component.ex deleted file mode 100644 index 2d4490f4..00000000 --- a/lib/lanttern_web/live/admin/activity_live/form_component.ex +++ /dev/null @@ -1,131 +0,0 @@ -defmodule LantternWeb.Admin.ActivityLive.FormComponent do - use LantternWeb, :live_component - - alias Lanttern.LearningContext - import LantternWeb.LearningContextHelpers - import LantternWeb.TaxonomyHelpers - - @impl true - def render(assigns) do - ~H""" -
- <.header> - <%= @title %> - <:subtitle>Use this form to manage activity records in your database. - - - <.simple_form - for={@form} - id="activity-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > - <.input - field={@form[:strand_id]} - type="select" - label="Strand" - prompt="Select strand" - options={@strand_options} - /> - <.input - field={@form[:subjects_ids]} - type="select" - label="Subjects" - prompt="Select subjects" - options={@subject_options} - multiple - /> - <.input field={@form[:name]} type="text" label="Name" /> - <.input field={@form[:description]} type="text" label="Description" /> - <.input field={@form[:position]} type="number" label="Position" /> - <:actions> - <.button phx-disable-with="Saving...">Save Activity - - -
- """ - end - - @impl true - def mount(socket) do - {:ok, - socket - |> assign(:strand_options, generate_strand_options()) - |> assign(:subject_options, generate_subject_options())} - end - - @impl true - def update(%{activity: activity} = assigns, socket) do - changeset = - activity - |> set_virtual_fields() - |> LearningContext.change_activity() - - {:ok, - socket - |> assign(assigns) - |> assign_form(changeset)} - end - - defp set_virtual_fields(activity) do - activity - |> Map.put(:subjects_ids, activity.subjects |> Enum.map(& &1.id)) - end - - # event handlers - - @impl true - def handle_event("validate", %{"activity" => activity_params}, socket) do - changeset = - socket.assigns.activity - |> LearningContext.change_activity(activity_params) - |> Map.put(:action, :validate) - - {:noreply, assign_form(socket, changeset)} - end - - def handle_event("save", %{"activity" => activity_params}, socket) do - save_activity(socket, socket.assigns.action, activity_params) - end - - defp save_activity(socket, :edit, activity_params) do - case LearningContext.update_activity(socket.assigns.activity, activity_params, - preloads: [:strand, :curriculum_items] - ) do - {:ok, activity} -> - notify_parent({:saved, activity}) - - {:noreply, - socket - |> put_flash(:info, "Activity updated successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - defp save_activity(socket, :new, activity_params) do - case LearningContext.create_activity(activity_params, - preloads: [:strand, :subjects, :curriculum_items] - ) do - {:ok, activity} -> - notify_parent({:saved, activity}) - - {:noreply, - socket - |> put_flash(:info, "Activity created successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign_form(socket, changeset)} - end - end - - defp assign_form(socket, %Ecto.Changeset{} = changeset) do - assign(socket, :form, to_form(changeset)) - end - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) -end diff --git a/lib/lanttern_web/live/admin/activity_live/index.ex b/lib/lanttern_web/live/admin/activity_live/index.ex index 7b6f38ae..99bfa2a7 100644 --- a/lib/lanttern_web/live/admin/activity_live/index.ex +++ b/lib/lanttern_web/live/admin/activity_live/index.ex @@ -49,7 +49,7 @@ defmodule LantternWeb.Admin.ActivityLive.Index do end @impl true - def handle_info({LantternWeb.Admin.ActivityLive.FormComponent, {:saved, activity}}, socket) do + def handle_info({LantternWeb.StrandLive.ActivityFormComponent, {:saved, activity}}, socket) do {:noreply, stream_insert(socket, :activities, activity)} end diff --git a/lib/lanttern_web/live/admin/activity_live/index.html.heex b/lib/lanttern_web/live/admin/activity_live/index.html.heex index dcddb8a8..a6ae954d 100644 --- a/lib/lanttern_web/live/admin/activity_live/index.html.heex +++ b/lib/lanttern_web/live/admin/activity_live/index.html.heex @@ -48,12 +48,19 @@ show on_cancel={JS.patch(~p"/admin/activities")} > + <.header> + <%= @page_title %> + <:subtitle>Use this form to manage activity records in your database. + <.live_component - module={LantternWeb.Admin.ActivityLive.FormComponent} + module={LantternWeb.StrandLive.ActivityFormComponent} id={@activity.id || :new} - title={@page_title} action={@live_action} activity={@activity} patch={~p"/admin/activities"} + class="mt-6" + is_admin + notify_parent + save_preloads={[:strand, :curriculum_items]} /> diff --git a/lib/lanttern_web/live/admin/activity_live/show.html.heex b/lib/lanttern_web/live/admin/activity_live/show.html.heex index e788f833..9eb67525 100644 --- a/lib/lanttern_web/live/admin/activity_live/show.html.heex +++ b/lib/lanttern_web/live/admin/activity_live/show.html.heex @@ -33,12 +33,17 @@ show on_cancel={JS.patch(~p"/admin/activities/#{@activity}")} > + <.header> + <%= @page_title %> + <:subtitle>Use this form to manage activity records in your database. + <.live_component - module={LantternWeb.Admin.ActivityLive.FormComponent} + module={LantternWeb.StrandLive.ActivityFormComponent} id={@activity.id} - title={@page_title} action={@live_action} activity={@activity} patch={~p"/admin/activities/#{@activity}"} + class="mt-6" + is_admin /> diff --git a/lib/lanttern_web/live/school_live/class_filter_form_component.ex b/lib/lanttern_web/live/school_live/class_filter_form_component.ex index e306189a..242668b1 100644 --- a/lib/lanttern_web/live/school_live/class_filter_form_component.ex +++ b/lib/lanttern_web/live/school_live/class_filter_form_component.ex @@ -8,7 +8,6 @@ defmodule LantternWeb.SchoolLive.ClassFilterFormComponent do use LantternWeb, :live_component alias Lanttern.Schools - import LantternWeb.NotifyHelpers def render(assigns) do ~H""" diff --git a/lib/lanttern_web/live/shared_live/multi_select_component.ex b/lib/lanttern_web/live/shared_live/multi_select_component.ex index 56ab49fa..937c3688 100644 --- a/lib/lanttern_web/live/shared_live/multi_select_component.ex +++ b/lib/lanttern_web/live/shared_live/multi_select_component.ex @@ -1,8 +1,6 @@ defmodule LantternWeb.SharedLive.MultiSelectComponent do use LantternWeb, :live_component - import LantternWeb.NotifyHelpers - @impl true def render(assigns) do ~H""" diff --git a/lib/lanttern_web/live/strand_live/activity_form_component.ex b/lib/lanttern_web/live/strand_live/activity_form_component.ex new file mode 100644 index 00000000..6d2026b8 --- /dev/null +++ b/lib/lanttern_web/live/strand_live/activity_form_component.ex @@ -0,0 +1,172 @@ +defmodule LantternWeb.StrandLive.ActivityFormComponent do + use LantternWeb, :live_component + + alias Lanttern.LearningContext + import LantternWeb.LearningContextHelpers + alias Lanttern.Taxonomy + import LantternWeb.TaxonomyHelpers + + # live components + alias LantternWeb.SharedLive.MultiSelectComponent + + @impl true + def render(assigns) do + ~H""" +
+ <.form + for={@form} + id="activity-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <%= if @is_admin do %> + <.input + field={@form[:strand_id]} + type="select" + label="Strand" + prompt="Select strand" + options={@strand_options} + class="mb-6" + /> + <% else %> + <.input field={@form[:strand_id]} type="hidden" /> + <% end %> + <.input field={@form[:name]} type="text" label="Name" class="mb-6" phx-debounce="1500" /> + <.input + field={@form[:description]} + type="textarea" + label="Description" + class="mb-1" + phx-debounce="1500" + /> + <.markdown_supported class="mb-6" /> + <.live_component + module={MultiSelectComponent} + id="activity-subjects-select" + field={@form[:subject_id]} + multi_field={:subjects_ids} + options={@subject_options} + selected_ids={@selected_subjects_ids} + label="Subjects" + prompt="Select subject" + empty_message="No subject selected" + notify_component={@myself} + /> +
+ <.input field={@form[:position]} type="number" label="Position" class="mb-6" /> +
+ <.button type="submit" phx-disable-with="Saving...">Save activity +
+
+ +
+ """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:class, nil) + |> assign(:save_preloads, []) + |> assign(:is_admin, false)} + end + + @impl true + def update(%{activity: activity, is_admin: true} = assigns, socket) do + selected_subjects_ids = activity.subjects |> Enum.map(& &1.id) + + changeset = LearningContext.change_activity(activity) + + {:ok, + socket + |> assign(assigns) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:strand_options, generate_strand_options()) + |> assign(:subject_options, generate_subject_options()) + |> assign_form(changeset)} + end + + def update(%{activity: activity} = assigns, socket) do + selected_subjects_ids = activity.subjects |> Enum.map(& &1.id) + + changeset = LearningContext.change_activity(activity) + + subject_options = + Taxonomy.list_strand_subjects(activity.strand_id) + |> Enum.map(&{&1.name, &1.id}) + + {:ok, + socket + |> assign(assigns) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:subject_options, subject_options) + |> assign_form(changeset)} + end + + def update(%{action: {MultiSelectComponent, {:change, :subjects_ids, ids}}}, socket) do + {:ok, assign(socket, :selected_subjects_ids, ids)} + end + + # event handlers + + @impl true + def handle_event("validate", %{"activity" => activity_params}, socket) do + changeset = + socket.assigns.activity + |> LearningContext.change_activity(activity_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"activity" => activity_params}, socket) do + # add subjects_ids to params + activity_params = + activity_params + |> Map.put("subjects_ids", socket.assigns.selected_subjects_ids) + + save_activity(socket, socket.assigns.action, activity_params) + end + + defp save_activity(socket, :edit, activity_params) do + case LearningContext.update_activity(socket.assigns.activity, activity_params, + preloads: socket.assigns.save_preloads + ) do + {:ok, activity} -> + notify_parent(__MODULE__, {:saved, activity}, socket.assigns) + + {:noreply, + socket + |> put_flash(:info, "Activity updated successfully") + |> handle_navigation(activity)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_activity(socket, :new, activity_params) do + case LearningContext.create_activity(activity_params, + preloads: socket.assigns.save_preloads + ) do + {:ok, activity} -> + notify_parent(__MODULE__, {:saved, activity}, socket.assigns) + + {:noreply, + socket + |> put_flash(:info, "Activity created successfully") + |> handle_navigation(activity)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + # helpers + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end +end diff --git a/lib/lanttern_web/live/strand_live/details.ex b/lib/lanttern_web/live/strand_live/details.ex index cc53897b..2f73ebe3 100644 --- a/lib/lanttern_web/live/strand_live/details.ex +++ b/lib/lanttern_web/live/strand_live/details.ex @@ -25,18 +25,21 @@ defmodule LantternWeb.StrandLive.Details do def handle_params(params, _url, socket) do {:noreply, socket - |> set_current_tab(params) + |> set_current_tab(params, socket.assigns.live_action) |> apply_action(socket.assigns.live_action, params)} end - defp set_current_tab(socket, %{"tab" => tab}), + defp set_current_tab(socket, _params, :new_activity), + do: assign(socket, :current_tab, @tabs["activities"]) + + defp set_current_tab(socket, %{"tab" => tab}, _live_action), do: assign(socket, :current_tab, Map.get(@tabs, tab, :about)) - defp set_current_tab(socket, _params), + defp set_current_tab(socket, _params, _live_action), do: assign(socket, :current_tab, :about) defp apply_action(%{assigns: %{strand: nil}} = socket, live_action, %{"id" => id}) - when live_action in [:show, :edit] do + when live_action in [:show, :edit, :new_activity] do # pattern match assigned strand to prevent unnecessary get_strand calls # (during handle_params triggered by tab change for example) diff --git a/lib/lanttern_web/live/strand_live/details.html.heex b/lib/lanttern_web/live/strand_live/details.html.heex index 753fb511..fb432136 100644 --- a/lib/lanttern_web/live/strand_live/details.html.heex +++ b/lib/lanttern_web/live/strand_live/details.html.heex @@ -75,6 +75,7 @@ module={DetailsTabs.ActivitiesComponent} id="strand-details-activities" strand={@strand} + live_action={@live_action} /> <.live_component :if={@current_tab == :assessment} diff --git a/lib/lanttern_web/live/strand_live/details_tabs/activities_component.ex b/lib/lanttern_web/live/strand_live/details_tabs/activities_component.ex index 8decf766..ec9d439a 100644 --- a/lib/lanttern_web/live/strand_live/details_tabs/activities_component.ex +++ b/lib/lanttern_web/live/strand_live/details_tabs/activities_component.ex @@ -2,35 +2,106 @@ defmodule LantternWeb.StrandLive.DetailsTabs.ActivitiesComponent do use LantternWeb, :live_component alias Lanttern.LearningContext + alias Lanttern.LearningContext.Activity + + # live components + alias LantternWeb.StrandLive.ActivityFormComponent @impl true def render(assigns) do ~H"""
-
- <.link navigate={~p"/strands/activity/#{activity.id}"} class="font-display font-black text-xl"> - <%= "#{activity.position}." %> - <%= activity.name %> - -
- <.markdown text={activity.description} class="prose-sm" /> -
+
+

+ Strand activities +

+ <.collection_action + type="link" + patch={~p"/strands/#{@strand}/new_activity"} + icon_name="hero-plus-circle" + > + Create new activity +
+ <%= if @activities_count == 0 do %> +
+ <.empty_state>No activities for this strand yet +
+ <% else %> +
+
+ <.link + navigate={~p"/strands/activity/#{activity.id}"} + class="font-display font-black text-xl" + > + <%= "#{i + 1}." %> + <%= activity.name %> + +
+ <.markdown text={activity.description} class="prose-sm" /> +
+
+
+ <% end %> + <.slide_over + :if={@live_action == :new_activity} + id="activity-form-overlay" + show={true} + on_cancel={JS.patch(~p"/strands/#{@strand}?tab=activities")} + > + <:title>New activity + <.live_component + module={ActivityFormComponent} + id={:new} + activity={%Activity{strand_id: @strand.id, subjects: []}} + strand_id={@strand.id} + action={:new} + navigate={fn activity -> ~p"/strands/activity/#{activity}" end} + notify_parent + /> + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#activity-form-overlay")} + > + Cancel + + <.button type="submit" form="activity-form"> + Save + + +
""" end + # lifecycle + + @impl true + def mount(socket) do + {:ok, + socket + |> stream_configure( + :activities, + dom_id: fn {activity, _i} -> "activity-#{activity.id}" end + )} + end + @impl true def update(assigns, socket) do - list_activities_opts = [strands_ids: [assigns.strand.id]] + activities = + LearningContext.list_activities(strands_ids: [assigns.strand.id]) + |> Enum.with_index() {:ok, socket |> assign(assigns) - |> stream(:activities, LearningContext.list_activities(list_activities_opts))} + |> assign(:activities_count, length(activities)) + |> stream(:activities, activities)} end end diff --git a/lib/lanttern_web/live/strand_live/strand_form_component.ex b/lib/lanttern_web/live/strand_live/strand_form_component.ex index 032f5e05..b98fc5c6 100644 --- a/lib/lanttern_web/live/strand_live/strand_form_component.ex +++ b/lib/lanttern_web/live/strand_live/strand_form_component.ex @@ -4,7 +4,6 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do alias Lanttern.LearningContext alias Lanttern.Curricula import LantternWeb.TaxonomyHelpers - import LantternWeb.NotifyHelpers # live components alias LantternWeb.CurriculumLive.CurriculumItemSearchComponent diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 12982703..7c69b138 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -70,6 +70,7 @@ defmodule LantternWeb.Router do live "/strands/new", StrandLive.List, :new live "/strands/:id", StrandLive.Details, :show live "/strands/:id/edit", StrandLive.Details, :edit + live "/strands/:id/new_activity", StrandLive.Details, :new_activity live "/strands/activity/:id", StrandLive.Activity, :show live "/strands/activity/:id/assessment_point/new", diff --git a/test/lanttern/taxonomy_test.exs b/test/lanttern/taxonomy_test.exs index 5806aeaf..e18a9352 100644 --- a/test/lanttern/taxonomy_test.exs +++ b/test/lanttern/taxonomy_test.exs @@ -5,9 +5,10 @@ defmodule Lanttern.TaxonomyTest do describe "subjects" do alias Lanttern.Taxonomy.Subject - import Lanttern.TaxonomyFixtures + alias Lanttern.LearningContextFixtures + @invalid_attrs %{name: nil} test "list_subjects/0 returns all subjects" do @@ -47,6 +48,20 @@ defmodule Lanttern.TaxonomyTest do Taxonomy.list_assessment_points_subjects() end + test "list_strand_subjects/1 returns all subjects linked to the given strand" do + subject_a = subject_fixture(%{name: "AAA"}) + subject_b = subject_fixture(%{name: "BBB"}) + + strand = + LearningContextFixtures.strand_fixture(%{subjects_ids: [subject_a.id, subject_b.id]}) + + # extra subjects for filter test + other_subject = subject_fixture() + LearningContextFixtures.strand_fixture(%{subjects_ids: [other_subject.id]}) + + assert Taxonomy.list_strand_subjects(strand.id) == [subject_a, subject_b] + end + test "get_subject!/1 returns the subject with given id" do subject = subject_fixture() assert Taxonomy.get_subject!(subject.id) == subject diff --git a/test/lanttern_web/live/strand_live/details_test.exs b/test/lanttern_web/live/strand_live/details_test.exs index e90dc415..98ae9b22 100644 --- a/test/lanttern_web/live/strand_live/details_test.exs +++ b/test/lanttern_web/live/strand_live/details_test.exs @@ -141,4 +141,50 @@ defmodule LantternWeb.StrandLive.DetailsTest do assert_redirect(view, "/strands") end end + + describe "Activity management" do + alias Lanttern.LearningContext.Activity + + test "create activity", %{conn: conn} do + subject = TaxonomyFixtures.subject_fixture(%{name: "subject abc"}) + strand = LearningContextFixtures.strand_fixture(%{subjects_ids: [subject.id]}) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{strand.id}?tab=activities") + + # open create activity overlay + view |> element("a", "Create new activity") |> render_click() + assert_patch(view, "#{@live_view_base_path}/#{strand.id}/new_activity") + assert view |> has_element?("h2", "New activity") + + # add subject + view + |> element("#activity-form #activity_subject_id") + |> render_change(%{"activity" => %{"subject_id" => subject.id}}) + + # submit form with valid fields + view + |> element("#activity-form") + |> render_submit(%{ + "activity" => %{ + "strand_id" => strand.id, + "name" => "activity name abc", + "description" => "description abc" + } + }) + + {path, _flash} = assert_redirect(view) + + [_, activity_id] = + ~r".+\/(\d+)\z" + |> Regex.run(path) + + activity = + Activity + |> Lanttern.Repo.get!(activity_id) + |> Lanttern.Repo.preload(:subjects) + + assert activity.name == "activity name abc" + assert activity.subjects == [subject] + end + end end From 573526bdee90e4b269a0446223e78700ee4b2dd9 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 1 Dec 2023 16:15:17 -0300 Subject: [PATCH 7/8] feat: edit and delete activity from activity view - adjusted `activities_assessment_points_activity_id_fkey` constraint to prevent delete cascade assessment points when deleting activities --- lib/lanttern/learning_context.ex | 17 +++- lib/lanttern/learning_context/activity.ex | 10 +++ lib/lanttern_web/live/strand_live/activity.ex | 30 +++++++ .../live/strand_live/activity.html.heex | 90 ++++++++++++++----- .../live/strand_live/details.html.heex | 2 +- lib/lanttern_web/router.ex | 1 + ...nts_activity_id_fkey_on_delete_nothing.exs | 18 ++++ .../live/strand_live/activity_test.exs | 46 ++++++++++ 8 files changed, 188 insertions(+), 26 deletions(-) create mode 100644 priv/repo/migrations/20231201184646_set_activities_assessment_points_activity_id_fkey_on_delete_nothing.exs diff --git a/lib/lanttern/learning_context.ex b/lib/lanttern/learning_context.ex index cd33adc3..85092b42 100644 --- a/lib/lanttern/learning_context.ex +++ b/lib/lanttern/learning_context.ex @@ -229,13 +229,20 @@ defmodule Lanttern.LearningContext do defp set_activity_position_attr(attrs) do strand_id = attrs[:strand_id] || attrs["strand_id"] - position = + positions = from( a in Activity, where: a.strand_id == ^strand_id, - select: count() + select: a.position, + order_by: [desc: a.position] ) - |> Repo.one() + |> Repo.all() + + position = + case Enum.at(positions, 0) do + nil -> 0 + pos -> pos + 1 + end cond do not is_nil(attrs[:strand_id]) -> @@ -282,7 +289,9 @@ defmodule Lanttern.LearningContext do """ def delete_activity(%Activity{} = activity) do - Repo.delete(activity) + activity + |> Activity.delete_changeset() + |> Repo.delete() end @doc """ diff --git a/lib/lanttern/learning_context/activity.ex b/lib/lanttern/learning_context/activity.ex index 2420e639..c1e70bb3 100644 --- a/lib/lanttern/learning_context/activity.ex +++ b/lib/lanttern/learning_context/activity.ex @@ -34,4 +34,14 @@ defmodule Lanttern.LearningContext.Activity do |> validate_required([:name, :description, :position, :strand_id]) |> put_subjects() end + + def delete_changeset(activity) do + activity + |> cast(%{}, []) + |> foreign_key_constraint( + :id, + name: :activities_assessment_points_activity_id_fkey, + message: "Activity has linked assessment points." + ) + end end diff --git a/lib/lanttern_web/live/strand_live/activity.ex b/lib/lanttern_web/live/strand_live/activity.ex index f5b3472c..b0cf74b3 100644 --- a/lib/lanttern_web/live/strand_live/activity.ex +++ b/lib/lanttern_web/live/strand_live/activity.ex @@ -4,6 +4,8 @@ defmodule LantternWeb.StrandLive.Activity do alias Lanttern.LearningContext alias LantternWeb.StrandLive.ActivityTabs + # live components + alias LantternWeb.StrandLive.ActivityFormComponent alias LantternWeb.AssessmentPointLive.ActivityAssessmentPointFormComponent @tabs %{ @@ -14,10 +16,12 @@ defmodule LantternWeb.StrandLive.Activity do # lifecycle + @impl true def mount(_params, _session, socket) do {:ok, assign(socket, :activity, nil), layout: {LantternWeb.Layouts, :app_logged_in_blank}} end + @impl true def handle_params(params, _url, socket) do {:noreply, socket @@ -66,8 +70,34 @@ defmodule LantternWeb.StrandLive.Activity do defp apply_action(socket, _live_action, _params), do: socket + # event handlers + + @impl true + def handle_event("delete_activity", _params, socket) do + case LearningContext.delete_activity(socket.assigns.activity) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity deleted") + |> push_navigate(to: ~p"/strands/#{socket.assigns.activity.strand}?tab=activities")} + + {:error, _changeset} -> + {:noreply, + socket + |> put_flash( + :error, + "Activity has linked assessments. Deleting it would cause some data loss." + )} + end + end + # info handlers + @impl true + def handle_info({ActivityFormComponent, {:saved, activity}}, socket) do + {:noreply, assign(socket, :activity, activity)} + end + def handle_info( {ActivityTabs.AssessmentComponent, {:apply_class_filters, classes_ids}}, socket diff --git a/lib/lanttern_web/live/strand_live/activity.html.heex b/lib/lanttern_web/live/strand_live/activity.html.heex index a324252f..baf01390 100644 --- a/lib/lanttern_web/live/strand_live/activity.html.heex +++ b/lib/lanttern_web/live/strand_live/activity.html.heex @@ -24,27 +24,47 @@
- <.nav_tabs class="container mx-auto lg:max-w-5xl" id="activity-nav-tabs"> - <:tab - patch={~p"/strands/activity/#{@activity}?#{%{tab: "details"}}"} - is_current={@current_tab == :details && "true"} - > - Details & Curriculum - - <:tab - patch={~p"/strands/activity/#{@activity}?#{%{tab: "assessment"}}"} - is_current={@current_tab == :assessment && "true"} - > - Assessment - - <:tab - patch={~p"/strands/activity/#{@activity}?#{%{tab: "notes"}}"} - is_current={@current_tab == :notes && "true"} - icon_name="hero-eye-slash" - > - My notes - - +
+ <.nav_tabs id="activity-nav-tabs"> + <:tab + patch={~p"/strands/activity/#{@activity}?#{%{tab: "details"}}"} + is_current={@current_tab == :details && "true"} + > + Details & Curriculum + + <:tab + patch={~p"/strands/activity/#{@activity}?#{%{tab: "assessment"}}"} + is_current={@current_tab == :assessment && "true"} + > + Assessment + + <:tab + patch={~p"/strands/activity/#{@activity}?#{%{tab: "notes"}}"} + is_current={@current_tab == :notes && "true"} + icon_name="hero-eye-slash" + > + My notes + + + <.menu_button id={"activity-#{@activity.id}"}> + <:menu_items> + <.menu_button_item + id={"edit-activity-#{@activity.id}"} + phx-click={JS.patch(~p"/strands/activity/#{@activity}/edit")} + > + Edit activity + + <.menu_button_item + id={"remove-activity-#{@activity.id}"} + class="text-red-500" + phx-click="delete_activity" + data-confirm="Are you sure?" + > + Delete + + + +
<.live_component @@ -71,3 +91,31 @@ current_user={@current_user} />
+<.slide_over + :if={@live_action == :edit} + id="activity-form-overlay" + show={true} + on_cancel={JS.patch(~p"/strands/activity/#{@activity}")} +> + <:title>Edit activity + <.live_component + module={ActivityFormComponent} + id={@activity.id} + activity={@activity} + action={@live_action} + patch={~p"/strands/activity/#{@activity}"} + notify_parent + /> + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#activity-form-overlay")} + > + Cancel + + <.button type="submit" form="activity-form"> + Save + + + diff --git a/lib/lanttern_web/live/strand_live/details.html.heex b/lib/lanttern_web/live/strand_live/details.html.heex index fb432136..be34eacb 100644 --- a/lib/lanttern_web/live/strand_live/details.html.heex +++ b/lib/lanttern_web/live/strand_live/details.html.heex @@ -49,7 +49,7 @@ id={"edit-strand-#{@strand.id}"} phx-click={JS.patch(~p"/strands/#{@strand}/edit")} > - Edit + Edit strand <.menu_button_item id={"remove-strand-#{@strand.id}"} diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 7c69b138..025c6a57 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -72,6 +72,7 @@ defmodule LantternWeb.Router do live "/strands/:id/edit", StrandLive.Details, :edit live "/strands/:id/new_activity", StrandLive.Details, :new_activity live "/strands/activity/:id", StrandLive.Activity, :show + live "/strands/activity/:id/edit", StrandLive.Activity, :edit live "/strands/activity/:id/assessment_point/new", StrandLive.Activity, diff --git a/priv/repo/migrations/20231201184646_set_activities_assessment_points_activity_id_fkey_on_delete_nothing.exs b/priv/repo/migrations/20231201184646_set_activities_assessment_points_activity_id_fkey_on_delete_nothing.exs new file mode 100644 index 00000000..93ed1e12 --- /dev/null +++ b/priv/repo/migrations/20231201184646_set_activities_assessment_points_activity_id_fkey_on_delete_nothing.exs @@ -0,0 +1,18 @@ +defmodule Lanttern.Repo.Migrations.SetActivitiesAssessmentPointsActivityIdFkeyOnDeleteNothing do + use Ecto.Migration + + def change do + execute """ + ALTER TABLE activities_assessment_points + DROP CONSTRAINT activities_assessment_points_activity_id_fkey, + ADD CONSTRAINT activities_assessment_points_activity_id_fkey FOREIGN KEY (activity_id) + REFERENCES activities (id); + """, + """ + ALTER TABLE activities_assessment_points + DROP CONSTRAINT activities_assessment_points_activity_id_fkey, + ADD CONSTRAINT activities_assessment_points_activity_id_fkey FOREIGN KEY (activity_id) + REFERENCES activities (id) ON DELETE CASCADE; + """ + end +end diff --git a/test/lanttern_web/live/strand_live/activity_test.exs b/test/lanttern_web/live/strand_live/activity_test.exs index 7864ddcb..153b4a59 100644 --- a/test/lanttern_web/live/strand_live/activity_test.exs +++ b/test/lanttern_web/live/strand_live/activity_test.exs @@ -78,4 +78,50 @@ defmodule LantternWeb.StrandLive.ActivityTest do assert view |> has_element?("p", "activity description abc") end end + + describe "Activity management" do + test "edit activity", %{conn: conn} do + subject = TaxonomyFixtures.subject_fixture(%{name: "subject abc"}) + strand = LearningContextFixtures.strand_fixture(%{subjects_ids: [subject.id]}) + + activity = + LearningContextFixtures.activity_fixture(%{strand_id: strand.id, name: "activity abc"}) + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{activity.id}/edit") + + assert view + |> has_element?("h2", "Edit activity") + + # add subject + view + |> element("#activity-form #activity_subject_id") + |> render_change(%{"activity" => %{"subject_id" => subject.id}}) + + # submit form with valid field + view + |> element("#activity-form") + |> render_submit(%{ + "activity" => %{ + "name" => "activity name xyz" + } + }) + + assert_patch(view, "#{@live_view_base_path}/#{activity.id}") + + assert view |> has_element?("h1", "activity name xyz") + assert view |> has_element?("span", subject.name) + end + + test "delete activity", %{conn: conn} do + activity = LearningContextFixtures.activity_fixture() + + {:ok, view, _html} = live(conn, "#{@live_view_base_path}/#{activity.id}") + + view + |> element("button#remove-activity-#{activity.id}") + |> render_click() + + assert_redirect(view, "/strands/#{activity.strand_id}?tab=activities") + end + end end From 5da96832accec2f842f4ab9b4d8c0b85f65141e5 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 1 Dec 2023 17:18:03 -0300 Subject: [PATCH 8/8] chore: general adjustments - adjusted position definition in `Assessments.create_activity_assessment_point/2` - removed notification calls from `ActivityAssessmentPointFormComponent` in favor of `handle_navigation/2` - added `handle_navigation/2` to `ClassFilterFormComponent` save, passing classes ids as arg - removed all `handle_info` from `StrandLive.Activity` (at least the ones related to patch and navigation, in favor of `handle_navigation` in forms) - added empty state to `StrandLive.List` - added debounce and `handle_navigation/2` on save to `StrandFormComponent` - removed notification calls from `ActivityTabs.AssessmentComponent` in favor of local patch/navigate - adjusted broken seeds.exs --- lib/lanttern/assessments.ex | 16 ++- .../activity_assessment_point_form.ex | 27 ++--- .../class_filter_form_component.ex | 15 ++- lib/lanttern_web/live/strand_live/activity.ex | 43 -------- .../activity_tabs/assessment_component.ex | 102 ++++++++---------- lib/lanttern_web/live/strand_live/list.ex | 2 + .../live/strand_live/list.html.heex | 53 ++++----- .../live/strand_live/strand_form_component.ex | 14 ++- priv/repo/seeds.exs | 26 ++++- .../live/strand_live/list_test.exs | 19 +++- 10 files changed, 155 insertions(+), 162 deletions(-) diff --git a/lib/lanttern/assessments.ex b/lib/lanttern/assessments.ex index 5f7bcfe4..10cde866 100644 --- a/lib/lanttern/assessments.ex +++ b/lib/lanttern/assessments.ex @@ -663,12 +663,20 @@ defmodule Lanttern.Assessments do |> Ecto.Multi.run( :link_activity, fn _repo, %{insert_assessment_point: assessment_point} -> - position = - from(aap in ActivityAssessmentPoint, + positions = + from( + aap in ActivityAssessmentPoint, where: aap.activity_id == ^activity_id, - select: count() + select: aap.position, + order_by: [desc: aap.position] ) - |> Repo.one() + |> Repo.all() + + position = + case Enum.at(positions, 0) do + nil -> 0 + pos -> pos + 1 + end %ActivityAssessmentPoint{} |> ActivityAssessmentPoint.changeset(%{ diff --git a/lib/lanttern_web/live/assessment_point_live/activity_assessment_point_form.ex b/lib/lanttern_web/live/assessment_point_live/activity_assessment_point_form.ex index 4f6e4704..97481002 100644 --- a/lib/lanttern_web/live/assessment_point_live/activity_assessment_point_form.ex +++ b/lib/lanttern_web/live/assessment_point_live/activity_assessment_point_form.ex @@ -129,11 +129,10 @@ defmodule LantternWeb.AssessmentPointLive.ActivityAssessmentPointFormComponent d defp save(:new, params, socket) do case Assessments.create_activity_assessment_point(socket.assigns.activity_id, params) do - {:ok, assessment_point} -> - msg = {:created, assessment_point} - notify_parent(msg) - maybe_notify_component(msg, socket.assigns) - {:noreply, socket} + {:ok, _assessment_point} -> + {:noreply, + socket + |> handle_navigation()} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} @@ -142,23 +141,13 @@ defmodule LantternWeb.AssessmentPointLive.ActivityAssessmentPointFormComponent d defp save(:edit, params, socket) do case Assessments.update_assessment_point(socket.assigns.assessment_point, params) do - {:ok, assessment_point} -> - msg = {:updated, assessment_point} - notify_parent(msg) - maybe_notify_component(msg, socket.assigns) - {:noreply, socket} + {:ok, _assessment_point} -> + {:noreply, + socket + |> handle_navigation()} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end end - - # helpers - - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - - defp maybe_notify_component(msg, %{notify_component: %Phoenix.LiveComponent.CID{} = cid}), - do: send_update(cid, action: {__MODULE__, msg}) - - defp maybe_notify_component(_msg, _assigns), do: nil end diff --git a/lib/lanttern_web/live/school_live/class_filter_form_component.ex b/lib/lanttern_web/live/school_live/class_filter_form_component.ex index 242668b1..49be6147 100644 --- a/lib/lanttern_web/live/school_live/class_filter_form_component.ex +++ b/lib/lanttern_web/live/school_live/class_filter_form_component.ex @@ -46,9 +46,16 @@ defmodule LantternWeb.SchoolLive.ClassFilterFormComponent do # event handlers def handle_event("save", params, socket) do - params = Map.get(params, "classes", %{"classes_ids" => []}) - notify_parent(__MODULE__, {:save, params}, socket.assigns) - notify_component(__MODULE__, {:save, params}, socket.assigns) - {:noreply, socket} + classes_ids = + params + |> Map.get("classes", %{"classes_ids" => []}) + |> Map.get("classes_ids") + + notify_parent(__MODULE__, {:save, classes_ids}, socket.assigns) + notify_component(__MODULE__, {:save, classes_ids}, socket.assigns) + + {:noreply, + socket + |> handle_navigation(classes_ids)} end end diff --git a/lib/lanttern_web/live/strand_live/activity.ex b/lib/lanttern_web/live/strand_live/activity.ex index b0cf74b3..59a2598e 100644 --- a/lib/lanttern_web/live/strand_live/activity.ex +++ b/lib/lanttern_web/live/strand_live/activity.ex @@ -6,7 +6,6 @@ defmodule LantternWeb.StrandLive.Activity do # live components alias LantternWeb.StrandLive.ActivityFormComponent - alias LantternWeb.AssessmentPointLive.ActivityAssessmentPointFormComponent @tabs %{ "details" => :details, @@ -97,46 +96,4 @@ defmodule LantternWeb.StrandLive.Activity do def handle_info({ActivityFormComponent, {:saved, activity}}, socket) do {:noreply, assign(socket, :activity, activity)} end - - def handle_info( - {ActivityTabs.AssessmentComponent, {:apply_class_filters, classes_ids}}, - socket - ) do - {:noreply, - socket - |> push_navigate( - to: - ~p"/strands/activity/#{socket.assigns.activity}?#{%{tab: "assessment", classes_ids: classes_ids}}" - )} - end - - def handle_info( - {ActivityTabs.AssessmentComponent, {:assessment_point_deleted, _assessment_point}}, - socket - ) do - {:noreply, - socket - |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} - end - - def handle_info( - {ActivityTabs.AssessmentComponent, {:assessment_points_reordered, _assessment_point}}, - socket - ) do - {:noreply, - socket - |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} - end - - def handle_info({ActivityAssessmentPointFormComponent, {:created, _assessment_point}}, socket) do - {:noreply, - socket - |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} - end - - def handle_info({ActivityAssessmentPointFormComponent, {:updated, _assessment_point}}, socket) do - {:noreply, - socket - |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} - end end diff --git a/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex b/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex index e11ff8b5..e21fa3aa 100644 --- a/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex +++ b/lib/lanttern_web/live/strand_live/activity_tabs/assessment_component.ex @@ -129,6 +129,7 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do strand_id={@activity.strand_id} notify_component={@myself} assessment_point={@assessment_point} + navigate={~p"/strands/activity/#{@activity}?tab=assessment"} />
+ url_params = %{tab: "assessment", classes_ids: classes_ids} + ~p"/strands/activity/#{@activity}?#{url_params}" + end + } /> <:actions> <.button @@ -212,34 +219,34 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do
  • -
    - <%= "#{i + 1}. #{assessment_point.name}" %> -
    - <.icon_button - type="button" - sr_text="Move assessment point down" - name="hero-chevron-down-mini" - theme="ghost" - rounded - size="sm" - disabled={i + 1 == @assessment_points_count} - phx-click={JS.push("assessment_point_position_inc", value: %{index: i})} - phx-target={@myself} - /> - <.icon_button - type="button" - sr_text="Move assessment point up" - name="hero-chevron-up-mini" - theme="ghost" - rounded - size="sm" - disabled={i == 0} - phx-click={JS.push("assessment_point_position_dec", value: %{index: i})} - phx-target={@myself} - /> -
    +
    + <%= "#{i + 1}. #{assessment_point.name}" %> +
    +
    + <.icon_button + type="button" + sr_text="Move assessment point up" + name="hero-chevron-up-mini" + theme="ghost" + rounded + size="sm" + disabled={i == 0} + phx-click={JS.push("assessment_point_position", value: %{from: i, to: i - 1})} + phx-target={@myself} + /> + <.icon_button + type="button" + sr_text="Move assessment point down" + name="hero-chevron-down-mini" + theme="ghost" + rounded + size="sm" + disabled={i + 1 == @assessment_points_count} + phx-click={JS.push("assessment_point_position", value: %{from: i, to: i + 1})} + phx-target={@myself} + />
  • @@ -333,12 +340,6 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do end @impl true - def update(%{action: {ClassFilterFormComponent, {:save, params}}}, socket) do - classes_ids = Map.get(params, "classes_ids") - notify_parent({:apply_class_filters, classes_ids}) - {:ok, socket} - end - def update(%{activity: activity, assessment_point_id: assessment_point_id} = assigns, socket) do {:ok, socket @@ -417,9 +418,10 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do @impl true def handle_event("delete_assessment_point", _params, socket) do case Assessments.delete_assessment_point(socket.assigns.assessment_point) do - {:ok, assessment_point} -> - notify_parent({:assessment_point_deleted, assessment_point}) - {:noreply, socket} + {:ok, _assessment_point} -> + {:noreply, + socket + |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} {:error, _changeset} -> # we may have more error types, but for now we are handling only this one @@ -433,8 +435,9 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do def handle_event("delete_assessment_point_and_entries", _, socket) do case Assessments.delete_assessment_point_and_entries(socket.assigns.assessment_point) do {:ok, _} -> - notify_parent({:assessment_point_deleted, socket.assigns.assessment_point}) - {:noreply, socket} + {:noreply, + socket + |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} {:error, _} -> {:noreply, socket} @@ -447,21 +450,11 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do |> assign(:delete_assessment_point_error, nil)} end - def handle_event("assessment_point_position_inc", %{"index" => i}, socket) do + def handle_event("assessment_point_position", %{"from" => i, "to" => j}, socket) do sortable_assessment_points = socket.assigns.sortable_assessment_points |> Enum.map(fn {ap, _i} -> ap end) - |> swap(i, i + 1) - |> Enum.with_index() - - {:noreply, assign(socket, :sortable_assessment_points, sortable_assessment_points)} - end - - def handle_event("assessment_point_position_dec", %{"index" => i}, socket) do - sortable_assessment_points = - socket.assigns.sortable_assessment_points - |> Enum.map(fn {ap, _i} -> ap end) - |> swap(i, i - 1) + |> swap(i, j) |> Enum.with_index() {:noreply, assign(socket, :sortable_assessment_points, sortable_assessment_points)} @@ -476,9 +469,10 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do socket.assigns.activity.id, assessment_points_ids ) do - {:ok, assessment_points} -> - notify_parent({:assessment_points_reordered, assessment_points}) - {:noreply, socket} + {:ok, _assessment_points} -> + {:noreply, + socket + |> push_navigate(to: ~p"/strands/activity/#{socket.assigns.activity}?tab=assessment")} {:error, _} -> {:noreply, socket} @@ -487,8 +481,6 @@ defmodule LantternWeb.StrandLive.ActivityTabs.AssessmentComponent do # helpers - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - # https://elixirforum.com/t/swap-elements-in-a-list/34471/4 defp swap(a, i1, i2) do e1 = Enum.at(a, i1) diff --git a/lib/lanttern_web/live/strand_live/list.ex b/lib/lanttern_web/live/strand_live/list.ex index df8f7828..481a94cc 100644 --- a/lib/lanttern_web/live/strand_live/list.ex +++ b/lib/lanttern_web/live/strand_live/list.ex @@ -12,9 +12,11 @@ defmodule LantternWeb.StrandLive.List do @impl true def mount(_params, _session, socket) do strands = LearningContext.list_strands(preloads: [:subjects, :years]) + strands_count = length(strands) {:ok, socket + |> assign(:strands_count, strands_count) |> stream(:strands, strands)} end diff --git a/lib/lanttern_web/live/strand_live/list.html.heex b/lib/lanttern_web/live/strand_live/list.html.heex index 6c743cda..34fbdcf2 100644 --- a/lib/lanttern_web/live/strand_live/list.html.heex +++ b/lib/lanttern_web/live/strand_live/list.html.heex @@ -9,33 +9,37 @@ Create new strand
    -
    -
    + <%= if @strands_count == 0 do %> + <.empty_state>No strands created yet + <% else %> +
    -
    - <.link - navigate={~p"/strands/#{strand}"} - class="font-display font-black text-3xl underline line-clamp-3" - > - <%= strand.name %> - -
    - <.badge :for={subject <- strand.subjects}><%= subject.name %> - <.badge :for={year <- strand.years}><%= year.name %> -
    -
    - <.markdown text={strand.description} class="prose-sm" /> + :for={{dom_id, strand} <- @streams.strands} + class="rounded shadow-xl bg-white" + id={dom_id} + > +
    +
    + <.link + navigate={~p"/strands/#{strand}"} + class="font-display font-black text-3xl underline line-clamp-3" + > + <%= strand.name %> + +
    + <.badge :for={subject <- strand.subjects}><%= subject.name %> + <.badge :for={year <- strand.years}><%= year.name %> +
    +
    + <.markdown text={strand.description} class="prose-sm" /> +
    -
    + <% end %> <.slide_over :if={@live_action == :new} id="strand-form-overlay" @@ -48,8 +52,7 @@ id={:new} strand={%Strand{curriculum_items: [], subjects: [], years: []}} action={:new} - patch={~p"/strands"} - notify_parent + navigate={fn strand -> ~p"/strands/#{strand}" end} /> <:actions> <.button diff --git a/lib/lanttern_web/live/strand_live/strand_form_component.ex b/lib/lanttern_web/live/strand_live/strand_form_component.ex index b98fc5c6..60349361 100644 --- a/lib/lanttern_web/live/strand_live/strand_form_component.ex +++ b/lib/lanttern_web/live/strand_live/strand_form_component.ex @@ -17,8 +17,14 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do <.error_block :if={@form.source.action == :insert} class="mb-6"> Oops, something went wrong! Please check the errors below. - <.input field={@form[:name]} type="text" label="Name" class="mb-6" /> - <.input field={@form[:description]} type="textarea" label="Description" class="mb-1" /> + <.input field={@form[:name]} type="text" label="Name" class="mb-6" phx-debounce="1500" /> + <.input + field={@form[:description]} + type="textarea" + label="Description" + class="mb-1" + phx-debounce="1500" + /> <.markdown_supported class="mb-6" /> <.live_component module={MultiSelectComponent} @@ -227,7 +233,7 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do {:noreply, socket |> put_flash(:info, "Strand updated successfully") - |> push_patch(to: socket.assigns.patch)} + |> handle_navigation(strand)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign_form(socket, changeset)} @@ -244,7 +250,7 @@ defmodule LantternWeb.StrandLive.StrandFormComponent do {:noreply, socket |> put_flash(:info, "Strand created successfully") - |> push_patch(to: socket.assigns.patch)} + |> handle_navigation(strand)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign_form(socket, changeset)} diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 4a6c72fe..62a8b436 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -61,10 +61,19 @@ teacher = Repo.insert!(%Schools.Teacher{school_id: school.id, name: "The Teacher _teacher_1 = Repo.insert!(%Schools.Teacher{school_id: school.id, name: "Teacher 1"}) _teacher_2 = Repo.insert!(%Schools.Teacher{school_id: school.id, name: "Teacher 2"}) +cycle = + Repo.insert!(%Schools.Cycle{ + school_id: school.id, + name: "2024", + start_at: ~D[2024-01-01], + end_at: ~D[2024-12-31] + }) + # use changeset to `put_assoc` students _class_1 = Schools.Class.changeset(%Schools.Class{}, %{ school_id: school.id, + cycle_id: cycle.id, name: "Grade 1", students_ids: [std_1.id, std_2.id, std_3.id, std_4.id, std_5.id] }) @@ -73,6 +82,7 @@ _class_1 = _class_2 = Schools.Class.changeset(%Schools.Class{}, %{ school_id: school.id, + cycle_id: cycle.id, name: "Grade 2", students_ids: [std_6.id, std_7.id, std_8.id, std_9.id, std_10.id] }) @@ -355,22 +365,30 @@ ap_1 = Repo.insert!(%Assessments.AssessmentPointEntry{ student_id: std_1.id, - assessment_point_id: ap_1.id + assessment_point_id: ap_1.id, + scale_id: camino_levels_scale.id, + scale_type: camino_levels_scale.type }) Repo.insert!(%Assessments.AssessmentPointEntry{ student_id: std_2.id, - assessment_point_id: ap_1.id + assessment_point_id: ap_1.id, + scale_id: camino_levels_scale.id, + scale_type: camino_levels_scale.type }) Repo.insert!(%Assessments.AssessmentPointEntry{ student_id: std_3.id, - assessment_point_id: ap_1.id + assessment_point_id: ap_1.id, + scale_id: camino_levels_scale.id, + scale_type: camino_levels_scale.type }) Repo.insert!(%Assessments.AssessmentPointEntry{ student_id: std_4.id, - assessment_point_id: ap_1.id + assessment_point_id: ap_1.id, + scale_id: camino_levels_scale.id, + scale_type: camino_levels_scale.type }) # ------------------------------ diff --git a/test/lanttern_web/live/strand_live/list_test.exs b/test/lanttern_web/live/strand_live/list_test.exs index fd5f4c81..264f7e79 100644 --- a/test/lanttern_web/live/strand_live/list_test.exs +++ b/test/lanttern_web/live/strand_live/list_test.exs @@ -43,6 +43,8 @@ defmodule LantternWeb.StrandLive.ListTest do end describe "Strand management" do + alias Lanttern.LearningContext.Strand + test "create strand", %{conn: conn} do subject = TaxonomyFixtures.subject_fixture(%{name: "subject abc"}) year = TaxonomyFixtures.year_fixture(%{name: "year abc"}) @@ -74,11 +76,20 @@ defmodule LantternWeb.StrandLive.ListTest do } }) - assert_patch(view, "/strands") + {path, _flash} = assert_redirect(view) - assert view |> has_element?("a", "strand name abc") - assert view |> has_element?("span", subject.name) - assert view |> has_element?("span", year.name) + [_, strand_id] = + ~r".+\/(\d+)\z" + |> Regex.run(path) + + strand = + Strand + |> Lanttern.Repo.get!(strand_id) + |> Lanttern.Repo.preload([:subjects, :years]) + + assert strand.name == "strand name abc" + assert strand.subjects == [subject] + assert strand.years == [year] end end end