From c90fbd3e6d400c709c3608cae959bbbd703a792c Mon Sep 17 00:00:00 2001 From: Eric Endo Date: Thu, 2 Nov 2023 10:31:10 -0300 Subject: [PATCH] feat: added create and edit base support to rubrics view - created `LantternWeb.RubricsLive.FormComponent` live component and added support to edit/create rubric in `LantternWeb.RubricsLive.Explorer` - added `get_button_styles/1` function to core components, to apply button styles to different elements (e.g. links) - created `<.toggle>` core component - added support to `<.input type="toggle">` (basically a checkbox "mask") - added support to custom labels in `<.input>` component through `<:custom_label>` slot - added support to `:prevent_close_on_click_away` attr on `<.slide_over>` component --- .../components/core_components.ex | 60 +++- .../components/form_components.ex | 62 +++- .../components/overlay_components.ex | 5 +- .../live/rubrics_live/explorer.ex | 65 +++- .../live/rubrics_live/form_component.ex | 312 ++++++++++++++++++ lib/lanttern_web/router.ex | 4 +- 6 files changed, 489 insertions(+), 19 deletions(-) create mode 100644 lib/lanttern_web/live/rubrics_live/form_component.ex diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index 96705bff..9a418f2a 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -152,6 +152,7 @@ defmodule LantternWeb.CoreComponents do attr :type, :string, default: nil attr :class, :any, default: nil attr :theme, :string, default: "default", doc: "default | ghost" + attr :icon_name, :string, default: nil attr :rest, :global, include: ~w(disabled form name value) slot :inner_block, required: true @@ -161,18 +162,36 @@ defmodule LantternWeb.CoreComponents do """ end + @doc """ + Returns a list of button styles. + + Meant to be used while styling links as buttons. + + ## Examples + + <.link patch={~p"/somepath"} class={[get_button_styles()]}>Link + """ + def get_button_styles(theme \\ "default") do + [ + "inline-flex items-center gap-1 rounded-sm py-2 px-2 font-display text-sm font-bold", + "phx-submit-loading:opacity-50 phx-click-loading:opacity-50 phx-click-loading:pointer-events-none", + button_theme(theme) + ] + end + defp button_theme(theme) do %{ "default" => "bg-ltrn-primary hover:bg-cyan-300 shadow-sm", @@ -595,6 +614,41 @@ defmodule LantternWeb.CoreComponents do """ end + @doc """ + Toggle component. + """ + + attr :enabled, :boolean, required: true + attr :class, :any, default: nil + attr :sr_text, :string, default: nil + attr :rest, :global + + def toggle(assigns) do + ~H""" + + """ + end + @doc """ Highlights entring (mounting) elements in DOM. diff --git a/lib/lanttern_web/components/form_components.ex b/lib/lanttern_web/components/form_components.ex index 29bbfdbe..f6a85b6f 100644 --- a/lib/lanttern_web/components/form_components.ex +++ b/lib/lanttern_web/components/form_components.ex @@ -5,6 +5,7 @@ defmodule LantternWeb.FormComponents do use Phoenix.Component import LantternWeb.CoreComponents + alias Phoenix.LiveView.JS @doc """ Renders a simple form. @@ -75,7 +76,7 @@ defmodule LantternWeb.FormComponents do attr :type, :string, default: "text", values: ~w(checkbox color date datetime-local email file hidden month number password - range radio search select tel text textarea time url week) + range radio search select tel text textarea time url week toggle) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" @@ -93,6 +94,7 @@ defmodule LantternWeb.FormComponents do multiple pattern placeholder readonly required rows size step) slot :inner_block + slot :custom_label def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns @@ -127,10 +129,41 @@ defmodule LantternWeb.FormComponents do """ end + def input(%{type: "toggle", value: value} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end) + + ~H""" +
+ + <.error :for={msg <- @errors}><%= msg %> +
+ """ + end + def input(%{type: "select"} = assigns) do ~H"""
- <.label for={@id} show_optional={@show_optional}><%= @label %> + <.label + for={@id} + show_optional={@show_optional} + custom={if @custom_label, do: true, else: false} + > + <%= @label || render_slot(@custom_label) %> + <.select id={@id} name={@name} @@ -148,7 +181,13 @@ defmodule LantternWeb.FormComponents do def input(%{type: "textarea"} = assigns) do ~H"""
- <.label for={@id} show_optional={@show_optional}><%= @label %> + <.label + for={@id} + show_optional={@show_optional} + custom={if @custom_label, do: true, else: false} + > + <%= @label || render_slot(@custom_label) %> + <.textarea id={@id} name={@name} errors={@errors} value={@value} {@rest} /> <.error :for={msg <- @errors}><%= msg %>
@@ -159,7 +198,13 @@ defmodule LantternWeb.FormComponents do def input(assigns) do ~H"""
- <.label for={@id} show_optional={@show_optional}><%= @label %> + <.label + for={@id} + show_optional={@show_optional} + custom={if @custom_label, do: true, else: false} + > + <%= @label || render_slot(@custom_label) %> + <.base_input type={@type} name={@name} id={@id} value={@value} errors={@errors} {@rest} /> <.error :for={msg <- @errors}><%= msg %>
@@ -171,6 +216,7 @@ defmodule LantternWeb.FormComponents do """ attr :for, :string, default: nil attr :show_optional, :boolean, default: false + attr :custom, :boolean, default: false slot :inner_block, required: true def label(%{show_optional: true} = assigns) do @@ -184,6 +230,14 @@ defmodule LantternWeb.FormComponents do """ end + def label(%{custom: true} = assigns) do + ~H""" + + """ + end + def label(assigns) do ~H"""
-

Criteria: <%= rubric.criteria %>

+

+ <.badge>#<%= rubric.id %> + Criteria: <%= rubric.criteria %> +

<.icon name="hero-view-columns" class="shrink-0 text-rose-500" /> <%= rubric.scale.name %>
- <.button type="button" theme="ghost">Id #<%= rubric.id %> - <.button type="button" theme="ghost">Edit <.badge :if={rubric.is_differentiation} theme="secondary">Differentiation + <.link patch={~p"/rubrics/#{rubric}/edit"} class={get_button_styles("ghost")}> + Edit +
@@ -64,16 +72,35 @@ defmodule LantternWeb.RubricsLive.Explorer do
+ <.slide_over + :if={@live_action in [:new, :edit]} + id="rubric-form-overlay" + show={true} + prevent_close_on_click_away + > + <:title><%= @overlay_title %> + <.live_component + module={LantternWeb.RubricsLive.FormComponent} + id={@rubric.id || :new} + action={@live_action} + rubric={@rubric} + patch={~p"/rubrics"} + /> + <:actions> + <.button type="button" theme="ghost" phx-click={JS.patch(~p"/rubrics")}> + Cancel + + <.button type="submit" form="rubric-form" phx-disable-with="Saving..."> + Save + + + """ end # lifecycle def mount(_params, _session, socket) do - {:ok, socket} - end - - def handle_params(_params, _uri, socket) do rubrics = Rubrics.list_full_rubrics() results = length(rubrics) @@ -82,6 +109,24 @@ defmodule LantternWeb.RubricsLive.Explorer do |> stream(:rubrics, rubrics) |> assign(:results, results) - {:noreply, socket} + {:ok, socket} + 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 end diff --git a/lib/lanttern_web/live/rubrics_live/form_component.ex b/lib/lanttern_web/live/rubrics_live/form_component.ex new file mode 100644 index 00000000..af14d7e2 --- /dev/null +++ b/lib/lanttern_web/live/rubrics_live/form_component.ex @@ -0,0 +1,312 @@ +defmodule LantternWeb.RubricsLive.FormComponent do + use LantternWeb, :live_component + + alias Lanttern.Rubrics + alias Lanttern.Grading + import LantternWeb.GradingHelpers + + @impl true + def render(assigns) do + ~H""" +
+ <.form for={@form} id="rubric-form" phx-target={@myself} phx-change="validate" phx-submit="save"> + <.input field={@form[:criteria]} type="text" label="Criteria" phx-debounce="1500" /> + <.input + field={@form[:is_differentiation]} + type="toggle" + label="Is differentiation" + class="mt-6" + /> + <.input + field={@form[:scale_id]} + type="select" + label="Scale" + options={@scale_options} + prompt="Select scale" + phx-target={@myself} + phx-change="scale_selected" + class="mt-6" + /> + <.descriptors_fields scale={@scale} field={@form[:descriptors]} myself={@myself} /> + +
+ """ + end + + attr :scale, :map, required: true + attr :field, :map, required: true + attr :myself, :any, required: true + + defp descriptors_fields(%{scale: nil} = assigns) do + ~H""" +

Select a scale to create descriptors

+ """ + end + + defp descriptors_fields(%{scale: %{type: "ordinal"}} = assigns) do + ~H""" +
Descriptors
+ <.inputs_for :let={ef} field={@field}> + <.input type="hidden" field={ef[:scale_id]} /> + <.input type="hidden" field={ef[:scale_type]} /> + <.input type="hidden" field={ef[:ordinal_value_id]} /> + <.input type="textarea" field={ef[:descriptor]} class="mt-6"> + <:custom_label> + <.ordinal_value_label + ordinal_values={@scale.ordinal_values} + ordinal_value_id={ef[:ordinal_value_id].value} + /> + + + + """ + end + + defp descriptors_fields(%{scale: %{type: "numeric"}} = assigns) do + ~H""" +
Descriptors
+ <.inputs_for :let={ef} field={@field}> +
+
+ <.input type="hidden" field={ef[:scale_id]} /> + <.input type="hidden" field={ef[:scale_type]} /> + <.input + type="number" + min={@scale.start} + max={@scale.stop} + field={ef[:score]} + label="Score" + class="mt-6" + /> + <.input type="textarea" field={ef[:descriptor]} /> +
+ +
+ + + + """ + end + + attr :ordinal_values, :list, required: true + attr :ordinal_value_id, :integer, required: true + + defp ordinal_value_label( + %{ + ordinal_values: ordinal_values, + ordinal_value_id: ordinal_value_id + } = assigns + ) do + assigns = + assigns + |> assign( + :ordinal_value, + ordinal_values + |> Enum.find(&(ordinal_value_id in [&1.id, "#{&1.id}"])) + ) + + ~H""" + <.badge style_from_ordinal_value={@ordinal_value}> + <%= @ordinal_value.name %> + + """ + end + + @impl true + def mount(socket) do + scale_options = generate_scale_options() + + socket = + socket + |> assign(:scale_options, scale_options) + |> assign(:scale, nil) + + {:ok, socket} + end + + @impl true + def update(%{rubric: rubric} = assigns, socket) do + changeset = Rubrics.change_rubric(rubric) + + scale = + case rubric.scale_id do + nil -> nil + scale_id -> Grading.get_scale!(scale_id, preloads: :ordinal_values) + end + + {:ok, + socket + |> assign(assigns) + |> assign(:scale, scale) + |> assign_form(changeset)} + end + + @impl true + def handle_event("scale_selected", %{"rubric" => %{"scale_id" => ""}}, socket) do + changeset = + socket.assigns.rubric + |> Rubrics.change_rubric( + socket.assigns.form.params + |> Map.put("descriptors", %{}) + # at this point, socket form params represents the last change, + # not the scale id change. so we add it manually + |> Map.put("scale_id", "") + ) + + socket = + socket + |> assign(:scale, nil) + |> assign_form(changeset) + + {:noreply, socket} + end + + def handle_event("scale_selected", %{"rubric" => %{"scale_id" => scale_id}}, socket) do + scale = Grading.get_scale!(scale_id, preloads: :ordinal_values) + + descriptors = + case scale.type do + "ordinal" -> + scale.ordinal_values + |> Enum.map( + &%{ + scale_id: &1.scale_id, + scale_type: scale.type, + ordinal_value_id: &1.id, + descriptor: "—" + } + ) + + "numeric" -> + %{ + "0" => + blank_numeric_descriptor(scale) + |> Map.put("score", scale.start), + "1" => + blank_numeric_descriptor(scale) + |> Map.put("score", scale.stop) + } + end + + changeset = + socket.assigns.rubric + |> Rubrics.change_rubric( + socket.assigns.form.params + |> Map.put("descriptors", descriptors) + # at this point, socket form params represents the last change, + # not the scale id change. so we add it manually + |> Map.put("scale_id", scale_id) + ) + + socket = + socket + |> assign(:scale, scale) + |> assign_form(changeset) + + {:noreply, socket} + end + + def handle_event("validate", %{"rubric" => %{"add_descriptor" => "on"} = rubric_params}, socket) do + %{scale: scale} = socket.assigns + + blank_descriptor = blank_numeric_descriptor(scale) + + changeset = + socket.assigns.rubric + |> Rubrics.change_rubric( + rubric_params + |> Map.delete("add_descriptor") + |> Map.update("descriptors", %{"0" => blank_descriptor}, fn descriptors -> + i = length(Map.keys(descriptors)) + Map.put(descriptors, "#{i}", blank_descriptor) + end) + ) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event( + "validate", + %{"rubric" => %{"remove_descriptor" => index} = rubric_params}, + socket + ) do + changeset = + socket.assigns.rubric + |> Rubrics.change_rubric( + rubric_params + |> Map.update("descriptors", %{}, &Map.delete(&1, index)) + ) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("validate", %{"rubric" => rubric_params}, socket) do + changeset = + socket.assigns.rubric + |> Rubrics.change_rubric(rubric_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"rubric" => rubric_params}, socket) do + # force "descriptors" be present in params for removing descriptors on cast_assoc + rubric_params = + rubric_params + |> Map.put_new("descriptors", %{}) + + save_rubric(socket, socket.assigns.action, rubric_params) + end + + defp blank_numeric_descriptor(scale) do + %{ + "scale_id" => scale.id, + "scale_type" => scale.type, + "score" => "", + "descriptor" => "" + } + end + + defp save_rubric(socket, :edit, rubric_params) do + case Rubrics.update_rubric(socket.assigns.rubric, rubric_params, preloads: :scale) do + {:ok, rubric} -> + notify_parent({:saved, rubric}) + + {:noreply, + socket + |> put_flash(:info, "Rubric updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_rubric(socket, :new, rubric_params) do + case Rubrics.create_rubric(rubric_params, preloads: :scale) do + {:ok, rubric} -> + notify_parent({:saved, rubric}) + + {:noreply, + socket + |> put_flash(:info, "Rubric 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/router.ex b/lib/lanttern_web/router.ex index 9900f15c..dca0857a 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -46,7 +46,9 @@ defmodule LantternWeb.Router do live "/assessment_points", AssessmentPointsExplorerLive live "/assessment_points/:id", AssessmentPointLive - live "/rubrics", RubricsLive.Explorer + live "/rubrics", RubricsLive.Explorer, :index + live "/rubrics/new", RubricsLive.Explorer, :new + live "/rubrics/:id/edit", RubricsLive.Explorer, :edit live "/curriculum", CurriculumLive live "/curriculum/bncc_ef", CurriculumBNCCEFLive