From 5472feb1195961d3c14571b410e39691f8822459 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 29 Feb 2024 11:18:40 -0300 Subject: [PATCH 01/15] chore: added base structure for report card grades subjects and cycles - created `ReportCardGradeCycle` and `ReportCardGradeSubject` schemas in `Reporting` context - added `list_report_card_grades_subjects/1`, `add_subject_to_report_card_grades/1`, `update_report_card_grades_subjects_positions/1`, `list_report_card_grades_cycles/1`, and `add_cycle_to_report_card_grades/1` to `Reporting` module - added `update_positions/2` to `Lanttern.Utils` for DRY reasons - added basic grades tab in report card live view --- lib/lanttern/reporting.ex | 165 ++++++++++++++--- .../reporting/report_card_grade_cycle.ex | 18 ++ .../reporting/report_card_grade_subject.ex | 20 ++ lib/lanttern/utils.ex | 39 ++++ .../pages/report_cards/id/grades_component.ex | 49 +++++ .../pages/report_cards/id/report_card_live.ex | 4 +- .../id/report_card_live.html.heex | 33 +++- .../live/shared/menu_component.ex | 2 +- ...217_create_report_card_grades_subjects.exs | 16 ++ ...21906_create_report_card_grades_cycles.exs | 15 ++ test/lanttern/reporting_test.exs | 173 ++++++++++++++++++ test/support/fixtures/reporting_fixtures.ex | 36 ++++ 12 files changed, 534 insertions(+), 36 deletions(-) create mode 100644 lib/lanttern/reporting/report_card_grade_cycle.ex create mode 100644 lib/lanttern/reporting/report_card_grade_subject.ex create mode 100644 lib/lanttern_web/live/pages/report_cards/id/grades_component.ex create mode 100644 priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs create mode 100644 priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 59ccde7a..4c526c3f 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -6,10 +6,12 @@ defmodule Lanttern.Reporting do import Ecto.Query, warn: false alias Lanttern.Repo import Lanttern.RepoHelpers - import LantternWeb.Gettext + alias Lanttern.Utils alias Lanttern.Reporting.ReportCard alias Lanttern.Reporting.StudentReportCard + alias Lanttern.Reporting.ReportCardGradeSubject + alias Lanttern.Reporting.ReportCardGradeCycle alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Schools @@ -329,29 +331,8 @@ defmodule Lanttern.Reporting do """ @spec update_strands_reports_positions([integer()]) :: :ok | {:error, String.t()} - def update_strands_reports_positions(strands_reports_ids) do - strands_reports_ids - |> Enum.with_index() - |> Enum.reduce( - Ecto.Multi.new(), - fn {id, i}, multi -> - multi - |> Ecto.Multi.update_all( - "update-#{id}", - from( - sr in StrandReport, - where: sr.id == ^id - ), - set: [position: i] - ) - end - ) - |> Repo.transaction() - |> case do - {:ok, _} -> :ok - _ -> {:error, gettext("Something went wrong")} - end - end + def update_strands_reports_positions(strands_reports_ids), + do: Utils.update_positions(StrandReport, strands_reports_ids) @doc """ Deletes a strand_report. @@ -601,4 +582,140 @@ defmodule Lanttern.Reporting do strand_reports |> Enum.map(&{&1, Map.get(ast_entries_map, &1.id, [])}) end + + @doc """ + Returns the list of report card grades subjects. + + Results are ordered by position and preloaded subjects. + + ## Examples + + iex> list_report_card_grades_subjects(1) + [%ReportCardGradeSubject{}, ...] + + """ + @spec list_report_card_grades_subjects(report_card_id :: integer()) :: [ + ReportCardGradeSubject.t() + ] + + def list_report_card_grades_subjects(report_card_id) do + from(rcgs in ReportCardGradeSubject, + order_by: rcgs.position, + join: s in assoc(rcgs, :subject), + preload: [subject: s], + where: rcgs.report_card_id == ^report_card_id + ) + |> Repo.all() + end + + @doc """ + Add a subject to a report card grades section. + + ## Examples + + iex> add_subject_to_report_card_grades(%{field: value}) + {:ok, %ReportCardGradeSubject{}} + + iex> add_subject_to_report_card_grades(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + + @spec add_subject_to_report_card_grades(map()) :: + {:ok, ReportCardGradeSubject.t()} | {:error, Ecto.Changeset.t()} + def add_subject_to_report_card_grades(attrs \\ %{}) do + %ReportCardGradeSubject{} + |> ReportCardGradeSubject.changeset(attrs) + |> set_report_card_grade_subject_position() + |> Repo.insert() + end + + # skip if not valid + defp set_report_card_grade_subject_position(%Ecto.Changeset{valid?: false} = changeset), + do: changeset + + # skip if changeset already has position change + defp set_report_card_grade_subject_position( + %Ecto.Changeset{changes: %{position: _position}} = changeset + ), + do: changeset + + defp set_report_card_grade_subject_position(%Ecto.Changeset{} = changeset) do + report_card_id = + Ecto.Changeset.get_field(changeset, :report_card_id) + + position = + from( + rcgs in ReportCardGradeSubject, + where: rcgs.report_card_id == ^report_card_id, + select: rcgs.position, + order_by: [desc: rcgs.position], + limit: 1 + ) + |> Repo.one() + |> case do + nil -> 0 + pos -> pos + 1 + end + + changeset + |> Ecto.Changeset.put_change(:position, position) + end + + @doc """ + Update report card grades subjects positions based on ids list order. + + ## Examples + + iex> update_report_card_grades_subjects_positions([3, 2, 1]) + :ok + + """ + @spec update_report_card_grades_subjects_positions([integer()]) :: :ok | {:error, String.t()} + def update_report_card_grades_subjects_positions(report_card_grades_subjects_ids), + do: Utils.update_positions(ReportCardGradeSubject, report_card_grades_subjects_ids) + + @doc """ + Returns the list of report card grades cycles. + + Results are ordered asc by cycle `end_at` and desc by cycle `start_at`, and have preloaded school cycles. + + ## Examples + + iex> list_report_card_grades_cycles(1) + [%ReportCardGradeCycle{}, ...] + + """ + @spec list_report_card_grades_cycles(report_card_id :: integer()) :: [ + ReportCardGradeCycle.t() + ] + + def list_report_card_grades_cycles(report_card_id) do + from(rcgc in ReportCardGradeCycle, + join: sc in assoc(rcgc, :school_cycle), + preload: [school_cycle: sc], + where: rcgc.report_card_id == ^report_card_id, + order_by: [asc: sc.end_at, desc: sc.start_at] + ) + |> Repo.all() + end + + @doc """ + Add a cycle to a report card grades section. + + ## Examples + + iex> add_cycle_to_report_card_grades(%{field: value}) + {:ok, %ReportCardGradeCycle{}} + + iex> add_cycle_to_report_card_grades(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + + @spec add_cycle_to_report_card_grades(map()) :: + {:ok, ReportCardGradeCycle.t()} | {:error, Ecto.Changeset.t()} + def add_cycle_to_report_card_grades(attrs \\ %{}) do + %ReportCardGradeCycle{} + |> ReportCardGradeCycle.changeset(attrs) + |> Repo.insert() + end end diff --git a/lib/lanttern/reporting/report_card_grade_cycle.ex b/lib/lanttern/reporting/report_card_grade_cycle.ex new file mode 100644 index 00000000..16b478f7 --- /dev/null +++ b/lib/lanttern/reporting/report_card_grade_cycle.ex @@ -0,0 +1,18 @@ +defmodule Lanttern.Reporting.ReportCardGradeCycle do + use Ecto.Schema + import Ecto.Changeset + + schema "report_card_grades_cycles" do + belongs_to :school_cycle, Lanttern.Schools.Cycle + belongs_to :report_card, Lanttern.Reporting.ReportCard + + timestamps() + end + + @doc false + def changeset(report_card_grade_cycle, attrs) do + report_card_grade_cycle + |> cast(attrs, [:school_cycle_id, :report_card_id]) + |> validate_required([:school_cycle_id, :report_card_id]) + end +end diff --git a/lib/lanttern/reporting/report_card_grade_subject.ex b/lib/lanttern/reporting/report_card_grade_subject.ex new file mode 100644 index 00000000..5dbd35f4 --- /dev/null +++ b/lib/lanttern/reporting/report_card_grade_subject.ex @@ -0,0 +1,20 @@ +defmodule Lanttern.Reporting.ReportCardGradeSubject do + use Ecto.Schema + import Ecto.Changeset + + schema "report_card_grades_subjects" do + field :position, :integer, default: 0 + + belongs_to :subject, Lanttern.Taxonomy.Subject + belongs_to :report_card, Lanttern.Reporting.ReportCard + + timestamps() + end + + @doc false + def changeset(report_card_grade_subject, attrs) do + report_card_grade_subject + |> cast(attrs, [:position, :subject_id, :report_card_id]) + |> validate_required([:subject_id, :report_card_id]) + end +end diff --git a/lib/lanttern/utils.ex b/lib/lanttern/utils.ex index 4c3f3360..d838de6f 100644 --- a/lib/lanttern/utils.ex +++ b/lib/lanttern/utils.ex @@ -3,6 +3,11 @@ defmodule Lanttern.Utils do Collection of utils functions. """ + import Ecto.Query, warn: false + alias Lanttern.Repo + + import LantternWeb.Gettext + @doc """ Swaps two items in a list, based on the given indexes. @@ -16,4 +21,38 @@ defmodule Lanttern.Utils do |> List.replace_at(i1, e2) |> List.replace_at(i2, e1) end + + @doc """ + Update schema positions based on ids list order. + + ## Examples + + iex> update_positions(queryable, [3, 2, 1]) + :ok + + """ + @spec update_positions(Ecto.Queryable.t(), [integer()]) :: :ok | {:error, String.t()} + def update_positions(queryable, ids) do + ids + |> Enum.with_index() + |> Enum.reduce( + Ecto.Multi.new(), + fn {id, i}, multi -> + multi + |> Ecto.Multi.update_all( + "update-#{id}", + from( + q in queryable, + where: q.id == ^id + ), + set: [position: i] + ) + end + ) + |> Repo.transaction() + |> case do + {:ok, _} -> :ok + _ -> {:error, gettext("Something went wrong")} + end + end end diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex new file mode 100644 index 00000000..1c23374b --- /dev/null +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -0,0 +1,49 @@ +defmodule LantternWeb.ReportCardLive.GradesComponent do + use LantternWeb, :live_component + + alias Lanttern.Schools + alias Lanttern.Taxonomy + + @impl true + def render(assigns) do + ~H""" +
+
+

+ <%= gettext("Grades report grid") %> +

+

+ <%= gettext("Select subjects and cycles to build the grades report grid.") %> +

+
+
+ <.badge :for={subject <- @subjects}> + <%= subject.name %> + +
+
+ <.badge :for={cycle <- @cycles}> + <%= cycle.name %> + +
+
+
+ TBD +
+
+
+ """ + end + + # lifecycle + + @impl true + def mount(socket) do + socket = + socket + |> assign(:subjects, Taxonomy.list_subjects()) + |> assign(:cycles, Schools.list_cycles(order_by: [asc: :end_at, desc: :start_at])) + + {:ok, socket} + end +end diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex index 6d89cf0a..1c9215ac 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex @@ -6,13 +6,15 @@ defmodule LantternWeb.ReportCardLive do # page components alias __MODULE__.StudentsComponent alias __MODULE__.StrandsReportsComponent + alias __MODULE__.GradesComponent # shared components alias LantternWeb.Reporting.ReportCardFormComponent @tabs %{ "students" => :students, - "strands" => :strands + "strands" => :strands, + "grades" => :grades } # lifecycle diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex index 79aa6eb2..ec16deae 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex @@ -28,6 +28,12 @@ > <%= gettext("Strands") %> + <:tab + patch={~p"/report_cards/#{@report_card}?#{%{tab: "grades"}}"} + is_current={@current_tab == :grades && "true"} + > + <%= gettext("Grades") %> + <.menu_button id={@report_card.id}> <:menu_items> @@ -50,16 +56,15 @@
-
- <.live_component - module={StudentsComponent} - id="report-card-students" - report_card={@report_card} - current_user={@current_user} - params={@params} - /> -
-
+ <.live_component + :if={@current_tab == :students} + module={StudentsComponent} + id="report-card-students" + report_card={@report_card} + current_user={@current_user} + params={@params} + /> +
<.markdown text={@report_card.description} /> <.live_component module={StrandsReportsComponent} @@ -68,6 +73,14 @@ params={@params} />
+ <.live_component + :if={@current_tab == :grades} + module={GradesComponent} + id="report-card-grades" + report_card={@report_card} + current_user={@current_user} + params={@params} + />
<.slide_over :if={@live_action == :edit} diff --git a/lib/lanttern_web/live/shared/menu_component.ex b/lib/lanttern_web/live/shared/menu_component.ex index c022337c..687f8934 100644 --- a/lib/lanttern_web/live/shared/menu_component.ex +++ b/lib/lanttern_web/live/shared/menu_component.ex @@ -41,7 +41,7 @@ defmodule LantternWeb.MenuComponent do
- lanttern + Lanttern
diff --git a/priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs b/priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs new file mode 100644 index 00000000..590887a6 --- /dev/null +++ b/priv/repo/migrations/20240229121217_create_report_card_grades_subjects.exs @@ -0,0 +1,16 @@ +defmodule Lanttern.Repo.Migrations.CreateReportCardGradesSubjects do + use Ecto.Migration + + def change do + create table(:report_card_grades_subjects) do + add :position, :integer, null: false, default: 0 + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_subjects, [:subject_id]) + create unique_index(:report_card_grades_subjects, [:report_card_id, :subject_id]) + end +end diff --git a/priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs b/priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs new file mode 100644 index 00000000..b40c9ac3 --- /dev/null +++ b/priv/repo/migrations/20240229121906_create_report_card_grades_cycles.exs @@ -0,0 +1,15 @@ +defmodule Lanttern.Repo.Migrations.CreateReportCardGradesCycles do + use Ecto.Migration + + def change do + create table(:report_card_grades_cycles) do + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_cycles, [:school_cycle_id]) + create unique_index(:report_card_grades_cycles, [:report_card_id, :school_cycle_id]) + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 9a600543..cd4baf3a 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -576,4 +576,177 @@ defmodule Lanttern.ReportingTest do assert expected_entry_2_1.score == 5 end end + + describe "report card grade subject and cycle" do + alias Lanttern.Reporting.ReportCardGradeSubject + alias Lanttern.Reporting.ReportCardGradeCycle + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures + + test "list_report_card_grades_subjects/1 returns all report card grades subjects ordered by position and subjects preloaded" do + report_card = report_card_fixture() + subject_1 = TaxonomyFixtures.subject_fixture() + subject_2 = TaxonomyFixtures.subject_fixture() + + report_card_grade_subject_1 = + report_card_grade_subject_fixture(%{ + report_card_id: report_card.id, + subject_id: subject_1.id + }) + + report_card_grade_subject_2 = + report_card_grade_subject_fixture(%{ + report_card_id: report_card.id, + subject_id: subject_2.id + }) + + assert [expected_rcgs_1, expected_rcgs_2] = + Reporting.list_report_card_grades_subjects(report_card.id) + + assert expected_rcgs_1.id == report_card_grade_subject_1.id + assert expected_rcgs_1.subject.id == subject_1.id + + assert expected_rcgs_2.id == report_card_grade_subject_2.id + assert expected_rcgs_2.subject.id == subject_2.id + end + + test "add_subject_to_report_card_grades/1 with valid data creates a report card grade subject" do + report_card = report_card_fixture() + subject = TaxonomyFixtures.subject_fixture() + + valid_attrs = %{ + report_card_id: report_card.id, + subject_id: subject.id + } + + assert {:ok, %ReportCardGradeSubject{} = report_card_grade_subject} = + Reporting.add_subject_to_report_card_grades(valid_attrs) + + assert report_card_grade_subject.report_card_id == report_card.id + assert report_card_grade_subject.subject_id == subject.id + assert report_card_grade_subject.position == 0 + + # insert one more report card grade subject in a different report card to test position auto increment scope + + # extra fixture in different report card + report_card_grade_subject_fixture() + + subject = TaxonomyFixtures.subject_fixture() + + valid_attrs = %{ + report_card_id: report_card.id, + subject_id: subject.id + } + + assert {:ok, %ReportCardGradeSubject{} = report_card_grade_subject} = + Reporting.add_subject_to_report_card_grades(valid_attrs) + + assert report_card_grade_subject.report_card_id == report_card.id + assert report_card_grade_subject.subject_id == subject.id + assert report_card_grade_subject.position == 1 + end + + test "update_report_card_grades_subjects_positions/1 update report card grades subjects positions based on list order" do + report_card = report_card_fixture() + + report_card_grade_subject_1 = + report_card_grade_subject_fixture(%{report_card_id: report_card.id}) + + report_card_grade_subject_2 = + report_card_grade_subject_fixture(%{report_card_id: report_card.id}) + + report_card_grade_subject_3 = + report_card_grade_subject_fixture(%{report_card_id: report_card.id}) + + report_card_grade_subject_4 = + report_card_grade_subject_fixture(%{report_card_id: report_card.id}) + + sorted_report_card_grades_subjects_ids = + [ + report_card_grade_subject_2.id, + report_card_grade_subject_3.id, + report_card_grade_subject_1.id, + report_card_grade_subject_4.id + ] + + assert :ok == + Reporting.update_report_card_grades_subjects_positions( + sorted_report_card_grades_subjects_ids + ) + + assert [ + expected_rcgs_2, + expected_rcgs_3, + expected_rcgs_1, + expected_rcgs_4 + ] = + Reporting.list_report_card_grades_subjects(report_card.id) + + assert expected_rcgs_1.id == report_card_grade_subject_1.id + assert expected_rcgs_2.id == report_card_grade_subject_2.id + assert expected_rcgs_3.id == report_card_grade_subject_3.id + assert expected_rcgs_4.id == report_card_grade_subject_4.id + end + + test "list_report_card_grades_cycles/1 returns all report card grades cycles ordered by dates and preloaded cycles" do + report_card = report_card_fixture() + + cycle_2023 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2023-01-01], end_at: ~D[2023-12-31]}) + + cycle_2024_q4 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-09-01], end_at: ~D[2024-12-31]}) + + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + + report_card_grade_cycle_2023 = + report_card_grade_cycle_fixture(%{ + report_card_id: report_card.id, + school_cycle_id: cycle_2023.id + }) + + report_card_grade_cycle_2024_q4 = + report_card_grade_cycle_fixture(%{ + report_card_id: report_card.id, + school_cycle_id: cycle_2024_q4.id + }) + + report_card_grade_cycle_2024 = + report_card_grade_cycle_fixture(%{ + report_card_id: report_card.id, + school_cycle_id: cycle_2024.id + }) + + assert [expected_rcgc_2023, expected_rcgc_2024_q4, expected_rcgc_2024] = + Reporting.list_report_card_grades_cycles(report_card.id) + + assert expected_rcgc_2023.id == report_card_grade_cycle_2023.id + assert expected_rcgc_2023.school_cycle.id == cycle_2023.id + + assert expected_rcgc_2024_q4.id == report_card_grade_cycle_2024_q4.id + assert expected_rcgc_2024_q4.school_cycle.id == cycle_2024_q4.id + + assert expected_rcgc_2024.id == report_card_grade_cycle_2024.id + assert expected_rcgc_2024.school_cycle.id == cycle_2024.id + end + + test "add_cycle_to_report_card_grades/1 with valid data creates a report card grade cycle" do + report_card = report_card_fixture() + school_cycle = SchoolsFixtures.cycle_fixture() + + valid_attrs = %{ + report_card_id: report_card.id, + school_cycle_id: school_cycle.id + } + + assert {:ok, %ReportCardGradeCycle{} = report_card_grade_cycle} = + Reporting.add_cycle_to_report_card_grades(valid_attrs) + + assert report_card_grade_cycle.report_card_id == report_card.id + assert report_card_grade_cycle.school_cycle_id == school_cycle.id + end + end end diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index 43847121..77c5e235 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -77,4 +77,40 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_student_id(_attrs), do: Lanttern.SchoolsFixtures.student_fixture().id + + @doc """ + Generate a report_card_grade_subject. + """ + def report_card_grade_subject_fixture(attrs \\ %{}) do + {:ok, report_card_grade_subject} = + attrs + |> Enum.into(%{ + report_card_id: maybe_gen_report_card_id(attrs), + subject_id: maybe_gen_subject_id(attrs) + }) + |> Lanttern.Reporting.add_subject_to_report_card_grades() + + report_card_grade_subject + end + + defp maybe_gen_subject_id(%{subject_id: subject_id} = _attrs), + do: subject_id + + defp maybe_gen_subject_id(_attrs), + do: Lanttern.TaxonomyFixtures.subject_fixture().id + + @doc """ + Generate a report_card_grade_cycle. + """ + def report_card_grade_cycle_fixture(attrs \\ %{}) do + {:ok, report_card_grade_cycle} = + attrs + |> Enum.into(%{ + report_card_id: maybe_gen_report_card_id(attrs), + school_cycle_id: maybe_gen_school_cycle_id(attrs) + }) + |> Lanttern.Reporting.add_cycle_to_report_card_grades() + + report_card_grade_cycle + end end From c89339a0bd7e1b3750288c10b8f443ba6177a4e4 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 29 Feb 2024 15:03:12 -0300 Subject: [PATCH 02/15] chore: added support to manage report card grades subjects - added `delete_report_card_grade_subject/1` and `delete_report_card_grade_cycle/1` to `Reporting` module - added "primary" theme to badges components --- lib/lanttern/reporting.ex | 32 +++ .../reporting/report_card_grade_cycle.ex | 6 + .../reporting/report_card_grade_subject.ex | 6 + .../components/core_components.ex | 5 +- .../pages/report_cards/id/grades_component.ex | 259 +++++++++++++++++- test/lanttern/reporting_test.exs | 23 ++ 6 files changed, 324 insertions(+), 7 deletions(-) diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 4c526c3f..2c803749 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -674,6 +674,22 @@ defmodule Lanttern.Reporting do def update_report_card_grades_subjects_positions(report_card_grades_subjects_ids), do: Utils.update_positions(ReportCardGradeSubject, report_card_grades_subjects_ids) + @doc """ + Deletes a report card grade subject. + + ## Examples + + iex> delete_report_card_grade_subject(report_card_grade_subject) + {:ok, %ReportCardGradeSubject{}} + + iex> delete_report_card_grade_subject(report_card_grade_subject) + {:error, %Ecto.Changeset{}} + + """ + def delete_report_card_grade_subject(%ReportCardGradeSubject{} = report_card_grade_subject) do + Repo.delete(report_card_grade_subject) + end + @doc """ Returns the list of report card grades cycles. @@ -718,4 +734,20 @@ defmodule Lanttern.Reporting do |> ReportCardGradeCycle.changeset(attrs) |> Repo.insert() end + + @doc """ + Deletes a report card grade cycle. + + ## Examples + + iex> delete_report_card_grade_cycle(report_card_grade_cycle) + {:ok, %ReportCardGradeCycle{}} + + iex> delete_report_card_grade_cycle(report_card_grade_cycle) + {:error, %Ecto.Changeset{}} + + """ + def delete_report_card_grade_cycle(%ReportCardGradeCycle{} = report_card_grade_cycle) do + Repo.delete(report_card_grade_cycle) + end end diff --git a/lib/lanttern/reporting/report_card_grade_cycle.ex b/lib/lanttern/reporting/report_card_grade_cycle.ex index 16b478f7..fe5cbd4e 100644 --- a/lib/lanttern/reporting/report_card_grade_cycle.ex +++ b/lib/lanttern/reporting/report_card_grade_cycle.ex @@ -2,6 +2,8 @@ defmodule Lanttern.Reporting.ReportCardGradeCycle do use Ecto.Schema import Ecto.Changeset + import LantternWeb.Gettext + schema "report_card_grades_cycles" do belongs_to :school_cycle, Lanttern.Schools.Cycle belongs_to :report_card, Lanttern.Reporting.ReportCard @@ -14,5 +16,9 @@ defmodule Lanttern.Reporting.ReportCardGradeCycle do report_card_grade_cycle |> cast(attrs, [:school_cycle_id, :report_card_id]) |> validate_required([:school_cycle_id, :report_card_id]) + |> unique_constraint(:school_cycle_id, + name: "report_card_grades_cycles_report_card_id_school_cycle_id_index", + message: gettext("Cycle already added to this report card grades report") + ) end end diff --git a/lib/lanttern/reporting/report_card_grade_subject.ex b/lib/lanttern/reporting/report_card_grade_subject.ex index 5dbd35f4..17ad584d 100644 --- a/lib/lanttern/reporting/report_card_grade_subject.ex +++ b/lib/lanttern/reporting/report_card_grade_subject.ex @@ -2,6 +2,8 @@ defmodule Lanttern.Reporting.ReportCardGradeSubject do use Ecto.Schema import Ecto.Changeset + import LantternWeb.Gettext + schema "report_card_grades_subjects" do field :position, :integer, default: 0 @@ -16,5 +18,9 @@ defmodule Lanttern.Reporting.ReportCardGradeSubject do report_card_grade_subject |> cast(attrs, [:position, :subject_id, :report_card_id]) |> validate_required([:subject_id, :report_card_id]) + |> unique_constraint(:subject_id, + name: "report_card_grades_subjects_report_card_id_subject_id_index", + message: gettext("Subject already added to this report card grades report") + ) end end diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index 68bcf614..bf930154 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -83,6 +83,7 @@ defmodule LantternWeb.CoreComponents do @badge_themes %{ "default" => "bg-ltrn-lightest text-ltrn-dark", + "primary" => "bg-ltrn-primary text-white", "secondary" => "bg-ltrn-secondary text-white", "cyan" => "bg-ltrn-mesh-cyan text-ltrn-dark", "dark" => "bg-ltrn-dark text-ltrn-lighter", @@ -91,6 +92,7 @@ defmodule LantternWeb.CoreComponents do @badge_themes_hover %{ "default" => "hover:bg-ltrn-lightest/50", + "primary" => "hover:bg-ltrn-primary/50", "secondary" => "hover:bg-ltrn-secondary/50", "cyan" => "hover:bg-ltrn-mesh-cyan/50", "dark" => "hover:bg-ltrn-dark/50" @@ -102,6 +104,7 @@ defmodule LantternWeb.CoreComponents do @badge_icon_themes %{ "default" => "text-ltrn-subtle", + "primary" => "text-ltrn-mesh-cyan", "secondary" => "text-white", "cyan" => "text-ltrn-subtle", "dark" => "text-ltrn-lighter" @@ -959,7 +962,7 @@ defmodule LantternWeb.CoreComponents do def sortable_card(assigns) do ~H""" -
+
<%= render_slot(@inner_block) %>
diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex index 1c23374b..e7296972 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -1,9 +1,12 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do use LantternWeb, :live_component + alias Lanttern.Reporting alias Lanttern.Schools alias Lanttern.Taxonomy + import Lanttern.Utils, only: [swap: 3] + @impl true def render(assigns) do ~H""" @@ -17,20 +20,125 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do

- <.badge :for={subject <- @subjects}> + <.badge_button + :for={subject <- @subjects} + theme={if subject.id in @selected_subjects_ids, do: "primary", else: "default"} + icon_name={ + if subject.id in @selected_subjects_ids, do: "hero-check-mini", else: "hero-plus-mini" + } + phx-click={JS.push("toggle_subject", value: %{"id" => subject.id}, target: @myself)} + > <%= subject.name %> - +
- <.badge :for={cycle <- @cycles}> + <.badge_button + :for={cycle <- @cycles} + theme={if cycle.id in @selected_cycles_ids, do: "primary", else: "default"} + icon_name={ + if cycle.id in @selected_cycles_ids, do: "hero-check-mini", else: "hero-plus-mini" + } + phx-click={JS.push("toggle_cycle", value: %{"id" => cycle.id}, target: @myself)} + > <%= cycle.name %> - +
-
- TBD +
+ <.button + :if={@has_grades_subjects_order_change} + theme="ghost" + phx-click="save_grade_report_subject_order_changes" + phx-target={@myself} + class="w-full mb-4 justify-center" + > + <%= gettext("Save grade report subjects order changes") %> + + <.grades_grid + sortable_grades_subjects={@sortable_grades_subjects} + grades_cycles={@grades_cycles} + myself={@myself} + /> +
+
+
+ """ + end + + # function components + + attr :sortable_grades_subjects, :list, required: true + attr :grades_cycles, :list, required: true + attr :myself, :any, required: true + + def grades_grid(assigns) do + grid_template_columns_style = + case length(assigns.grades_cycles) do + n when n > 1 -> + "grid-template-columns: 160px repeat(#{n} minmax(0, 1fr))" + + _ -> + "grid-template-columns: 160px minmax(0, 1fr)" + end + + grid_column_style = + case length(assigns.grades_cycles) do + 0 -> "grid-column: span 2 / span 2" + n -> "grid-column: span #{n + 1} / span #{n + 1}" + end + + assigns = + assigns + |> assign(:grid_template_columns_style, grid_template_columns_style) + |> assign(:grid_column_style, grid_column_style) + |> assign(:has_subjects, length(assigns.sortable_grades_subjects) > 0) + + ~H""" +
+
Grades
+
Add cycles
+ <%= if @has_subjects do %> +
+ <.sortable_card + is_move_up_disabled={i == 0} + on_move_up={ + JS.push("swap_grades_subjects_position", + value: %{from: i, to: i - 1}, + target: @myself + ) + } + is_move_down_disabled={i + 1 == length(@sortable_grades_subjects)} + on_move_down={ + JS.push("swap_grades_subjects_position", + value: %{from: i, to: i + 1}, + target: @myself + ) + } + > + <%= grade_subject.subject.name %> + +
TBD
+
+ <% else %> +
+
Add subjects
+
TBD
+ <% end %> + <%!--

Subjects

+
+ <%= grade_subject.subject.name %>
+ +

Cycles

+
+ <%= grade_cycle.school_cycle.name %> +
--%>
""" end @@ -43,7 +151,146 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do socket |> assign(:subjects, Taxonomy.list_subjects()) |> assign(:cycles, Schools.list_cycles(order_by: [asc: :end_at, desc: :start_at])) + |> assign(:has_grades_subjects_order_change, false) + + {:ok, socket} + end + + @impl true + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:sortable_grades_subjects, fn %{report_card: report_card} -> + Reporting.list_report_card_grades_subjects(report_card.id) + |> Enum.with_index() + end) + |> assign_new(:selected_subjects_ids, fn %{ + sortable_grades_subjects: + sortable_grades_subjects + } -> + Enum.map(sortable_grades_subjects, fn {grade_subject, _i} -> grade_subject.subject.id end) + end) + |> assign_new(:grades_cycles, fn %{report_card: report_card} -> + Reporting.list_report_card_grades_cycles(report_card.id) + end) + |> assign_new(:selected_cycles_ids, fn %{grades_cycles: grades_cycles} -> + Enum.map(grades_cycles, & &1.school_cycle.id) + end) {:ok, socket} end + + # event handlers + + @impl true + def handle_event("toggle_subject", %{"id" => subject_id}, socket) do + socket = + case subject_id in socket.assigns.selected_subjects_ids do + true -> remove_subject_grade_report(socket, subject_id) + false -> add_subject_grade_report(socket, subject_id) + end + + {:noreply, socket} + end + + def handle_event("toggle_cycle", %{"id" => cycle_id}, socket) do + socket = + case cycle_id in socket.assigns.selected_cycles_ids do + true -> remove_cycle_grade_report(socket, cycle_id) + false -> add_cycle_grade_report(socket, cycle_id) + end + + {:noreply, socket} + end + + def handle_event("swap_grades_subjects_position", %{"from" => i, "to" => j}, socket) do + sortable_grades_subjects = + socket.assigns.sortable_grades_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject end) + |> swap(i, j) + |> Enum.with_index() + + socket = + socket + |> assign(:has_grades_subjects_order_change, true) + |> assign(:sortable_grades_subjects, sortable_grades_subjects) + + {:noreply, socket} + end + + def handle_event("save_grade_report_subject_order_changes", _, socket) do + socket.assigns.sortable_grades_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject.id end) + |> Reporting.update_report_card_grades_subjects_positions() + |> case do + :ok -> + socket = + socket + |> assign(:has_grades_subjects_order_change, false) + |> put_flash(:info, gettext("Order changes saved succesfully!")) + + {:noreply, socket} + + {:error, msg} -> + {:noreply, put_flash(socket, :error, msg)} + end + end + + defp add_subject_grade_report(socket, subject_id) do + %{ + report_card_id: socket.assigns.report_card.id, + subject_id: subject_id + } + |> Reporting.add_subject_to_report_card_grades() + |> case do + {:ok, _report_card_grade_subject} -> + push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error adding subject to report card grades")) + end + end + + defp remove_subject_grade_report(socket, subject_id) do + socket.assigns.sortable_grades_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject end) + |> Enum.find(&(&1.subject_id == subject_id)) + |> Reporting.delete_report_card_grade_subject() + |> case do + {:ok, _report_card_grade_subject} -> + push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error removing subject from report card grades")) + end + end + + defp add_cycle_grade_report(socket, cycle_id) do + %{ + report_card_id: socket.assigns.report_card.id, + school_cycle_id: cycle_id + } + |> Reporting.add_cycle_to_report_card_grades() + |> case do + {:ok, _report_card_grade_cycle} -> + push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error adding cycle to report card grades")) + end + end + + defp remove_cycle_grade_report(socket, cycle_id) do + socket.assigns.grades_cycles + |> Enum.find(&(&1.school_cycle_id == cycle_id)) + |> Reporting.delete_report_card_grade_cycle() + |> case do + {:ok, _report_card_grade_cycle} -> + push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error removing cycle from report card grades")) + end + end end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index cd4baf3a..684f59ab 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -1,6 +1,7 @@ defmodule Lanttern.ReportingTest do use Lanttern.DataCase + alias Lanttern.Repo alias Lanttern.Reporting describe "report_cards" do @@ -690,6 +691,17 @@ defmodule Lanttern.ReportingTest do assert expected_rcgs_4.id == report_card_grade_subject_4.id end + test "delete_report_card_grade_subject/1 deletes the report_card_grade_subject" do + report_card_grade_subject = report_card_grade_subject_fixture() + + assert {:ok, %ReportCardGradeSubject{}} = + Reporting.delete_report_card_grade_subject(report_card_grade_subject) + + assert_raise Ecto.NoResultsError, fn -> + Repo.get!(ReportCardGradeSubject, report_card_grade_subject.id) + end + end + test "list_report_card_grades_cycles/1 returns all report card grades cycles ordered by dates and preloaded cycles" do report_card = report_card_fixture() @@ -748,5 +760,16 @@ defmodule Lanttern.ReportingTest do assert report_card_grade_cycle.report_card_id == report_card.id assert report_card_grade_cycle.school_cycle_id == school_cycle.id end + + test "delete_report_card_grade_cycle/1 deletes the report_card_grade_cycle" do + report_card_grade_cycle = report_card_grade_cycle_fixture() + + assert {:ok, %ReportCardGradeCycle{}} = + Reporting.delete_report_card_grade_cycle(report_card_grade_cycle) + + assert_raise Ecto.NoResultsError, fn -> + Repo.get!(ReportCardGradeCycle, report_card_grade_cycle.id) + end + end end end From 0691919e165a787c961964c6bc01868be065e5b3 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 29 Feb 2024 18:18:27 -0300 Subject: [PATCH 03/15] feat: added support to manage report card grades cycles in report card view --- .../pages/report_cards/id/grades_component.ex | 68 +++++++++++-------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex index e7296972..67d6e83f 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -45,15 +45,6 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do
- <.button - :if={@has_grades_subjects_order_change} - theme="ghost" - phx-click="save_grade_report_subject_order_changes" - phx-target={@myself} - class="w-full mb-4 justify-center" - > - <%= gettext("Save grade report subjects order changes") %> - <.grades_grid sortable_grades_subjects={@sortable_grades_subjects} grades_cycles={@grades_cycles} @@ -75,7 +66,7 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do grid_template_columns_style = case length(assigns.grades_cycles) do n when n > 1 -> - "grid-template-columns: 160px repeat(#{n} minmax(0, 1fr))" + "grid-template-columns: 160px repeat(#{n}, minmax(0, 1fr))" _ -> "grid-template-columns: 160px minmax(0, 1fr)" @@ -92,11 +83,24 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do |> assign(:grid_template_columns_style, grid_template_columns_style) |> assign(:grid_column_style, grid_column_style) |> assign(:has_subjects, length(assigns.sortable_grades_subjects) > 0) + |> assign(:has_cycles, length(assigns.grades_cycles) > 0) ~H""" -
-
Grades
-
Add cycles
+
+
+ <%= if @has_cycles do %> +
+ <%= grade_cycle.school_cycle.name %> +
+ <% else %> +
+ <%= gettext("Use the buttons above to add cycles to this grid") %> +
+ <% end %> <%= if @has_subjects do %>
<%= grade_subject.subject.name %> -
TBD
+ <%= if @has_cycles do %> +
+
+ <% else %> +
+ <% end %>
<% else %>
-
Add subjects
-
TBD
+
+ <%= gettext("Use the buttons above to add subjects to this grid") %> +
+ <%= if @has_cycles do %> +
+
+ <% else %> +
+ <% end %>
<% end %> <%!--

Subjects

@@ -211,24 +233,14 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do |> swap(i, j) |> Enum.with_index() - socket = - socket - |> assign(:has_grades_subjects_order_change, true) - |> assign(:sortable_grades_subjects, sortable_grades_subjects) - - {:noreply, socket} - end - - def handle_event("save_grade_report_subject_order_changes", _, socket) do - socket.assigns.sortable_grades_subjects + sortable_grades_subjects |> Enum.map(fn {grade_subject, _i} -> grade_subject.id end) |> Reporting.update_report_card_grades_subjects_positions() |> case do :ok -> socket = socket - |> assign(:has_grades_subjects_order_change, false) - |> put_flash(:info, gettext("Order changes saved succesfully!")) + |> assign(:sortable_grades_subjects, sortable_grades_subjects) {:noreply, socket} From 0a22adaaf2357243dbf92e0f058b2390cb0b6e44 Mon Sep 17 00:00:00 2001 From: endoooo Date: Tue, 5 Mar 2024 17:32:47 -0300 Subject: [PATCH 04/15] chore: adjusted subject and year picker active style - adjusted text color in primary badge theme --- lib/lanttern_web/components/core_components.ex | 4 ++-- .../live/shared/taxonomy/subject_picker_component.ex | 2 +- .../live/shared/taxonomy/year_picker_component.ex | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index 283712bd..d9ac437d 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -83,7 +83,7 @@ defmodule LantternWeb.CoreComponents do @badge_themes %{ "default" => "bg-ltrn-lightest text-ltrn-dark", - "primary" => "bg-ltrn-primary text-white", + "primary" => "bg-ltrn-primary text-ltrn-dark", "secondary" => "bg-ltrn-secondary text-white", "cyan" => "bg-ltrn-mesh-cyan text-ltrn-dark", "dark" => "bg-ltrn-dark text-ltrn-lighter", @@ -104,7 +104,7 @@ defmodule LantternWeb.CoreComponents do @badge_icon_themes %{ "default" => "text-ltrn-subtle", - "primary" => "text-ltrn-mesh-cyan", + "primary" => "text-ltrn-dark", "secondary" => "text-white", "cyan" => "text-ltrn-subtle", "dark" => "text-ltrn-lighter" diff --git a/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex b/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex index 090115b9..4e143038 100644 --- a/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex +++ b/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex @@ -9,7 +9,7 @@ defmodule LantternWeb.Taxonomy.SubjectPickerComponent do
<.badge_button :for={subject <- @subjects} - theme={if subject.id in @selected_ids, do: "cyan", else: "default"} + theme={if subject.id in @selected_ids, do: "primary", else: "default"} icon_name={if subject.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} phx-click={@on_select.(subject.id)} > diff --git a/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex b/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex index 66929e8d..4357ade7 100644 --- a/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex +++ b/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex @@ -9,7 +9,7 @@ defmodule LantternWeb.Taxonomy.YearPickerComponent do
<.badge_button :for={year <- @years} - theme={if year.id in @selected_ids, do: "cyan", else: "default"} + theme={if year.id in @selected_ids, do: "primary", else: "default"} icon_name={if year.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} phx-click={@on_select.(year.id)} > From 78d085f18bfb3393f4a9ad3b0161fac57e8f300a Mon Sep 17 00:00:00 2001 From: endoooo Date: Tue, 5 Mar 2024 18:34:34 -0300 Subject: [PATCH 05/15] chore: better strand filter UX - created `LantternWeb.PersonalizationHelpers` with `assign_user_filters/3` function, reusing the logic implemented in `LantternWeb.CurriculumComponentLive` --- .../helpers/personalization_helpers.ex | 80 ++++++ .../component/id/curriculum_component_live.ex | 25 +- .../live/pages/strands/strands_live.ex | 235 +++++++----------- .../live/pages/strands/strands_live.html.heex | 62 ++--- 4 files changed, 200 insertions(+), 202 deletions(-) create mode 100644 lib/lanttern_web/helpers/personalization_helpers.ex diff --git a/lib/lanttern_web/helpers/personalization_helpers.ex b/lib/lanttern_web/helpers/personalization_helpers.ex new file mode 100644 index 00000000..572c35cf --- /dev/null +++ b/lib/lanttern_web/helpers/personalization_helpers.ex @@ -0,0 +1,80 @@ +defmodule LantternWeb.PersonalizationHelpers do + import Phoenix.Component, only: [assign: 3] + + alias Lanttern.Personalization + + alias Lanttern.Identity.User + alias Lanttern.Taxonomy + + import LantternWeb.LocalizationHelpers + + @doc """ + Handle filter related assigns in socket. + + ## Filter types and assigns + + ### `:subjects`'s assigns + + - :subjects + - :selected_subjects_ids + - :selected_subjects + + ### `:years`'s assigns + + - :years + - :selected_years_ids + - :selected_years + + ## Examples + + iex> assign_user_filters(socket, [:subjects], user) + socket + """ + @spec assign_user_filters(Phoenix.LiveView.Socket.t(), [atom()], User.t()) :: + Phoenix.LiveView.Socket.t() + def assign_user_filters(socket, filter_types, %User{} = current_user) do + current_filters = + case Personalization.get_profile_settings(current_user.current_profile_id) do + %{current_filters: current_filters} -> current_filters + _ -> %{} + end + + socket + |> assign_filter_type(current_filters, filter_types) + end + + defp assign_filter_type(socket, _current_filters, []), do: socket + + defp assign_filter_type(socket, current_filters, [:subjects | filter_types]) do + subjects = + Taxonomy.list_subjects() + |> translate_struct_list("taxonomy", :name, reorder: true) + + selected_subjects_ids = Map.get(current_filters, :subjects_ids) || [] + selected_subjects = Enum.filter(subjects, &(&1.id in selected_subjects_ids)) + + socket + |> assign(:subjects, subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:selected_subjects, selected_subjects) + |> assign_filter_type(current_filters, filter_types) + end + + defp assign_filter_type(socket, current_filters, [:years | filter_types]) do + years = + Taxonomy.list_years() + |> translate_struct_list("taxonomy") + + selected_years_ids = Map.get(current_filters, :years_ids) || [] + selected_years = Enum.filter(years, &(&1.id in selected_years_ids)) + + socket + |> assign(:years, years) + |> assign(:selected_years_ids, selected_years_ids) + |> assign(:selected_years, selected_years) + |> assign_filter_type(current_filters, filter_types) + end + + defp assign_filter_type(socket, current_filters, [_ | filter_types]), + do: assign_filter_type(socket, current_filters, filter_types) +end diff --git a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex index a1958a5e..4c278e6a 100644 --- a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex +++ b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex @@ -4,7 +4,8 @@ defmodule LantternWeb.CurriculumComponentLive do alias Lanttern.Curricula alias Lanttern.Curricula.CurriculumItem alias Lanttern.Personalization - alias Lanttern.Taxonomy + + import LantternWeb.PersonalizationHelpers # shared components alias LantternWeb.Curricula.CurriculumItemFormComponent @@ -13,29 +14,9 @@ defmodule LantternWeb.CurriculumComponentLive do @impl true def mount(_params, _session, socket) do - subjects = Taxonomy.list_subjects() - years = Taxonomy.list_years() - - current_filters = - case Personalization.get_profile_settings(socket.assigns.current_user.current_profile_id) do - %{current_filters: current_filters} -> current_filters - _ -> %{} - end - - selected_subjects_ids = Map.get(current_filters, :subjects_ids) || [] - selected_years_ids = Map.get(current_filters, :years_ids) || [] - - selected_subjects = Enum.filter(subjects, &(&1.id in selected_subjects_ids)) - selected_years = Enum.filter(years, &(&1.id in selected_years_ids)) - socket = socket - |> assign(:subjects, subjects) - |> assign(:years, years) - |> assign(:selected_subjects_ids, selected_subjects_ids) - |> assign(:selected_years_ids, selected_years_ids) - |> assign(:selected_subjects, selected_subjects) - |> assign(:selected_years, selected_years) + |> assign_user_filters([:subjects, :years], socket.assigns.current_user) |> assign(:show_subjects_filter, false) |> assign(:show_years_filter, false) diff --git a/lib/lanttern_web/live/pages/strands/strands_live.ex b/lib/lanttern_web/live/pages/strands/strands_live.ex index 32e9f91d..d4e9ef03 100644 --- a/lib/lanttern_web/live/pages/strands/strands_live.ex +++ b/lib/lanttern_web/live/pages/strands/strands_live.ex @@ -3,14 +3,17 @@ defmodule LantternWeb.StrandsLive do alias Lanttern.LearningContext alias Lanttern.LearningContext.Strand - alias Lanttern.Taxonomy - import LantternWeb.LocalizationHelpers + alias Lanttern.Personalization + + import LantternWeb.PersonalizationHelpers # live components alias LantternWeb.LearningContext.StrandFormComponent # shared components import LantternWeb.LearningContextComponents + alias LantternWeb.Taxonomy.SubjectPickerComponent + alias LantternWeb.Taxonomy.YearPickerComponent # function components @@ -75,45 +78,63 @@ defmodule LantternWeb.StrandsLive do @impl true def mount(_params, _session, socket) do - {:ok, - socket - |> assign(:is_creating_strand, false) - |> assign(:current_subjects, []) - |> assign(:current_years, [])} + socket = + socket + |> assign_user_filters([:subjects, :years], socket.assigns.current_user) + |> assign(:is_creating_strand, false) + |> stream_strands() + + {:ok, socket} end - @impl true - def handle_params(params, _url, socket) do - {:noreply, - socket - |> assign( - :subjects, - Taxonomy.list_subjects() |> translate_struct_list("taxonomy", :name, reorder: true) - ) - |> assign(:years, Taxonomy.list_years() |> translate_struct_list("taxonomy")) - # sync subjects_ids and years_ids filters with profile - |> handle_params_and_profile_filters_sync( - params, - [:subjects_ids, :years_ids], - &handle_assigns/2, - fn params -> ~p"/strands?#{params}" end - )} + defp stream_strands(socket) do + %{ + selected_subjects_ids: subjects_ids, + selected_years_ids: years_ids + } = + socket.assigns + + {strands, meta} = + LearningContext.list_strands( + preloads: [:subjects, :years], + after: socket.assigns[:end_cursor], + subjects_ids: subjects_ids, + years_ids: years_ids, + show_starred_for_profile_id: socket.assigns.current_user.current_profile.id + ) + + strands_count = length(strands) + + starred_strands = + LearningContext.list_starred_strands( + socket.assigns.current_user.current_profile.id, + preloads: [:subjects, :years], + subjects_ids: subjects_ids, + years_ids: years_ids + ) + + starred_strands_count = length(starred_strands) + + socket + |> stream(:strands, strands) + |> assign(:strands_count, strands_count) + |> assign(:end_cursor, meta.end_cursor) + |> assign(:has_next_page, meta.has_next_page?) + |> stream(:starred_strands, starred_strands) + |> assign(:starred_strands_count, starred_strands_count) end # event handlers @impl true - def handle_event("create-strand", _params, socket) do - {:noreply, assign(socket, :is_creating_strand, true)} - end + def handle_event("create-strand", _params, socket), + do: {:noreply, assign(socket, :is_creating_strand, true)} - def handle_event("cancel-strand-creation", _params, socket) do - {:noreply, assign(socket, :is_creating_strand, false)} - end + def handle_event("cancel-strand-creation", _params, socket), + do: {:noreply, assign(socket, :is_creating_strand, false)} - def handle_event("load-more", _params, socket) do - {:noreply, load_strands(socket)} - end + def handle_event("load-more", _params, socket), + do: {:noreply, stream_strands(socket)} def handle_event("star-strand", %{"id" => id, "name" => name}, socket) do profile_id = socket.assigns.current_user.current_profile.id @@ -137,133 +158,57 @@ defmodule LantternWeb.StrandsLive do end end - def handle_event("filter", params, socket) do - # enforce years and subjects in params, - # required to clear profile filters when none is selected - params = %{ - subjects_ids: Map.get(params, "subjects_ids"), - years_ids: Map.get(params, "years_ids") - } - - {:noreply, - socket - |> push_navigate(to: ~p"/strands?#{params}")} - end + def handle_event("toggle_subject_id", %{"id" => id}, socket) do + selected_subjects_ids = + case id in socket.assigns.selected_subjects_ids do + true -> + socket.assigns.selected_subjects_ids + |> Enum.filter(&(&1 != id)) - def handle_event("clear-filters", _params, socket) do - params = %{ - subjects_ids: nil, - years_ids: nil - } + false -> + [id | socket.assigns.selected_subjects_ids] + end - {:noreply, - socket - |> push_navigate(to: path(socket, ~p"/strands?#{params}"))} + {:noreply, assign(socket, :selected_subjects_ids, selected_subjects_ids)} end - # helpers - - defp handle_assigns(socket, params) do - params_years_ids = - case Map.get(params, "years_ids") do - ids when is_list(ids) -> ids - _ -> nil - end + def handle_event("toggle_year_id", %{"id" => id}, socket) do + selected_years_ids = + case id in socket.assigns.selected_years_ids do + true -> + socket.assigns.selected_years_ids + |> Enum.filter(&(&1 != id)) - params_subjects_ids = - case Map.get(params, "subjects_ids") do - ids when is_list(ids) -> ids - _ -> nil + false -> + [id | socket.assigns.selected_years_ids] end - form = - %{ - "years_ids" => params_years_ids || [], - "subjects_ids" => params_subjects_ids || [] - } - |> Phoenix.Component.to_form() - - socket - |> assign_subjects(params) - |> assign_years(params) - |> assign(:form, form) - |> load_strands() - end - - defp assign_subjects(socket, %{"subjects_ids" => subjects_ids}) when subjects_ids != "" do - current_subjects = - socket.assigns.subjects - |> Enum.filter(&("#{&1.id}" in subjects_ids)) - - assign(socket, :current_subjects, current_subjects) + {:noreply, assign(socket, :selected_years_ids, selected_years_ids)} end - defp assign_subjects(socket, _params), do: socket - - defp assign_years(socket, %{"years_ids" => years_ids}) when years_ids != "" do - current_years = - socket.assigns.years - |> Enum.filter(&("#{&1.id}" in years_ids)) + def handle_event("clear_filters", _, socket) do + Personalization.set_profile_current_filters( + socket.assigns.current_user, + %{subjects_ids: [], years_ids: []} + ) - assign(socket, :current_years, current_years) + {:noreply, push_navigate(socket, to: ~p"/strands")} end - defp assign_years(socket, _params), do: socket - - defp load_strands(socket) do - subjects_ids = - socket.assigns.current_subjects - |> Enum.map(& &1.id) - - years_ids = - socket.assigns.current_years - |> Enum.map(& &1.id) - - {strands, meta} = - LearningContext.list_strands( - preloads: [:subjects, :years], - after: socket.assigns[:end_cursor], - subjects_ids: subjects_ids, - years_ids: years_ids, - show_starred_for_profile_id: socket.assigns.current_user.current_profile.id - ) - - strands_count = length(strands) - - starred_strands = - LearningContext.list_starred_strands( - socket.assigns.current_user.current_profile.id, - preloads: [:subjects, :years], - subjects_ids: subjects_ids, - years_ids: years_ids - ) - - starred_strands_count = length(starred_strands) - - socket - |> stream(:strands, strands) - |> assign(:strands_count, strands_count) - |> assign(:end_cursor, meta.end_cursor) - |> assign(:has_next_page, meta.has_next_page?) - |> stream(:starred_strands, starred_strands) - |> assign(:starred_strands_count, starred_strands_count) - end + def handle_event("apply_filters", _, socket) do + Personalization.set_profile_current_filters( + socket.assigns.current_user, + %{ + subjects_ids: socket.assigns.selected_subjects_ids, + years_ids: socket.assigns.selected_years_ids + } + ) - defp show_filter(js \\ %JS{}) do - js - # |> JS.push("show-filter") - |> JS.exec("data-show", to: "#strands-filters") + {:noreply, push_navigate(socket, to: ~p"/strands")} end - defp filter(js \\ %JS{}) do - js - |> JS.push("filter") - |> JS.exec("data-cancel", to: "#strands-filters") - end + # helpers - defp clear_filters(js \\ %JS{}) do - js - |> JS.push("clear-filters") - |> JS.exec("data-cancel", to: "#strands-filters") - end + defp show_filter(js \\ %JS{}), + do: js |> JS.exec("data-show", to: "#strands-filters") end diff --git a/lib/lanttern_web/live/pages/strands/strands_live.html.heex b/lib/lanttern_web/live/pages/strands/strands_live.html.heex index 32003292..9c2b76b0 100644 --- a/lib/lanttern_web/live/pages/strands/strands_live.html.heex +++ b/lib/lanttern_web/live/pages/strands/strands_live.html.heex @@ -3,8 +3,8 @@

<%= gettext("I want to explore strands in") %>
- <.filter_buttons type={gettext("years")} items={@current_years} />, - <.filter_buttons type={gettext("subjects")} items={@current_subjects} /> + <.filter_buttons type={gettext("years")} items={@selected_years} />, + <.filter_buttons type={gettext("subjects")} items={@selected_subjects} />

<.collection_action type="button" icon_name="hero-plus-circle" phx-click="create-strand"> <%= gettext("Create new strand") %> @@ -51,36 +51,28 @@
<.slide_over id="strands-filters"> <:title><%= gettext("Filter Strands") %> - <.form id="strands-filters-form" for={@form} phx-submit={filter()} class="flex gap-6"> -
- - <%= gettext("Years") %> - -
- <.check_field - :for={opt <- @years} - id={"year-#{opt.id}"} - field={@form[:years_ids]} - opt={opt} - /> -
-
-
- - <%= gettext("Subjects") %> - -
- <.check_field - :for={opt <- @subjects} - id={"subject-#{opt.id}"} - field={@form[:subjects_ids]} - opt={opt} - /> -
-
- +
+ <%= gettext("By subject") %> +
+ <.live_component + module={SubjectPickerComponent} + id="curriculum-item-subjects-filter" + on_select={&JS.push("toggle_subject_id", value: %{"id" => &1})} + selected_ids={@selected_subjects_ids} + class="mt-4" + /> +
+ <%= gettext("By year") %> +
+ <.live_component + module={YearPickerComponent} + id="curriculum-item-years-filter" + on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + selected_ids={@selected_years_ids} + class="mt-4" + /> <:actions_left> - <.button type="button" theme="ghost" phx-click={clear_filters()}> + <.button type="button" theme="ghost" phx-click="clear_filters"> <%= gettext("Clear filters") %> @@ -93,9 +85,9 @@ <%= gettext("Cancel") %> <.button - type="submit" - form="strands-filters-form" + type="button" phx-disable-with={gettext("Applying filters...")} + phx-click="apply_filters" > <%= gettext("Apply filters") %> @@ -113,8 +105,8 @@ id={:new} strand={ %Strand{ - subjects: @current_subjects, - years: @current_years + subjects: @selected_subjects, + years: @selected_years } } action={:new} From c4d82e89e5e34cbb3b906738bb79bbf6c2f641ae Mon Sep 17 00:00:00 2001 From: endoooo Date: Wed, 6 Mar 2024 08:17:03 -0300 Subject: [PATCH 06/15] cd: adjusted fly.toml config to always keep 1 machine running --- fly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fly.toml b/fly.toml index 5d3f31e7..322fd1f1 100644 --- a/fly.toml +++ b/fly.toml @@ -24,7 +24,7 @@ internal_port = 8080 force_https = true auto_stop_machines = true auto_start_machines = true -min_machines_running = 0 +min_machines_running = 1 processes = ["app"] [http_service.concurrency] type = "connections" From e75c4c6bf4a2c105e615d4ef08ee32d6e605e39c Mon Sep 17 00:00:00 2001 From: endoooo Date: Wed, 6 Mar 2024 14:18:17 -0300 Subject: [PATCH 07/15] chore: added `year_id` to `ReportCard` schema - added support to filter by cycle and year in `Reporting.list_report_cards/1` (and added opts to `Reporting.list_report_cards_by_cycle/1`) - added default order by in `Schools.list_cycles/1` - added support to cycles in profile settings - created core `<.filter_text_button>` component - in `LantternWeb.PersonalizationHelpers`: added support to cycles in `assign_user_filters/3`, created `handle_filter_toggle/3`, `clear_profile_filters/2`, and `save_profile_filters/3` - deleted `Taxonomy.SubjectPickerComponent` and `Taxonomy.YearPickerComponent` in favor of `BadgeButtonPickerComponent` - added cycle and year filters to report cards view - adjusted active views in menu --- .../personalization/profile_settings.ex | 6 +- lib/lanttern/reporting.ex | 42 +++-- lib/lanttern/reporting/report_card.ex | 8 +- lib/lanttern/schools.ex | 7 +- .../components/core_components.ex | 50 ++++++ .../components/reporting_components.ex | 9 +- .../helpers/personalization_helpers.ex | 153 ++++++++++++++++-- .../component/id/curriculum_component_live.ex | 3 +- .../id/curriculum_component_live.html.heex | 6 +- .../pages/report_cards/id/grades_component.ex | 2 +- .../pages/report_cards/id/report_card_live.ex | 2 +- .../id/report_card_live.html.heex | 11 +- .../pages/report_cards/report_cards_live.ex | 38 ++++- .../report_cards/report_cards_live.html.heex | 59 ++++++- .../live/pages/strands/strands_live.ex | 96 ++--------- .../live/pages/strands/strands_live.html.heex | 22 ++- ...er_component.ex => badge_button_picker.ex} | 19 +-- .../curriculum_item_form_component.ex | 22 +-- .../live/shared/menu_component.ex | 17 +- .../reporting/report_card_form_component.ex | 12 ++ .../taxonomy/subject_picker_component.ex | 44 ----- ...0240306113958_add_year_to_report_cards.exs | 18 +++ test/lanttern/reporting_test.exs | 21 ++- .../live/admin/report_card_live_test.exs | 4 +- test/support/fixtures/reporting_fixtures.ex | 9 +- 25 files changed, 475 insertions(+), 205 deletions(-) rename lib/lanttern_web/live/shared/{taxonomy/year_picker_component.ex => badge_button_picker.ex} (60%) delete mode 100644 lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex create mode 100644 priv/repo/migrations/20240306113958_add_year_to_report_cards.exs diff --git a/lib/lanttern/personalization/profile_settings.ex b/lib/lanttern/personalization/profile_settings.ex index 6196b2d6..cc2e96c8 100644 --- a/lib/lanttern/personalization/profile_settings.ex +++ b/lib/lanttern/personalization/profile_settings.ex @@ -16,7 +16,8 @@ defmodule Lanttern.Personalization.ProfileSettings do @type current_filters() :: %__MODULE__.CurrentFilters{ classes_ids: [pos_integer()], subjects_ids: [pos_integer()], - years_ids: [pos_integer()] + years_ids: [pos_integer()], + cycles_ids: [pos_integer()] } schema "profile_settings" do @@ -26,6 +27,7 @@ defmodule Lanttern.Personalization.ProfileSettings do field :classes_ids, {:array, :id} field :subjects_ids, {:array, :id} field :years_ids, {:array, :id} + field :cycles_ids, {:array, :id} end timestamps() @@ -41,6 +43,6 @@ defmodule Lanttern.Personalization.ProfileSettings do defp current_filters_changeset(current_filters, attrs) do current_filters - |> cast(attrs, [:classes_ids, :subjects_ids, :years_ids]) + |> cast(attrs, [:classes_ids, :subjects_ids, :years_ids, :cycles_ids]) end end diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 2c803749..7ad45793 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -26,8 +26,10 @@ defmodule Lanttern.Reporting do ## Options: - - `:preloads` – preloads associated data - - `:strands_ids` – filter report cards by strands + - `:preloads` – preloads associated data + - `:strands_ids` – filter report cards by strands + - `:cycles_ids` - filter report cards by cycles + - `:years_ids` - filter report cards by year ## Examples @@ -38,28 +40,40 @@ defmodule Lanttern.Reporting do def list_report_cards(opts \\ []) do from(rc in ReportCard, join: sc in assoc(rc, :school_cycle), - order_by: [desc: sc.end_at, asc: rc.name] + order_by: [desc: sc.end_at, asc: sc.start_at, asc: rc.name] ) - |> filter_report_cards(opts) + |> apply_list_report_cards_opts(opts) |> Repo.all() |> maybe_preload(opts) end - defp filter_report_cards(queryable, opts) do - Enum.reduce(opts, queryable, &apply_report_cards_filter/2) - end + defp apply_list_report_cards_opts(queryable, []), do: queryable - defp apply_report_cards_filter({:strands_ids, ids}, queryable) do + defp apply_list_report_cards_opts(queryable, [{:strands_ids, ids} | opts]) + when is_list(ids) and ids != [] do from( rc in queryable, join: sr in assoc(rc, :strand_reports), join: s in assoc(sr, :strand), where: s.id in ^ids ) + |> apply_list_report_cards_opts(opts) end - defp apply_report_cards_filter(_, queryable), - do: queryable + defp apply_list_report_cards_opts(queryable, [{:cycles_ids, ids} | opts]) + when is_list(ids) and ids != [] do + from(rc in queryable, where: rc.school_cycle_id in ^ids) + |> apply_list_report_cards_opts(opts) + end + + defp apply_list_report_cards_opts(queryable, [{:years_ids, ids} | opts]) + when is_list(ids) and ids != [] do + from(rc in queryable, where: rc.year_id in ^ids) + |> apply_list_report_cards_opts(opts) + end + + defp apply_list_report_cards_opts(queryable, [_ | opts]), + do: apply_list_report_cards_opts(queryable, opts) @doc """ Returns the list of report cards, grouped by cycle. @@ -68,6 +82,8 @@ defmodule Lanttern.Reporting do in each group ordered by name (asc) — it's the same order returned by `list_report_cards/1`, which is used internally. + See `list_report_cards/1` for options. + ## Examples iex> list_report_cards_by_cycle() @@ -75,12 +91,12 @@ defmodule Lanttern.Reporting do """ - @spec list_report_cards_by_cycle() :: [ + @spec list_report_cards_by_cycle(Keyword.t()) :: [ {Cycle.t(), [ReportCard.t()]} ] - def list_report_cards_by_cycle() do + def list_report_cards_by_cycle(opts \\ []) do report_cards_by_cycle_map = - list_report_cards() + list_report_cards(opts) |> Enum.group_by(& &1.school_cycle_id) Schools.list_cycles(order_by: [desc: :end_at]) diff --git a/lib/lanttern/reporting/report_card.ex b/lib/lanttern/reporting/report_card.ex index cb1d18a5..fbed6445 100644 --- a/lib/lanttern/reporting/report_card.ex +++ b/lib/lanttern/reporting/report_card.ex @@ -4,6 +4,7 @@ defmodule Lanttern.Reporting.ReportCard do alias Lanttern.Reporting.StrandReport alias Lanttern.Schools.Cycle + alias Lanttern.Taxonomy.Year @type t :: %__MODULE__{ id: pos_integer(), @@ -11,6 +12,8 @@ defmodule Lanttern.Reporting.ReportCard do description: String.t(), school_cycle: Cycle.t(), school_cycle_id: pos_integer(), + year: Year.t(), + year_id: pos_integer(), strand_reports: [StrandReport.t()], inserted_at: DateTime.t(), updated_at: DateTime.t() @@ -21,6 +24,7 @@ defmodule Lanttern.Reporting.ReportCard do field :description, :string belongs_to :school_cycle, Cycle + belongs_to :year, Year has_many :strand_reports, StrandReport, preload_order: [asc: :position] @@ -30,7 +34,7 @@ defmodule Lanttern.Reporting.ReportCard do @doc false def changeset(report_card, attrs) do report_card - |> cast(attrs, [:name, :description, :school_cycle_id]) - |> validate_required([:name, :school_cycle_id]) + |> cast(attrs, [:name, :description, :school_cycle_id, :year_id]) + |> validate_required([:name, :school_cycle_id, :year_id]) end end diff --git a/lib/lanttern/schools.ex b/lib/lanttern/schools.ex index cc854c69..e8e32cb5 100644 --- a/lib/lanttern/schools.ex +++ b/lib/lanttern/schools.ex @@ -114,7 +114,7 @@ defmodule Lanttern.Schools do ## Options: - - `:schools_ids` – filter classes by schools + - `:schools_ids` – filter cycles by schools - `:order_by` - an order by query expression ([ref](https://hexdocs.pm/ecto/Ecto.Query.html#order_by/3)) ## Examples @@ -130,7 +130,10 @@ defmodule Lanttern.Schools do |> Repo.all() end - defp order_cycles(queryable, nil), do: queryable + defp order_cycles(queryable, nil) do + from c in queryable, + order_by: [asc: :end_at, desc: :start_at] + end defp order_cycles(queryable, order_by_expression) do from c in queryable, diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index d9ac437d..f6f5f3f2 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -380,6 +380,56 @@ defmodule LantternWeb.CoreComponents do """ end + @doc """ + Renders a filter text button. + """ + + attr :items, :list, required: true + attr :item_key, :any, default: :name + attr :type, :string, required: true + attr :max_items, :integer, default: 3 + attr :class, :any, default: nil + attr :on_click, JS, default: %JS{} + + def filter_text_button(%{items: []} = assigns) do + ~H""" + + """ + end + + def filter_text_button(assigns) do + %{ + items: items, + type: type, + max_items: max_items, + item_key: item_key + } = assigns + + items = + if length(items) > max_items do + {initial, rest} = Enum.split(items, max_items - 1) + + initial + |> Enum.map(&Map.get(&1, item_key)) + |> Enum.join(" / ") + |> Kernel.<>(" / + #{length(rest)} #{type}") + else + items + |> Enum.map(&Map.get(&1, item_key)) + |> Enum.join(" / ") + end + + assigns = assign(assigns, :items, items) + + ~H""" + + """ + end + @doc """ Renders flash notices. diff --git a/lib/lanttern_web/components/reporting_components.ex b/lib/lanttern_web/components/reporting_components.ex index 2f20bdee..a0082511 100644 --- a/lib/lanttern_web/components/reporting_components.ex +++ b/lib/lanttern_web/components/reporting_components.ex @@ -10,12 +10,14 @@ defmodule LantternWeb.ReportingComponents do alias Lanttern.Reporting.ReportCard alias Lanttern.Rubrics.Rubric alias Lanttern.Schools.Cycle + alias Lanttern.Taxonomy.Year @doc """ Renders a report card card (yes, card card, 2x). """ attr :report_card, ReportCard, required: true attr :cycle, Cycle, default: nil + attr :year, Year, default: nil attr :navigate, :string, default: nil attr :id, :string, default: nil attr :class, :any, default: nil @@ -43,10 +45,13 @@ defmodule LantternWeb.ReportingComponents do <%= @report_card.name %> <% end %> -
- <.badge> +
+ <.badge :if={@cycle}> <%= gettext("Cycle") %>: <%= @cycle.name %> + <.badge :if={@year}> + <%= @year.name %> +
diff --git a/lib/lanttern_web/helpers/personalization_helpers.ex b/lib/lanttern_web/helpers/personalization_helpers.ex index 572c35cf..01e1986f 100644 --- a/lib/lanttern_web/helpers/personalization_helpers.ex +++ b/lib/lanttern_web/helpers/personalization_helpers.ex @@ -2,8 +2,10 @@ defmodule LantternWeb.PersonalizationHelpers do import Phoenix.Component, only: [assign: 3] alias Lanttern.Personalization + alias Lanttern.Personalization.ProfileSettings alias Lanttern.Identity.User + alias Lanttern.Schools alias Lanttern.Taxonomy import LantternWeb.LocalizationHelpers @@ -13,18 +15,24 @@ defmodule LantternWeb.PersonalizationHelpers do ## Filter types and assigns - ### `:subjects`'s assigns + ### `:subjects`' assigns - :subjects - :selected_subjects_ids - :selected_subjects - ### `:years`'s assigns + ### `:years`' assigns - :years - :selected_years_ids - :selected_years + ### `:cycles`' assigns + + - :cycles + - :selected_cycles_ids + - :selected_cycles + ## Examples iex> assign_user_filters(socket, [:subjects], user) @@ -40,12 +48,12 @@ defmodule LantternWeb.PersonalizationHelpers do end socket - |> assign_filter_type(current_filters, filter_types) + |> assign_filter_type(current_user, current_filters, filter_types) end - defp assign_filter_type(socket, _current_filters, []), do: socket + defp assign_filter_type(socket, _current_user, _current_filters, []), do: socket - defp assign_filter_type(socket, current_filters, [:subjects | filter_types]) do + defp assign_filter_type(socket, current_user, current_filters, [:subjects | filter_types]) do subjects = Taxonomy.list_subjects() |> translate_struct_list("taxonomy", :name, reorder: true) @@ -57,10 +65,10 @@ defmodule LantternWeb.PersonalizationHelpers do |> assign(:subjects, subjects) |> assign(:selected_subjects_ids, selected_subjects_ids) |> assign(:selected_subjects, selected_subjects) - |> assign_filter_type(current_filters, filter_types) + |> assign_filter_type(current_user, current_filters, filter_types) end - defp assign_filter_type(socket, current_filters, [:years | filter_types]) do + defp assign_filter_type(socket, current_user, current_filters, [:years | filter_types]) do years = Taxonomy.list_years() |> translate_struct_list("taxonomy") @@ -72,9 +80,134 @@ defmodule LantternWeb.PersonalizationHelpers do |> assign(:years, years) |> assign(:selected_years_ids, selected_years_ids) |> assign(:selected_years, selected_years) - |> assign_filter_type(current_filters, filter_types) + |> assign_filter_type(current_user, current_filters, filter_types) + end + + defp assign_filter_type(socket, current_user, current_filters, [:cycles | filter_types]) do + cycles = + Schools.list_cycles(schools_ids: [current_user.current_profile.school_id]) + + selected_cycles_ids = Map.get(current_filters, :cycles_ids) || [] + selected_cycles = Enum.filter(cycles, &(&1.id in selected_cycles_ids)) + + socket + |> assign(:cycles, cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + |> assign(:selected_cycles, selected_cycles) + |> assign_filter_type(current_user, current_filters, filter_types) + end + + defp assign_filter_type(socket, current_user, current_filters, [_ | filter_types]), + do: assign_filter_type(socket, current_user, current_filters, filter_types) + + @type_to_type_ids_key_map %{ + subjects: :subjects_ids, + years: :years_ids, + cycles: :cycles_ids + } + + @type_to_selected_ids_key_map %{ + subjects: :selected_subjects_ids, + years: :selected_years_ids, + cycles: :selected_cycles_ids + } + + @doc """ + Handle toggling of filter related assigns in socket. + + ## Supported types + + - `:subjects` + - `:years` + - `:cycles` + + ## Examples + + iex> handle_filter_toggle(socket, :subjects, 1) + %Phoenix.LiveView.Socket{} + """ + + @spec handle_filter_toggle(Phoenix.LiveView.Socket.t(), atom(), pos_integer()) :: + Phoenix.LiveView.Socket.t() + + def handle_filter_toggle(socket, type, id) do + selected_ids_key = @type_to_selected_ids_key_map[type] + selected_ids = socket.assigns[selected_ids_key] + + selected_ids = + case id in selected_ids do + true -> + selected_ids + |> Enum.filter(&(&1 != id)) + + false -> + [id | selected_ids] + end + + assign(socket, selected_ids_key, selected_ids) + end + + @doc """ + Handle clearing of profile filters. + + ## Supported types + + - `:subjects` + - `:years` + - `:cycles` + + ## Examples + + iex> clear_profile_filters(user, [:subjects]) + {:ok, %ProfileSettings{}} + + iex> clear_profile_filters(user, bad_value) + {:error, %Ecto.Changeset{}} + """ + + @spec clear_profile_filters(User.t(), [atom()]) :: + {:ok, ProfileSettings.t()} | {:error, Ecto.Changeset.t()} + + def clear_profile_filters(current_user, types) do + attrs = + types + |> Enum.map(&{@type_to_type_ids_key_map[&1], []}) + |> Enum.into(%{}) + + Personalization.set_profile_current_filters(current_user, attrs) end - defp assign_filter_type(socket, current_filters, [_ | filter_types]), - do: assign_filter_type(socket, current_filters, filter_types) + @doc """ + Handle saving of profile filters. + + ## Supported types + + - `:subjects` + - `:years` + - `:cycles` + + ## Examples + + iex> save_profile_filters(socket, user, [:subjects]) + %Phoenix.LiveView.Socket{} + """ + + @spec save_profile_filters(Phoenix.LiveView.Socket.t(), User.t(), [atom()]) :: + Phoenix.LiveView.Socket.t() + + def save_profile_filters(socket, current_user, types) do + attrs = + types + |> Enum.map(fn type -> + selected_ids_key = @type_to_selected_ids_key_map[type] + selected_ids = socket.assigns[selected_ids_key] + + {@type_to_type_ids_key_map[type], selected_ids} + end) + |> Enum.into(%{}) + + Personalization.set_profile_current_filters(current_user, attrs) + + socket + end end diff --git a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex index 4c278e6a..e3d73410 100644 --- a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex +++ b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.ex @@ -9,8 +9,7 @@ defmodule LantternWeb.CurriculumComponentLive do # shared components alias LantternWeb.Curricula.CurriculumItemFormComponent - alias LantternWeb.Taxonomy.SubjectPickerComponent - alias LantternWeb.Taxonomy.YearPickerComponent + alias LantternWeb.BadgeButtonPickerComponent @impl true def mount(_params, _session, socket) do diff --git a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex index c6c4b251..dde33878 100644 --- a/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex +++ b/lib/lanttern_web/live/pages/curriculum/component/id/curriculum_component_live.html.heex @@ -159,9 +159,10 @@ <%= gettext("Filter curriculum items by subject") %> <.live_component - module={SubjectPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-subjects-filter" on_select={&JS.push("toggle_subject_id", value: %{"id" => &1})} + items={@subjects} selected_ids={@selected_subjects_ids} class="mt-6" /> @@ -189,9 +190,10 @@ <%= gettext("Filter curriculum items by year") %> <.live_component - module={YearPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-years-filter" on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + items={@years} selected_ids={@selected_years_ids} class="mt-6" /> diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex index 67d6e83f..79eeb1cb 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -172,7 +172,7 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do socket = socket |> assign(:subjects, Taxonomy.list_subjects()) - |> assign(:cycles, Schools.list_cycles(order_by: [asc: :end_at, desc: :start_at])) + |> assign(:cycles, Schools.list_cycles()) |> assign(:has_grades_subjects_order_change, false) {:ok, socket} diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex index 1c9215ac..fcd28590 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex @@ -44,7 +44,7 @@ defmodule LantternWeb.ReportCardLive do socket |> assign(:params, params) |> assign_new(:report_card, fn -> - Reporting.get_report_card!(id, preloads: :school_cycle) + Reporting.get_report_card!(id, preloads: [:school_cycle, :year]) end) |> set_current_tab(params, socket.assigns.live_action) diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex index ec16deae..afefe2be 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex @@ -9,9 +9,14 @@

<%= @report_card.name %>

- <.badge theme="dark" class="mt-6"> - <%= gettext("Cycle") %>: <%= @report_card.school_cycle.name %> - +
+ <.badge theme="dark"> + <%= gettext("Cycle") %>: <%= @report_card.school_cycle.name %> + + <.badge theme="dark"> + <%= @report_card.year.name %> + +
diff --git a/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex b/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex index ad9f4c6e..404e92c2 100644 --- a/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex +++ b/lib/lanttern_web/live/pages/report_cards/report_cards_live.ex @@ -3,9 +3,11 @@ defmodule LantternWeb.ReportCardsLive do alias Lanttern.Reporting alias Lanttern.Reporting.ReportCard + import LantternWeb.PersonalizationHelpers # live components alias LantternWeb.Reporting.ReportCardFormComponent + alias LantternWeb.BadgeButtonPickerComponent # shared components import LantternWeb.ReportingComponents @@ -17,6 +19,7 @@ defmodule LantternWeb.ReportCardsLive do socket = socket |> maybe_redirect(params) + |> assign_user_filters([:cycles, :years], socket.assigns.current_user) |> stream_configure( :cycles_and_report_cards, dom_id: fn @@ -38,7 +41,13 @@ defmodule LantternWeb.ReportCardsLive do @impl true def handle_params(_params, _url, socket) do - cycles_and_report_cards = Reporting.list_report_cards_by_cycle() + cycles_and_report_cards = + Reporting.list_report_cards_by_cycle( + cycles_ids: socket.assigns.selected_cycles_ids, + years_ids: socket.assigns.selected_years_ids, + preloads: :year + ) + has_report_cards = length(cycles_and_report_cards) > 0 socket = @@ -48,4 +57,31 @@ defmodule LantternWeb.ReportCardsLive do {:noreply, socket} end + + # event handlers + + @impl true + def handle_event("toggle_cycle_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :cycles, id)} + + def handle_event("toggle_year_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :years, id)} + + def handle_event("clear_filters", _, socket) do + clear_profile_filters( + socket.assigns.current_user, + [:cycles, :years] + ) + + {:noreply, push_navigate(socket, to: ~p"/report_cards")} + end + + def handle_event("apply_filters", _, socket) do + socket = + socket + |> save_profile_filters(socket.assigns.current_user, [:cycles, :years]) + |> push_navigate(to: ~p"/report_cards") + + {:noreply, socket} + end end diff --git a/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex b/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex index 74d1e7bf..22db8b96 100644 --- a/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex +++ b/lib/lanttern_web/live/pages/report_cards/report_cards_live.html.heex @@ -2,7 +2,17 @@ <.page_title_with_menu><%= gettext("Report Cards") %>

- <%= gettext("Viewing report cards") %> + <%= gettext("Showing report cards from") %>
+ <.filter_text_button + type={gettext("cycles")} + items={@selected_cycles} + on_click={JS.exec("data-show", to: "#report-cards-filters")} + />, + <.filter_text_button + type={gettext("years")} + items={@selected_years} + on_click={JS.exec("data-show", to: "#report-cards-filters")} + />

<.collection_action type="link" icon_name="hero-plus-circle" patch={~p"/report_cards/new"}> <%= gettext("Create new report card") %> @@ -22,6 +32,7 @@ id={"report-card-#{report_card.id}"} report_card={report_card} navigate={~p"/report_cards/#{report_card}"} + year={report_card.year} />
@@ -32,6 +43,52 @@ <% end %>
+<.slide_over id="report-cards-filters"> + <:title><%= gettext("Filter report cards") %> +
+ <%= gettext("By cycle") %> +
+ <.live_component + module={BadgeButtonPickerComponent} + id="report-cards-cycles-filter" + on_select={&JS.push("toggle_cycle_id", value: %{"id" => &1})} + items={@cycles} + selected_ids={@selected_cycles_ids} + class="mt-4" + /> +
+ <%= gettext("By year") %> +
+ <.live_component + module={BadgeButtonPickerComponent} + id="report-cards-years-filter" + on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + items={@years} + selected_ids={@selected_years_ids} + class="mt-4" + /> + <:actions_left> + <.button type="button" theme="ghost" phx-click="clear_filters"> + <%= gettext("Clear filters") %> + + + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#report-cards-filters")} + > + <%= gettext("Cancel") %> + + <.button + type="button" + phx-disable-with={gettext("Applying filters...")} + phx-click="apply_filters" + > + <%= gettext("Apply filters") %> + + + <.slide_over :if={@live_action == :new} id="report-card-form-overlay" diff --git a/lib/lanttern_web/live/pages/strands/strands_live.ex b/lib/lanttern_web/live/pages/strands/strands_live.ex index d4e9ef03..edccf85a 100644 --- a/lib/lanttern_web/live/pages/strands/strands_live.ex +++ b/lib/lanttern_web/live/pages/strands/strands_live.ex @@ -3,7 +3,6 @@ defmodule LantternWeb.StrandsLive do alias Lanttern.LearningContext alias Lanttern.LearningContext.Strand - alias Lanttern.Personalization import LantternWeb.PersonalizationHelpers @@ -12,46 +11,10 @@ defmodule LantternWeb.StrandsLive do # shared components import LantternWeb.LearningContextComponents - alias LantternWeb.Taxonomy.SubjectPickerComponent - alias LantternWeb.Taxonomy.YearPickerComponent + alias LantternWeb.BadgeButtonPickerComponent # function components - attr :items, :list, required: true - attr :type, :string, required: true - - def filter_buttons(%{items: []} = assigns) do - ~H""" - - """ - end - - def filter_buttons(%{items: items, type: type} = assigns) do - items = - if length(items) > 3 do - {first_two, rest} = Enum.split(items, 2) - - first_two - |> Enum.map(& &1.name) - |> Enum.join(" / ") - |> Kernel.<>(" / + #{length(rest)} #{type}") - else - items - |> Enum.map(& &1.name) - |> Enum.join(" / ") - end - - assigns = assign(assigns, :items, items) - - ~H""" - - """ - end - attr :id, :string, required: true attr :strands, :list, required: true @@ -80,7 +43,10 @@ defmodule LantternWeb.StrandsLive do def mount(_params, _session, socket) do socket = socket - |> assign_user_filters([:subjects, :years], socket.assigns.current_user) + |> assign_user_filters( + [:subjects, :years], + socket.assigns.current_user + ) |> assign(:is_creating_strand, false) |> stream_strands() @@ -158,57 +124,27 @@ defmodule LantternWeb.StrandsLive do end end - def handle_event("toggle_subject_id", %{"id" => id}, socket) do - selected_subjects_ids = - case id in socket.assigns.selected_subjects_ids do - true -> - socket.assigns.selected_subjects_ids - |> Enum.filter(&(&1 != id)) + def handle_event("toggle_subject_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :subjects, id)} - false -> - [id | socket.assigns.selected_subjects_ids] - end - - {:noreply, assign(socket, :selected_subjects_ids, selected_subjects_ids)} - end - - def handle_event("toggle_year_id", %{"id" => id}, socket) do - selected_years_ids = - case id in socket.assigns.selected_years_ids do - true -> - socket.assigns.selected_years_ids - |> Enum.filter(&(&1 != id)) - - false -> - [id | socket.assigns.selected_years_ids] - end - - {:noreply, assign(socket, :selected_years_ids, selected_years_ids)} - end + def handle_event("toggle_year_id", %{"id" => id}, socket), + do: {:noreply, handle_filter_toggle(socket, :years, id)} def handle_event("clear_filters", _, socket) do - Personalization.set_profile_current_filters( + clear_profile_filters( socket.assigns.current_user, - %{subjects_ids: [], years_ids: []} + [:subjects, :years] ) {:noreply, push_navigate(socket, to: ~p"/strands")} end def handle_event("apply_filters", _, socket) do - Personalization.set_profile_current_filters( - socket.assigns.current_user, - %{ - subjects_ids: socket.assigns.selected_subjects_ids, - years_ids: socket.assigns.selected_years_ids - } - ) + socket = + socket + |> save_profile_filters(socket.assigns.current_user, [:subjects, :years]) + |> push_navigate(to: ~p"/strands") - {:noreply, push_navigate(socket, to: ~p"/strands")} + {:noreply, socket} end - - # helpers - - defp show_filter(js \\ %JS{}), - do: js |> JS.exec("data-show", to: "#strands-filters") end diff --git a/lib/lanttern_web/live/pages/strands/strands_live.html.heex b/lib/lanttern_web/live/pages/strands/strands_live.html.heex index 9c2b76b0..bf196e34 100644 --- a/lib/lanttern_web/live/pages/strands/strands_live.html.heex +++ b/lib/lanttern_web/live/pages/strands/strands_live.html.heex @@ -3,8 +3,16 @@

<%= gettext("I want to explore strands in") %>
- <.filter_buttons type={gettext("years")} items={@selected_years} />, - <.filter_buttons type={gettext("subjects")} items={@selected_subjects} /> + <.filter_text_button + type={gettext("years")} + items={@selected_years} + on_click={JS.exec("data-show", to: "#strands-filters")} + />, + <.filter_text_button + type={gettext("subjects")} + items={@selected_subjects} + on_click={JS.exec("data-show", to: "#strands-filters")} + />

<.collection_action type="button" icon_name="hero-plus-circle" phx-click="create-strand"> <%= gettext("Create new strand") %> @@ -55,9 +63,10 @@ <%= gettext("By subject") %> <.live_component - module={SubjectPickerComponent} - id="curriculum-item-subjects-filter" + module={BadgeButtonPickerComponent} + id="strands-subjects-filter" on_select={&JS.push("toggle_subject_id", value: %{"id" => &1})} + items={@subjects} selected_ids={@selected_subjects_ids} class="mt-4" /> @@ -65,9 +74,10 @@ <%= gettext("By year") %> <.live_component - module={YearPickerComponent} - id="curriculum-item-years-filter" + module={BadgeButtonPickerComponent} + id="strands-years-filter" on_select={&JS.push("toggle_year_id", value: %{"id" => &1})} + items={@years} selected_ids={@selected_years_ids} class="mt-4" /> diff --git a/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex b/lib/lanttern_web/live/shared/badge_button_picker.ex similarity index 60% rename from lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex rename to lib/lanttern_web/live/shared/badge_button_picker.ex index 4357ade7..27ca3a3b 100644 --- a/lib/lanttern_web/live/shared/taxonomy/year_picker_component.ex +++ b/lib/lanttern_web/live/shared/badge_button_picker.ex @@ -1,19 +1,17 @@ -defmodule LantternWeb.Taxonomy.YearPickerComponent do +defmodule LantternWeb.BadgeButtonPickerComponent do use LantternWeb, :live_component - alias Lanttern.Taxonomy - @impl true def render(assigns) do ~H"""
<.badge_button - :for={year <- @years} - theme={if year.id in @selected_ids, do: "primary", else: "default"} - icon_name={if year.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} - phx-click={@on_select.(year.id)} + :for={item <- @items} + theme={if item.id in @selected_ids, do: "primary", else: "default"} + icon_name={if item.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} + phx-click={@on_select.(item.id)} > - <%= year.name %> + <%= Map.get(item, @item_key) %>
""" @@ -24,6 +22,8 @@ defmodule LantternWeb.Taxonomy.YearPickerComponent do socket = socket |> assign(:class, nil) + |> assign(:items, []) + |> assign(:item_key, :name) |> assign(:selected_ids, []) |> assign(:on_select, fn _id -> %JS{} end) @@ -35,9 +35,6 @@ defmodule LantternWeb.Taxonomy.YearPickerComponent do socket = socket |> assign(assigns) - |> assign_new(:years, fn -> - Taxonomy.list_years() - end) {:ok, socket} end diff --git a/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex b/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex index 145b0239..0ace6127 100644 --- a/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex +++ b/lib/lanttern_web/live/shared/curricula/curriculum_item_form_component.ex @@ -2,12 +2,10 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do use LantternWeb, :live_component alias Lanttern.Curricula - # alias Lanttern.Taxonomy - # import LantternWeb.TaxonomyHelpers + alias Lanttern.Taxonomy # live components - alias LantternWeb.Taxonomy.YearPickerComponent - alias LantternWeb.Taxonomy.SubjectPickerComponent + alias LantternWeb.BadgeButtonPickerComponent @impl true def render(assigns) do @@ -38,18 +36,20 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do
<.label><%= gettext("Subjects") %> <.live_component - module={SubjectPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-subjects-select" on_select={&JS.push("toggle_subject", value: %{"id" => &1}, target: @myself)} + items={@subjects} selected_ids={@selected_subjects_ids} />
<.label><%= gettext("Years") %> <.live_component - module={YearPickerComponent} + module={BadgeButtonPickerComponent} id="curriculum-item-years-select" on_select={&JS.push("toggle_year", value: %{"id" => &1}, target: @myself)} + items={@years} selected_ids={@selected_years_ids} />
@@ -76,12 +76,12 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do def update(%{curriculum_item: curriculum_item} = assigns, socket) do changeset = Curricula.change_curriculum_item(curriculum_item) - subjects_ids = + selected_subjects_ids = curriculum_item |> Map.get(:subjects, []) |> Enum.map(& &1.id) - years_ids = + selected_years_ids = curriculum_item |> Map.get(:years, []) |> Enum.map(& &1.id) @@ -89,8 +89,10 @@ defmodule LantternWeb.Curricula.CurriculumItemFormComponent do socket = socket |> assign(assigns) - |> assign(:selected_subjects_ids, subjects_ids) - |> assign(:selected_years_ids, years_ids) + |> assign(:subjects, Taxonomy.list_subjects()) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:years, Taxonomy.list_years()) + |> assign(:selected_years_ids, selected_years_ids) |> assign_form(changeset) {:ok, socket} diff --git a/lib/lanttern_web/live/shared/menu_component.ex b/lib/lanttern_web/live/shared/menu_component.ex index 6fe1f9aa..4ac527b7 100644 --- a/lib/lanttern_web/live/shared/menu_component.ex +++ b/lib/lanttern_web/live/shared/menu_component.ex @@ -249,26 +249,27 @@ defmodule LantternWeb.MenuComponent do :dashboard socket.view in [ - LantternWeb.StrandLive.List, - LantternWeb.StrandLive.Details + LantternWeb.StrandsLive, + LantternWeb.StrandLive, + LantternWeb.MomentLive ] -> :strands socket.view in [ - LantternWeb.SchoolLive.Show, - LantternWeb.SchoolLive.Class, - LantternWeb.SchoolLive.Student + LantternWeb.SchoolLive, + LantternWeb.ClassLive, + LantternWeb.StudentLive ] -> :school socket.view in [ - LantternWeb.AssessmentPointLive.Explorer, - LantternWeb.AssessmentPointLive.Details + LantternWeb.AssessmentPointsLive, + LantternWeb.AssessmentPointLive ] -> :assessment_points socket.view in [ - LantternWeb.RubricsLive.Explorer + LantternWeb.RubricsLive ] -> :rubrics diff --git a/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex b/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex index fac2129c..a1c63185 100644 --- a/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex +++ b/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex @@ -3,6 +3,7 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do alias Lanttern.Reporting alias LantternWeb.SchoolsHelpers + alias LantternWeb.TaxonomyHelpers @impl true def render(assigns) do @@ -24,6 +25,15 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do phx-target={@myself} class="mb-6" /> + <.input + field={@form[:year_id]} + type="select" + label={gettext("Year")} + options={@year_options} + prompt={gettext("Select year")} + phx-target={@myself} + class="mb-6" + /> <.input field={@form[:name]} type="text" @@ -50,11 +60,13 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do @impl true def mount(socket) do cycle_options = SchoolsHelpers.generate_cycle_options() + year_options = TaxonomyHelpers.generate_year_options() socket = socket |> assign(:class, nil) |> assign(:cycle_options, cycle_options) + |> assign(:year_options, year_options) |> assign(:hide_submit, false) {:ok, socket} diff --git a/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex b/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex deleted file mode 100644 index 4e143038..00000000 --- a/lib/lanttern_web/live/shared/taxonomy/subject_picker_component.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule LantternWeb.Taxonomy.SubjectPickerComponent do - use LantternWeb, :live_component - - alias Lanttern.Taxonomy - - @impl true - def render(assigns) do - ~H""" -
- <.badge_button - :for={subject <- @subjects} - theme={if subject.id in @selected_ids, do: "primary", else: "default"} - icon_name={if subject.id in @selected_ids, do: "hero-check-mini", else: "hero-plus-mini"} - phx-click={@on_select.(subject.id)} - > - <%= subject.name %> - -
- """ - end - - @impl true - def mount(socket) do - socket = - socket - |> assign(:class, nil) - |> assign(:selected_ids, []) - |> assign(:on_select, fn _id -> %JS{} end) - - {:ok, socket} - end - - @impl true - def update(assigns, socket) do - socket = - socket - |> assign(assigns) - |> assign_new(:subjects, fn -> - Taxonomy.list_subjects() - end) - - {:ok, socket} - end -end diff --git a/priv/repo/migrations/20240306113958_add_year_to_report_cards.exs b/priv/repo/migrations/20240306113958_add_year_to_report_cards.exs new file mode 100644 index 00000000..ba26dbe3 --- /dev/null +++ b/priv/repo/migrations/20240306113958_add_year_to_report_cards.exs @@ -0,0 +1,18 @@ +defmodule Lanttern.Repo.Migrations.AddYearToReportCards do + use Ecto.Migration + + def change do + alter table(:report_cards) do + add :year_id, references(:years, on_delete: :nothing) + end + + create index(:report_cards, [:year_id]) + + # link existing report cards to an existing year, + # enabling to set the field as not null + execute "UPDATE report_cards SET year_id = years.id FROM years", "" + + # add not null constraints to report_cards' year_id + execute "ALTER TABLE report_cards ALTER COLUMN year_id SET NOT NULL", "" + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 684f59ab..80f4e96a 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -27,7 +27,7 @@ defmodule Lanttern.ReportingTest do assert expected.school_cycle.id == school_cycle.id end - test "list_report_cards/1 with filters returns all filtered report_cards" do + test "list_report_cards/1 with strand filters returns all filtered report_cards" do report_card = report_card_fixture() strand = Lanttern.LearningContextFixtures.strand_fixture() strand_report_fixture(%{report_card_id: report_card.id, strand_id: strand.id}) @@ -41,6 +41,21 @@ defmodule Lanttern.ReportingTest do assert expected.id == report_card.id end + test "list_report_cards/1 with year/cycle filters returns all filtered report_cards" do + year = Lanttern.TaxonomyFixtures.year_fixture() + cycle = Lanttern.SchoolsFixtures.cycle_fixture() + report_card = report_card_fixture(%{school_cycle_id: cycle.id, year_id: year.id}) + + # extra report cards for filtering test + report_card_fixture() + report_card_fixture(%{year_id: year.id}) + report_card_fixture(%{school_cycle_id: cycle.id}) + + [expected] = Reporting.list_report_cards(years_ids: [year.id], cycles_ids: [cycle.id]) + + assert expected.id == report_card.id + end + test "list_report_cards_by_cycle/0 returns report_cards grouped by cycle" do school = SchoolsFixtures.school_fixture() @@ -92,11 +107,13 @@ defmodule Lanttern.ReportingTest do test "create_report_card/1 with valid data creates a report_card" do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() + year = Lanttern.TaxonomyFixtures.year_fixture() valid_attrs = %{ name: "some name", description: "some description", - school_cycle_id: school_cycle.id + school_cycle_id: school_cycle.id, + year_id: year.id } assert {:ok, %ReportCard{} = report_card} = Reporting.create_report_card(valid_attrs) diff --git a/test/lanttern_web/live/admin/report_card_live_test.exs b/test/lanttern_web/live/admin/report_card_live_test.exs index 73711c15..3ab1a4d4 100644 --- a/test/lanttern_web/live/admin/report_card_live_test.exs +++ b/test/lanttern_web/live/admin/report_card_live_test.exs @@ -26,6 +26,7 @@ defmodule LantternWeb.Admin.ReportCardLiveTest do test "saves new report_card", %{conn: conn} do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() + year = Lanttern.TaxonomyFixtures.year_fixture() {:ok, index_live, _html} = live(conn, ~p"/admin/report_cards") @@ -37,7 +38,8 @@ defmodule LantternWeb.Admin.ReportCardLiveTest do create_attrs = %{ name: "some name", description: "some description", - school_cycle_id: school_cycle.id + school_cycle_id: school_cycle.id, + year_id: year.id } assert index_live diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index 77c5e235..53e764dc 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -13,7 +13,8 @@ defmodule Lanttern.ReportingFixtures do |> Enum.into(%{ name: "some name", description: "some description", - school_cycle_id: maybe_gen_school_cycle_id(attrs) + school_cycle_id: maybe_gen_school_cycle_id(attrs), + year_id: maybe_gen_year_id(attrs) }) |> Lanttern.Reporting.create_report_card() @@ -26,6 +27,12 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_school_cycle_id(_attrs), do: Lanttern.SchoolsFixtures.cycle_fixture().id + defp maybe_gen_year_id(%{year_id: year_id} = _attrs), + do: year_id + + defp maybe_gen_year_id(_attrs), + do: Lanttern.TaxonomyFixtures.year_fixture().id + @doc """ Generate a strand_report. """ From 7b8d29082ee30374e273d51bd5462a92b7a5864f Mon Sep 17 00:00:00 2001 From: endoooo Date: Wed, 6 Mar 2024 15:29:46 -0300 Subject: [PATCH 08/15] chore: base `GradeReport` schema setup (migration, schema, context functions) --- lib/lanttern/reporting.ex | 114 +++++++++++++++++- lib/lanttern/reporting/grade_report.ex | 51 ++++++++ .../20240306174041_create_grade_reports.exs | 21 ++++ test/lanttern/reporting_test.exs | 84 +++++++++++++ test/support/fixtures/reporting_fixtures.ex | 24 ++++ 5 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 lib/lanttern/reporting/grade_report.ex create mode 100644 priv/repo/migrations/20240306174041_create_grade_reports.exs diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 7ad45793..6e93c1af 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -9,9 +9,11 @@ defmodule Lanttern.Reporting do alias Lanttern.Utils alias Lanttern.Reporting.ReportCard + alias Lanttern.Reporting.StrandReport alias Lanttern.Reporting.StudentReportCard alias Lanttern.Reporting.ReportCardGradeSubject alias Lanttern.Reporting.ReportCardGradeCycle + alias Lanttern.Reporting.GradeReport alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Schools @@ -193,8 +195,6 @@ defmodule Lanttern.Reporting do ReportCard.changeset(report_card, attrs) end - alias Lanttern.Reporting.StrandReport - @doc """ Returns the list of strand reports. @@ -379,8 +379,6 @@ defmodule Lanttern.Reporting do StrandReport.changeset(strand_report, attrs) end - alias Lanttern.Reporting.StudentReportCard - @doc """ Returns the list of student_report_cards. @@ -766,4 +764,112 @@ defmodule Lanttern.Reporting do def delete_report_card_grade_cycle(%ReportCardGradeCycle{} = report_card_grade_cycle) do Repo.delete(report_card_grade_cycle) end + + @doc """ + Returns the list of grade reports. + + ## Options + + - `:preloads` – preloads associated data + + ## Examples + + iex> list_grade_reports() + [%GradeReport{}, ...] + + """ + def list_grade_reports(opts \\ []) do + GradeReport + |> Repo.all() + |> maybe_preload(opts) + end + + @doc """ + Gets a single grade report. + + Raises `Ecto.NoResultsError` if the grade report does not exist. + + ## Options: + + - `:preloads` – preloads associated data + + ## Examples + + iex> get_grade_report!(123) + %GradeReport{} + + iex> get_grade_report!(456) + ** (Ecto.NoResultsError) + + """ + def get_grade_report!(id, opts \\ []) do + GradeReport + |> Repo.get!(id) + |> maybe_preload(opts) + end + + @doc """ + Creates a grade report. + + ## Examples + + iex> create_grade_report(%{field: value}) + {:ok, %GradeReport{}} + + iex> create_grade_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_grade_report(attrs \\ %{}) do + %GradeReport{} + |> GradeReport.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a grade report. + + ## Examples + + iex> update_grade_report(grade_report, %{field: new_value}) + {:ok, %ReportCard{}} + + iex> update_grade_report(grade_report, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_grade_report(%GradeReport{} = grade_report, attrs) do + grade_report + |> GradeReport.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a grade report. + + ## Examples + + iex> delete_grade_report(grade_report) + {:ok, %ReportCard{}} + + iex> delete_grade_report(grade_report) + {:error, %Ecto.Changeset{}} + + """ + def delete_grade_report(%GradeReport{} = grade_report) do + Repo.delete(grade_report) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking grade report changes. + + ## Examples + + iex> change_grade_report(grade_report) + %Ecto.Changeset{data: %ReportCard{}} + + """ + def change_grade_report(%GradeReport{} = grade_report, attrs \\ %{}) do + GradeReport.changeset(grade_report, attrs) + end end diff --git a/lib/lanttern/reporting/grade_report.ex b/lib/lanttern/reporting/grade_report.ex new file mode 100644 index 00000000..b5a91638 --- /dev/null +++ b/lib/lanttern/reporting/grade_report.ex @@ -0,0 +1,51 @@ +defmodule Lanttern.Reporting.GradeReport do + use Ecto.Schema + import Ecto.Changeset + + alias Lanttern.Grading.Scale + alias Lanttern.Schools.Cycle + alias Lanttern.Taxonomy.Subject + alias Lanttern.Taxonomy.Year + + @type t :: %__MODULE__{ + id: pos_integer(), + info: String.t(), + is_differentiation: boolean(), + school_cycle: Cycle.t(), + school_cycle_id: pos_integer(), + subject: Subject.t(), + subject_id: pos_integer(), + year: Year.t(), + year_id: pos_integer(), + scale: Scale.t(), + scale_id: pos_integer(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "grade_reports" do + field :info, :string + field :is_differentiation, :boolean, default: false + + belongs_to :school_cycle, Cycle + belongs_to :subject, Subject + belongs_to :year, Year + belongs_to :scale, Scale + + timestamps() + end + + @doc false + def changeset(grade_report, attrs) do + grade_report + |> cast(attrs, [ + :info, + :is_differentiation, + :school_cycle_id, + :subject_id, + :year_id, + :scale_id + ]) + |> validate_required([:school_cycle_id, :subject_id, :year_id, :scale_id]) + end +end diff --git a/priv/repo/migrations/20240306174041_create_grade_reports.exs b/priv/repo/migrations/20240306174041_create_grade_reports.exs new file mode 100644 index 00000000..3940678a --- /dev/null +++ b/priv/repo/migrations/20240306174041_create_grade_reports.exs @@ -0,0 +1,21 @@ +defmodule Lanttern.Repo.Migrations.CreateGradeReports do + use Ecto.Migration + + def change do + create table(:grade_reports) do + add :info, :text + add :is_differentiation, :boolean, null: false, default: false + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :year_id, references(:years, on_delete: :nothing), null: false + add :scale_id, references(:grading_scales, on_delete: :nothing), null: false + + timestamps() + end + + create index(:grade_reports, [:school_cycle_id]) + create index(:grade_reports, [:subject_id]) + create index(:grade_reports, [:year_id]) + create index(:grade_reports, [:scale_id]) + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 80f4e96a..c5979e23 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -789,4 +789,88 @@ defmodule Lanttern.ReportingTest do end end end + + describe "grade_reports" do + alias Lanttern.Reporting.GradeReport + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + + @invalid_attrs %{info: "blah", scale_id: nil} + + test "list_grade_reports/1 returns all grade_reports" do + grade_report = grade_report_fixture() + assert Reporting.list_grade_reports() == [grade_report] + end + + test "get_grade_report!/2 returns the grade_report with given id" do + grade_report = grade_report_fixture() + assert Reporting.get_grade_report!(grade_report.id) == grade_report + end + + test "get_grade_report!/2 with preloads returns the grade report with given id and preloaded data" do + school_cycle = SchoolsFixtures.cycle_fixture() + grade_report = grade_report_fixture(%{school_cycle_id: school_cycle.id}) + + expected = Reporting.get_grade_report!(grade_report.id, preloads: :school_cycle) + + assert expected.id == grade_report.id + assert expected.school_cycle == school_cycle + end + + test "create_grade_report/1 with valid data creates a grade_report" do + school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() + subject = Lanttern.TaxonomyFixtures.subject_fixture() + year = Lanttern.TaxonomyFixtures.year_fixture() + scale = Lanttern.GradingFixtures.scale_fixture() + + valid_attrs = %{ + school_cycle_id: school_cycle.id, + subject_id: subject.id, + year_id: year.id, + scale_id: scale.id + } + + assert {:ok, %GradeReport{} = grade_report} = Reporting.create_grade_report(valid_attrs) + assert grade_report.school_cycle_id == school_cycle.id + assert grade_report.subject_id == subject.id + assert grade_report.year_id == year.id + assert grade_report.scale_id == scale.id + end + + test "create_grade_report/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Reporting.create_grade_report(@invalid_attrs) + end + + test "update_grade_report/2 with valid data updates the grade_report" do + grade_report = grade_report_fixture() + update_attrs = %{info: "some updated info", is_differentiation: "true"} + + assert {:ok, %GradeReport{} = grade_report} = + Reporting.update_grade_report(grade_report, update_attrs) + + assert grade_report.info == "some updated info" + assert grade_report.is_differentiation + end + + test "update_grade_report/2 with invalid data returns error changeset" do + grade_report = grade_report_fixture() + + assert {:error, %Ecto.Changeset{}} = + Reporting.update_grade_report(grade_report, @invalid_attrs) + + assert grade_report == Reporting.get_grade_report!(grade_report.id) + end + + test "delete_grade_report/1 deletes the grade_report" do + grade_report = grade_report_fixture() + assert {:ok, %GradeReport{}} = Reporting.delete_grade_report(grade_report) + assert_raise Ecto.NoResultsError, fn -> Reporting.get_grade_report!(grade_report.id) end + end + + test "change_report_card/1 returns a report_card changeset" do + report_card = report_card_fixture() + assert %Ecto.Changeset{} = Reporting.change_report_card(report_card) + end + end end diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index 53e764dc..f8f6a46d 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -120,4 +120,28 @@ defmodule Lanttern.ReportingFixtures do report_card_grade_cycle end + + @doc """ + Generate a grade report. + """ + def grade_report_fixture(attrs \\ %{}) do + {:ok, grade_report} = + attrs + |> Enum.into(%{ + info: "some info", + school_cycle_id: maybe_gen_school_cycle_id(attrs), + subject_id: maybe_gen_subject_id(attrs), + year_id: maybe_gen_year_id(attrs), + scale_id: maybe_gen_scale_id(attrs) + }) + |> Lanttern.Reporting.create_grade_report() + + grade_report + end + + defp maybe_gen_scale_id(%{scale_id: scale_id} = _attrs), + do: scale_id + + defp maybe_gen_scale_id(_attrs), + do: Lanttern.GradingFixtures.scale_fixture().id end From cb06b52d88b11be47f25e28affb2722890bd0b99 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 7 Mar 2024 14:46:12 -0300 Subject: [PATCH 09/15] chore: adjusted `GradeReport` schema due to architecture changes --- lib/lanttern/reporting/grade_report.ex | 21 ++++--------------- .../20240307173152_adjust_grade_reports.exs | 15 +++++++++++++ test/lanttern/reporting_test.exs | 8 ++----- test/support/fixtures/reporting_fixtures.ex | 3 +-- 4 files changed, 22 insertions(+), 25 deletions(-) create mode 100644 priv/repo/migrations/20240307173152_adjust_grade_reports.exs diff --git a/lib/lanttern/reporting/grade_report.ex b/lib/lanttern/reporting/grade_report.ex index b5a91638..a9913d67 100644 --- a/lib/lanttern/reporting/grade_report.ex +++ b/lib/lanttern/reporting/grade_report.ex @@ -4,19 +4,14 @@ defmodule Lanttern.Reporting.GradeReport do alias Lanttern.Grading.Scale alias Lanttern.Schools.Cycle - alias Lanttern.Taxonomy.Subject - alias Lanttern.Taxonomy.Year @type t :: %__MODULE__{ id: pos_integer(), + name: String.t(), info: String.t(), is_differentiation: boolean(), school_cycle: Cycle.t(), school_cycle_id: pos_integer(), - subject: Subject.t(), - subject_id: pos_integer(), - year: Year.t(), - year_id: pos_integer(), scale: Scale.t(), scale_id: pos_integer(), inserted_at: DateTime.t(), @@ -24,12 +19,11 @@ defmodule Lanttern.Reporting.GradeReport do } schema "grade_reports" do + field :name, :string field :info, :string field :is_differentiation, :boolean, default: false belongs_to :school_cycle, Cycle - belongs_to :subject, Subject - belongs_to :year, Year belongs_to :scale, Scale timestamps() @@ -38,14 +32,7 @@ defmodule Lanttern.Reporting.GradeReport do @doc false def changeset(grade_report, attrs) do grade_report - |> cast(attrs, [ - :info, - :is_differentiation, - :school_cycle_id, - :subject_id, - :year_id, - :scale_id - ]) - |> validate_required([:school_cycle_id, :subject_id, :year_id, :scale_id]) + |> cast(attrs, [:name, :info, :is_differentiation, :school_cycle_id, :scale_id]) + |> validate_required([:name, :school_cycle_id, :scale_id]) end end diff --git a/priv/repo/migrations/20240307173152_adjust_grade_reports.exs b/priv/repo/migrations/20240307173152_adjust_grade_reports.exs new file mode 100644 index 00000000..085e6a36 --- /dev/null +++ b/priv/repo/migrations/20240307173152_adjust_grade_reports.exs @@ -0,0 +1,15 @@ +defmodule Lanttern.Repo.Migrations.AdjustGradeReports do + use Ecto.Migration + + def change do + # "clear" table before migrating + execute "DELETE FROM grade_reports", "" + + alter table(:grade_reports) do + remove :subject_id, references(:subjects, on_delete: :nothing), null: false + remove :year_id, references(:years, on_delete: :nothing), null: false + + add :name, :text, null: false + end + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index c5979e23..decb88a6 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -820,21 +820,17 @@ defmodule Lanttern.ReportingTest do test "create_grade_report/1 with valid data creates a grade_report" do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() - subject = Lanttern.TaxonomyFixtures.subject_fixture() - year = Lanttern.TaxonomyFixtures.year_fixture() scale = Lanttern.GradingFixtures.scale_fixture() valid_attrs = %{ + name: "grade report name abc", school_cycle_id: school_cycle.id, - subject_id: subject.id, - year_id: year.id, scale_id: scale.id } assert {:ok, %GradeReport{} = grade_report} = Reporting.create_grade_report(valid_attrs) + assert grade_report.name == "grade report name abc" assert grade_report.school_cycle_id == school_cycle.id - assert grade_report.subject_id == subject.id - assert grade_report.year_id == year.id assert grade_report.scale_id == scale.id end diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index f8f6a46d..c7c2adcb 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -128,10 +128,9 @@ defmodule Lanttern.ReportingFixtures do {:ok, grade_report} = attrs |> Enum.into(%{ + name: "some name", info: "some info", school_cycle_id: maybe_gen_school_cycle_id(attrs), - subject_id: maybe_gen_subject_id(attrs), - year_id: maybe_gen_year_id(attrs), scale_id: maybe_gen_scale_id(attrs) }) |> Lanttern.Reporting.create_grade_report() From 7ce06d1c12aa23bbed23c25691229aec5f29a6b9 Mon Sep 17 00:00:00 2001 From: endoooo Date: Thu, 7 Mar 2024 18:21:35 -0300 Subject: [PATCH 10/15] feat: created basic grade reports page - added `Reporting.get_grade_report/2` - created `Reporting.GradeReportFormComponent` --- lib/lanttern/reporting.ex | 22 ++- .../live/pages/grading/grade_reports_live.ex | 79 ++++++++++ .../grading/grade_reports_live.html.heex | 98 +++++++++++++ .../live/shared/menu_component.ex | 13 +- .../reporting/grade_report_form_component.ex | 138 ++++++++++++++++++ lib/lanttern_web/router.ex | 6 +- .../pages/grading/grade_reports_live_test.exs | 45 ++++++ 7 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 lib/lanttern_web/live/pages/grading/grade_reports_live.ex create mode 100644 lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex create mode 100644 lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex create mode 100644 test/lanttern_web/live/pages/grading/grade_reports_live_test.exs diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 6e93c1af..0023935a 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -787,12 +787,32 @@ defmodule Lanttern.Reporting do @doc """ Gets a single grade report. - Raises `Ecto.NoResultsError` if the grade report does not exist. + Returns `nil` if the grade report does not exist. ## Options: - `:preloads` – preloads associated data + ## Examples + + iex> get_grade_report!(123) + %GradeReport{} + + iex> get_grade_report!(456) + nil + + """ + def get_grade_report(id, opts \\ []) do + GradeReport + |> Repo.get(id) + |> maybe_preload(opts) + end + + @doc """ + Gets a single grade report. + + Same as `get_grade_report/2`, but raises `Ecto.NoResultsError` if the grade report does not exist. + ## Examples iex> get_grade_report!(123) diff --git a/lib/lanttern_web/live/pages/grading/grade_reports_live.ex b/lib/lanttern_web/live/pages/grading/grade_reports_live.ex new file mode 100644 index 00000000..8437c6d3 --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grade_reports_live.ex @@ -0,0 +1,79 @@ +defmodule LantternWeb.GradeReportsLive do + use LantternWeb, :live_view + + alias Lanttern.Reporting + alias Lanttern.Reporting.GradeReport + + # live components + alias LantternWeb.Reporting.GradeReportFormComponent + + # shared + import LantternWeb.GradingComponents + + # lifecycle + + @impl true + def handle_params(params, _uri, socket) do + grade_reports = + Reporting.list_grade_reports(preloads: [:school_cycle, [scale: :ordinal_values]]) + + socket = + socket + |> stream(:grade_reports, grade_reports) + |> assign(:has_grade_reports, length(grade_reports) > 0) + |> assign_show_grade_report_form(params) + + {:noreply, socket} + end + + defp assign_show_grade_report_form(socket, %{"is_creating" => "true"}) do + socket + |> assign(:grade_report, %GradeReport{}) + |> assign(:form_overlay_title, gettext("Create grade report")) + |> assign(:show_grade_report_form, true) + end + + defp assign_show_grade_report_form(socket, %{"is_editing" => id}) do + cond do + String.match?(id, ~r/[0-9]+/) -> + case Reporting.get_grade_report(id) do + %GradeReport{} = grade_report -> + socket + |> assign(:form_overlay_title, gettext("Edit grade report")) + |> assign(:grade_report, grade_report) + |> assign(:show_grade_report_form, true) + + _ -> + assign(socket, :show_grade_report_form, false) + end + + true -> + assign(socket, :show_grade_report_form, false) + end + end + + defp assign_show_grade_report_form(socket, _), + do: assign(socket, :show_grade_report_form, false) + + # event handlers + + @impl true + def handle_event("delete_grade_report", _params, socket) do + case Reporting.delete_grade_report(socket.assigns.grade_report) do + {:ok, _grade_report} -> + socket = + socket + |> put_flash(:info, gettext("Grade report deleted")) + |> push_navigate(to: ~p"/grading") + + {:noreply, socket} + + {:error, _changeset} -> + socket = + socket + |> put_flash(:error, gettext("Error deleting grade report")) + + {:noreply, socket} + end + end +end diff --git a/lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex b/lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex new file mode 100644 index 00000000..17796e9a --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex @@ -0,0 +1,98 @@ +
+ <.page_title_with_menu><%= gettext("Grade reports") %> +
+

+ <%= gettext("Viewing all grade reports") %> +

+ <.collection_action + type="link" + icon_name="hero-plus-circle" + patch={~p"/grading?is_creating=true"} + > + <%= gettext("Create new grade report") %> + +
+ <%= if @has_grade_reports do %> +
+
+
+

<%= grade_report.name %>

+ <.button + type="button" + theme="ghost" + icon_name="hero-pencil-mini" + phx-click={JS.patch(~p"/grading?is_editing=#{grade_report.id}")} + > + <%= gettext("Edit") %> + +
+
+
+ <.icon name="hero-calendar" class="w-6 h-6 shrink-0 text-ltrn-subtle" /> + <%= gettext("Cycle") %>: <%= grade_report.school_cycle.name %> +
+
+ <.icon name="hero-view-columns" class="w-6 h-6 shrink-0 text-ltrn-subtle" /> + <%= gettext("Scale") %>: <%= grade_report.scale.name %> +
+ <%= for ov <- grade_report.scale.ordinal_values do %> + <.ordinal_value_badge ordinal_value={ov}> + <%= ov.name %> + + <% end %> +
+
+
+ <.markdown :if={grade_report.info} text={grade_report.info} class="mt-6" size="sm" /> +
+
+ <% else %> + <.empty_state class="mt-12"> + <%= gettext("No grade reports created yet") %> + + <% end %> +
+<.slide_over + :if={@show_grade_report_form} + id="grade-report-form-overlay" + show={true} + on_cancel={JS.patch(~p"/grading")} +> + <:title><%= @form_overlay_title %> + <.live_component + module={GradeReportFormComponent} + id={@grade_report.id || :new} + grade_report={@grade_report} + navigate={fn _report_card -> ~p"/grading" end} + hide_submit + /> + <:actions_left :if={@grade_report.id}> + <.button + type="button" + theme="ghost" + phx-click="delete_grade_report" + data-confirm={gettext("Are you sure?")} + > + <%= gettext("Delete") %> + + + <:actions> + <.button + type="button" + theme="ghost" + phx-click={JS.exec("data-cancel", to: "#grade-report-form-overlay")} + > + <%= gettext("Cancel") %> + + <.button type="submit" form="grade-report-form"> + <%= gettext("Save") %> + + + diff --git a/lib/lanttern_web/live/shared/menu_component.ex b/lib/lanttern_web/live/shared/menu_component.ex index 4ac527b7..cc8bf048 100644 --- a/lib/lanttern_web/live/shared/menu_component.ex +++ b/lib/lanttern_web/live/shared/menu_component.ex @@ -31,12 +31,14 @@ defmodule LantternWeb.MenuComponent do <.nav_item active={@active_nav == :curriculum} path={~p"/curriculum"}> <%= gettext("Curriculum") %> - <.nav_item active={@active_nav == :reporting} path={~p"/report_cards"}> - <%= gettext("Reporting") %> + <.nav_item active={@active_nav == :report_cards} path={~p"/report_cards"}> + <%= gettext("Report cards") %> + + <.nav_item active={@active_nav == :grading} path={~p"/grading"}> + <%= gettext("Grading") %> <%!-- use this li as placeholder when nav items % 3 != 0--%>
  • -
  • @@ -282,7 +284,10 @@ defmodule LantternWeb.MenuComponent do :curriculum socket.view in [LantternWeb.ReportCardsLive, LantternWeb.ReportCardLive] -> - :reporting + :report_cards + + socket.view in [LantternWeb.GradeReportsLive] -> + :grading true -> nil diff --git a/lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex b/lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex new file mode 100644 index 00000000..59b248f0 --- /dev/null +++ b/lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex @@ -0,0 +1,138 @@ +defmodule LantternWeb.Reporting.GradeReportFormComponent do + use LantternWeb, :live_component + + alias Lanttern.Reporting + + alias LantternWeb.GradingHelpers + alias LantternWeb.SchoolsHelpers + + @impl true + def render(assigns) do + ~H""" +
    + <.form + for={@form} + id="grade-report-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input + field={@form[:name]} + type="text" + label={gettext("Name")} + class="mb-6" + phx-debounce="1500" + /> + <.input + field={@form[:info]} + type="textarea" + label={gettext("Info")} + phx-debounce="1500" + class="mb-1" + show_optional + /> + <.markdown_supported class="mb-6" /> + <.input + field={@form[:school_cycle_id]} + type="select" + label="Cycle" + options={@cycle_options} + prompt="Select a cycle" + class="mb-6" + /> + <.input + field={@form[:scale_id]} + type="select" + label="Scale" + options={@scale_options} + prompt="Select a scale" + class={if !@hide_submit, do: "mb-6"} + /> + <.button :if={!@hide_submit} phx-disable-with={gettext("Saving...")}> + <%= gettext("Save grade report") %> + + +
    + """ + end + + @impl true + def mount(socket) do + scale_options = GradingHelpers.generate_scale_options() + cycle_options = SchoolsHelpers.generate_cycle_options() + + socket = + socket + |> assign(:class, nil) + |> assign(:hide_submit, false) + |> assign(:scale_options, scale_options) + |> assign(:cycle_options, cycle_options) + + {:ok, socket} + end + + @impl true + def update(%{grade_report: grade_report} = assigns, socket) do + changeset = Reporting.change_grade_report(grade_report) + + socket = + socket + |> assign(assigns) + |> assign_form(changeset) + + {:ok, socket} + end + + @impl true + def handle_event("validate", %{"grade_report" => grade_report_params}, socket) do + changeset = + socket.assigns.grade_report + |> Reporting.change_grade_report(grade_report_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"grade_report" => grade_report_params}, socket) do + save_grade_report(socket, socket.assigns.grade_report.id, grade_report_params) + end + + defp save_grade_report(socket, nil, grade_report_params) do + case Reporting.create_grade_report(grade_report_params) do + {:ok, grade_report} -> + notify_parent(__MODULE__, {:saved, grade_report}, socket.assigns) + + socket = + socket + |> put_flash(:info, "Grade report created successfully") + |> handle_navigation(grade_report) + + {:noreply, socket} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_grade_report(socket, _grade_report_id, grade_report_params) do + case Reporting.update_grade_report(socket.assigns.grade_report, grade_report_params) do + {:ok, grade_report} -> + notify_parent(__MODULE__, {:saved, grade_report}, socket.assigns) + + socket = + socket + |> put_flash(:info, "Grade report updated successfully") + |> handle_navigation(grade_report) + + {:noreply, socket} + + {: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 +end diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 288a4d92..7b10b372 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -95,7 +95,7 @@ defmodule LantternWeb.Router do live "/curriculum/:id", CurriculumLive, :show live "/curriculum/component/:id", CurriculumComponentLive, :show - # reporting + # report cards live "/report_cards", ReportCardsLive, :index live "/report_cards/new", ReportCardsLive, :new @@ -107,6 +107,10 @@ defmodule LantternWeb.Router do live "/student_report_card/:id/strand_report/:strand_report_id", StudentStrandReportLive, :show + + # grading + + live "/grading", GradeReportsLive, :index end end diff --git a/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs b/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs new file mode 100644 index 00000000..b9732b48 --- /dev/null +++ b/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs @@ -0,0 +1,45 @@ +defmodule LantternWeb.GradeReportsLiveTest do + use LantternWeb.ConnCase + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.GradingFixtures + + @live_view_path "/grading" + + setup [:register_and_log_in_user] + + describe "Grade reports live view basic navigation" do + test "disconnected and connected mount", %{conn: conn} do + conn = get(conn, @live_view_path) + + assert html_response(conn, 200) =~ ~r"

    \s*Grade reports\s*<\/h1>" + + {:ok, _view, _html} = live(conn) + end + + test "list grade reports", %{conn: conn} do + cycle = SchoolsFixtures.cycle_fixture(%{name: "Some cycle 000"}) + scale = GradingFixtures.scale_fixture(%{name: "Some scale AZ", type: "ordinal"}) + + _ordinal_value = + GradingFixtures.ordinal_value_fixture(%{name: "Ordinal value A", scale_id: scale.id}) + + _grade_report = + grade_report_fixture(%{ + name: "Some grade report ABC", + info: "Some info XYZ", + school_cycle_id: cycle.id, + scale_id: scale.id + }) + + {:ok, view, _html} = live(conn, @live_view_path) + + assert view |> has_element?("h3", "Some grade report ABC") + assert view |> has_element?("p", "Some info XYZ") + assert view |> has_element?("div", "Some cycle 000") + assert view |> has_element?("div", "Some scale AZ") + assert view |> has_element?("span", "Ordinal value A") + end + end +end From 7c8604a0f3c2052563eb27b23cc4bf1aac940f3c Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 8 Mar 2024 12:51:24 -0300 Subject: [PATCH 11/15] feat: added support to grades report cycles and subjects - refactor: renamed `GradeReport` schema to `GradesReport` (gradeS). all function names and database were updated to align with the new name - added grades report cycles and subjects schemas and functions to `Reporting` context, based on existing report card grade cycles and subjects (which will be removed soon) - added grades report cycles and subjects management UI in grading view --- lib/lanttern/reporting.ex | 259 +++++++++++-- .../{grade_report.ex => grades_report.ex} | 8 +- lib/lanttern/reporting/grades_report_cycle.ex | 40 ++ .../reporting/grades_report_subject.ex | 40 ++ .../components/core_components.ex | 5 +- .../components/form_components.ex | 13 +- .../live/pages/grading/grade_reports_live.ex | 79 ---- ...des_report_grid_setup_overlay_component.ex | 342 ++++++++++++++++++ .../live/pages/grading/grades_reports_live.ex | 197 ++++++++++ ...tml.heex => grades_reports_live.html.heex} | 54 +-- .../live/shared/menu_component.ex | 2 +- ...ent.ex => grades_report_form_component.ex} | 52 +-- lib/lanttern_web/router.ex | 2 +- ...rename_grade_reports_to_grades_reports.exs | 28 ++ ...reate_grade_report_cycles_and_subjects.exs | 27 ++ test/lanttern/reporting_test.exs | 288 +++++++++++++-- .../pages/grading/grade_reports_live_test.exs | 8 +- test/support/fixtures/reporting_fixtures.ex | 44 ++- 18 files changed, 1282 insertions(+), 206 deletions(-) rename lib/lanttern/reporting/{grade_report.ex => grades_report.ex} (86%) create mode 100644 lib/lanttern/reporting/grades_report_cycle.ex create mode 100644 lib/lanttern/reporting/grades_report_subject.ex delete mode 100644 lib/lanttern_web/live/pages/grading/grade_reports_live.ex create mode 100644 lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex create mode 100644 lib/lanttern_web/live/pages/grading/grades_reports_live.ex rename lib/lanttern_web/live/pages/grading/{grade_reports_live.html.heex => grades_reports_live.html.heex} (58%) rename lib/lanttern_web/live/shared/reporting/{grade_report_form_component.ex => grades_report_form_component.ex} (60%) create mode 100644 priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs create mode 100644 priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 0023935a..9d3a6449 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -13,7 +13,9 @@ defmodule Lanttern.Reporting do alias Lanttern.Reporting.StudentReportCard alias Lanttern.Reporting.ReportCardGradeSubject alias Lanttern.Reporting.ReportCardGradeCycle - alias Lanttern.Reporting.GradeReport + alias Lanttern.Reporting.GradesReport + alias Lanttern.Reporting.GradesReportSubject + alias Lanttern.Reporting.GradesReportCycle alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Schools @@ -774,12 +776,12 @@ defmodule Lanttern.Reporting do ## Examples - iex> list_grade_reports() - [%GradeReport{}, ...] + iex> list_grades_reports() + [%GradesReport{}, ...] """ - def list_grade_reports(opts \\ []) do - GradeReport + def list_grades_reports(opts \\ []) do + GradesReport |> Repo.all() |> maybe_preload(opts) end @@ -795,15 +797,15 @@ defmodule Lanttern.Reporting do ## Examples - iex> get_grade_report!(123) - %GradeReport{} + iex> get_grades_report!(123) + %GradesReport{} - iex> get_grade_report!(456) + iex> get_grades_report!(456) nil """ - def get_grade_report(id, opts \\ []) do - GradeReport + def get_grades_report(id, opts \\ []) do + GradesReport |> Repo.get(id) |> maybe_preload(opts) end @@ -811,19 +813,19 @@ defmodule Lanttern.Reporting do @doc """ Gets a single grade report. - Same as `get_grade_report/2`, but raises `Ecto.NoResultsError` if the grade report does not exist. + Same as `get_grades_report/2`, but raises `Ecto.NoResultsError` if the grade report does not exist. ## Examples - iex> get_grade_report!(123) - %GradeReport{} + iex> get_grades_report!(123) + %GradesReport{} - iex> get_grade_report!(456) + iex> get_grades_report!(456) ** (Ecto.NoResultsError) """ - def get_grade_report!(id, opts \\ []) do - GradeReport + def get_grades_report!(id, opts \\ []) do + GradesReport |> Repo.get!(id) |> maybe_preload(opts) end @@ -833,16 +835,16 @@ defmodule Lanttern.Reporting do ## Examples - iex> create_grade_report(%{field: value}) - {:ok, %GradeReport{}} + iex> create_grades_report(%{field: value}) + {:ok, %GradesReport{}} - iex> create_grade_report(%{field: bad_value}) + iex> create_grades_report(%{field: bad_value}) {:error, %Ecto.Changeset{}} """ - def create_grade_report(attrs \\ %{}) do - %GradeReport{} - |> GradeReport.changeset(attrs) + def create_grades_report(attrs \\ %{}) do + %GradesReport{} + |> GradesReport.changeset(attrs) |> Repo.insert() end @@ -851,16 +853,16 @@ defmodule Lanttern.Reporting do ## Examples - iex> update_grade_report(grade_report, %{field: new_value}) + iex> update_grades_report(grades_report, %{field: new_value}) {:ok, %ReportCard{}} - iex> update_grade_report(grade_report, %{field: bad_value}) + iex> update_grades_report(grades_report, %{field: bad_value}) {:error, %Ecto.Changeset{}} """ - def update_grade_report(%GradeReport{} = grade_report, attrs) do - grade_report - |> GradeReport.changeset(attrs) + def update_grades_report(%GradesReport{} = grades_report, attrs) do + grades_report + |> GradesReport.changeset(attrs) |> Repo.update() end @@ -869,15 +871,15 @@ defmodule Lanttern.Reporting do ## Examples - iex> delete_grade_report(grade_report) + iex> delete_grades_report(grades_report) {:ok, %ReportCard{}} - iex> delete_grade_report(grade_report) + iex> delete_grades_report(grades_report) {:error, %Ecto.Changeset{}} """ - def delete_grade_report(%GradeReport{} = grade_report) do - Repo.delete(grade_report) + def delete_grades_report(%GradesReport{} = grades_report) do + Repo.delete(grades_report) end @doc """ @@ -885,11 +887,200 @@ defmodule Lanttern.Reporting do ## Examples - iex> change_grade_report(grade_report) + iex> change_grades_report(grades_report) %Ecto.Changeset{data: %ReportCard{}} """ - def change_grade_report(%GradeReport{} = grade_report, attrs \\ %{}) do - GradeReport.changeset(grade_report, attrs) + def change_grades_report(%GradesReport{} = grades_report, attrs \\ %{}) do + GradesReport.changeset(grades_report, attrs) end + + @doc """ + Returns the list of grades report subjects. + + Results are ordered by position and preloaded subjects. + + ## Examples + + iex> list_grades_report_subjects(1) + [%GradesReportSubject{}, ...] + + """ + @spec list_grades_report_subjects(grades_report_id :: integer()) :: [ + GradesReportSubject.t() + ] + + def list_grades_report_subjects(grades_report_id) do + from(grs in GradesReportSubject, + order_by: grs.position, + join: s in assoc(grs, :subject), + preload: [subject: s], + where: grs.grades_report_id == ^grades_report_id + ) + |> Repo.all() + end + + @doc """ + Add a subject to a grades report. + + Result has subject preloaded. + + ## Examples + + iex> add_subject_to_grades_report(%{field: value}) + {:ok, %GradesReportSubject{}} + + iex> add_subject_to_grades_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + + @spec add_subject_to_grades_report(map()) :: + {:ok, GradesReportSubject.t()} | {:error, Ecto.Changeset.t()} + + def add_subject_to_grades_report(attrs \\ %{}) do + %GradesReportSubject{} + |> GradesReportSubject.changeset(attrs) + |> set_grades_report_subject_position() + |> Repo.insert() + |> maybe_preload(preloads: :subject) + end + + # skip if not valid + defp set_grades_report_subject_position(%Ecto.Changeset{valid?: false} = changeset), + do: changeset + + # skip if changeset already has position change + defp set_grades_report_subject_position( + %Ecto.Changeset{changes: %{position: _position}} = changeset + ), + do: changeset + + defp set_grades_report_subject_position(%Ecto.Changeset{} = changeset) do + grades_report_id = + Ecto.Changeset.get_field(changeset, :grades_report_id) + + position = + from( + grs in GradesReportSubject, + where: grs.grades_report_id == ^grades_report_id, + select: grs.position, + order_by: [desc: grs.position], + limit: 1 + ) + |> Repo.one() + |> case do + nil -> 0 + pos -> pos + 1 + end + + changeset + |> Ecto.Changeset.put_change(:position, position) + end + + @doc """ + Update grades report subjects positions based on ids list order. + + ## Examples + + iex> update_grades_report_subjects_positions([3, 2, 1]) + :ok + + """ + @spec update_grades_report_subjects_positions([integer()]) :: :ok | {:error, String.t()} + def update_grades_report_subjects_positions(grades_report_subjects_ids), + do: Utils.update_positions(GradesReportSubject, grades_report_subjects_ids) + + @doc """ + Deletes a grades report subject. + + ## Examples + + iex> delete_grades_report_subject(grades_report_subject) + {:ok, %GradesReportSubject{}} + + iex> delete_grades_report_subject(grades_report_subject) + {:error, %Ecto.Changeset{}} + + """ + def delete_grades_report_subject(%GradesReportSubject{} = grades_report_subject), + do: Repo.delete(grades_report_subject) + + @doc """ + Returns the list of grades report cycles. + + Results are ordered asc by cycle `end_at` and desc by cycle `start_at`, and have preloaded school cycles. + + ## Examples + + iex> list_grades_report_cycles(1) + [%GradesReportCycle{}, ...] + + """ + @spec list_grades_report_cycles(grades_report_id :: integer()) :: [ + GradesReportCycle.t() + ] + + def list_grades_report_cycles(grades_report_id) do + from(grc in GradesReportCycle, + join: sc in assoc(grc, :school_cycle), + preload: [school_cycle: sc], + where: grc.grades_report_id == ^grades_report_id, + order_by: [asc: sc.end_at, desc: sc.start_at] + ) + |> Repo.all() + end + + @doc """ + Add a cycle to a grades report. + + ## Examples + + iex> add_cycle_to_grades_report(%{field: value}) + {:ok, %GradesReportCycle{}} + + iex> add_cycle_to_grades_report(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + """ + + @spec add_cycle_to_grades_report(map()) :: + {:ok, GradesReportCycle.t()} | {:error, Ecto.Changeset.t()} + + def add_cycle_to_grades_report(attrs \\ %{}) do + %GradesReportCycle{} + |> GradesReportCycle.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a grades_report_cycle. + + ## Examples + + iex> update_grades_report_cycle(grades_report_cycle, %{field: new_value}) + {:ok, %GradesReportCycle{}} + + iex> update_grades_report_cycle(grades_report_cycle, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_grades_report_cycle(%GradesReportCycle{} = grades_report_cycle, attrs) do + grades_report_cycle + |> GradesReportCycle.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a grades report cycle. + + ## Examples + + iex> delete_grades_report_cycle(grades_report_cycle) + {:ok, %GradesReportCycle{}} + + iex> delete_grades_report_cycle(grades_report_cycle) + {:error, %Ecto.Changeset{}} + + """ + def delete_grades_report_cycle(%GradesReportCycle{} = grades_report_cycle), + do: Repo.delete(grades_report_cycle) end diff --git a/lib/lanttern/reporting/grade_report.ex b/lib/lanttern/reporting/grades_report.ex similarity index 86% rename from lib/lanttern/reporting/grade_report.ex rename to lib/lanttern/reporting/grades_report.ex index a9913d67..cc395cd9 100644 --- a/lib/lanttern/reporting/grade_report.ex +++ b/lib/lanttern/reporting/grades_report.ex @@ -1,4 +1,4 @@ -defmodule Lanttern.Reporting.GradeReport do +defmodule Lanttern.Reporting.GradesReport do use Ecto.Schema import Ecto.Changeset @@ -18,7 +18,7 @@ defmodule Lanttern.Reporting.GradeReport do updated_at: DateTime.t() } - schema "grade_reports" do + schema "grades_reports" do field :name, :string field :info, :string field :is_differentiation, :boolean, default: false @@ -30,8 +30,8 @@ defmodule Lanttern.Reporting.GradeReport do end @doc false - def changeset(grade_report, attrs) do - grade_report + def changeset(grades_report, attrs) do + grades_report |> cast(attrs, [:name, :info, :is_differentiation, :school_cycle_id, :scale_id]) |> validate_required([:name, :school_cycle_id, :scale_id]) end diff --git a/lib/lanttern/reporting/grades_report_cycle.ex b/lib/lanttern/reporting/grades_report_cycle.ex new file mode 100644 index 00000000..686100b4 --- /dev/null +++ b/lib/lanttern/reporting/grades_report_cycle.ex @@ -0,0 +1,40 @@ +defmodule Lanttern.Reporting.GradesReportCycle do + use Ecto.Schema + import Ecto.Changeset + + import LantternWeb.Gettext + + alias Lanttern.Schools.Cycle + alias Lanttern.Reporting.GradesReport + + @type t :: %__MODULE__{ + id: pos_integer(), + weight: float(), + school_cycle: Cycle.t(), + school_cycle_id: pos_integer(), + grades_report: GradesReport.t(), + grades_report_id: pos_integer(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "grades_report_cycles" do + field :weight, :float, default: 1.0 + + belongs_to :school_cycle, Cycle + belongs_to :grades_report, GradesReport + + timestamps() + end + + @doc false + def changeset(grades_report_cycle, attrs) do + grades_report_cycle + |> cast(attrs, [:weight, :school_cycle_id, :grades_report_id]) + |> validate_required([:school_cycle_id, :grades_report_id]) + |> unique_constraint(:school_cycle_id, + name: "grades_report_cycles_grades_report_id_school_cycle_id_index", + message: gettext("Cycle already added to this grade report") + ) + end +end diff --git a/lib/lanttern/reporting/grades_report_subject.ex b/lib/lanttern/reporting/grades_report_subject.ex new file mode 100644 index 00000000..7e0f4e26 --- /dev/null +++ b/lib/lanttern/reporting/grades_report_subject.ex @@ -0,0 +1,40 @@ +defmodule Lanttern.Reporting.GradesReportSubject do + use Ecto.Schema + import Ecto.Changeset + + import LantternWeb.Gettext + + alias Lanttern.Taxonomy.Subject + alias Lanttern.Reporting.GradesReport + + @type t :: %__MODULE__{ + id: pos_integer(), + position: non_neg_integer(), + subject: Subject.t(), + subject_id: pos_integer(), + grades_report: GradesReport.t(), + grades_report_id: pos_integer(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + schema "grades_report_subjects" do + field :position, :integer, default: 0 + + belongs_to :subject, Subject + belongs_to :grades_report, GradesReport + + timestamps() + end + + @doc false + def changeset(grades_report_subject, attrs) do + grades_report_subject + |> cast(attrs, [:position, :subject_id, :grades_report_id]) + |> validate_required([:subject_id, :grades_report_id]) + |> unique_constraint(:subject_id, + name: "grades_report_subjects_grades_report_id_subject_id_index", + message: gettext("Cycle already added to this grade report") + ) + end +end diff --git a/lib/lanttern_web/components/core_components.ex b/lib/lanttern_web/components/core_components.ex index f6f5f3f2..238cf584 100644 --- a/lib/lanttern_web/components/core_components.ex +++ b/lib/lanttern_web/components/core_components.ex @@ -232,7 +232,7 @@ defmodule LantternWeb.CoreComponents do """ def get_button_styles(theme \\ "default", size \\ "normal", rounded \\ false) do [ - "inline-flex items-center font-display text-sm font-bold", + "inline-flex items-center justify-center font-display text-sm font-bold", if(size == "sm", do: "gap-1 p-1", else: "gap-2 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", @@ -1029,6 +1029,7 @@ defmodule LantternWeb.CoreComponents do @doc """ Renders a sortable card """ + attr :id, :string, default: nil attr :class, :any, default: nil attr :is_move_up_disabled, :boolean, default: false attr :on_move_up, JS, required: true @@ -1039,7 +1040,7 @@ defmodule LantternWeb.CoreComponents do def sortable_card(assigns) do ~H""" -
    +
    <%= render_slot(@inner_block) %>
    diff --git a/lib/lanttern_web/components/form_components.ex b/lib/lanttern_web/components/form_components.ex index e180d25b..5ab6ff5f 100644 --- a/lib/lanttern_web/components/form_components.ex +++ b/lib/lanttern_web/components/form_components.ex @@ -99,9 +99,11 @@ defmodule LantternWeb.FormComponents do include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength multiple pattern placeholder readonly required rows size step) - slot :inner_block slot :custom_label + slot :description, + doc: "works for type select, textarea and text variations (e.g. number, email)" + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns |> assign(field: nil, id: assigns.id || field.id) @@ -175,6 +177,9 @@ defmodule LantternWeb.FormComponents do > <%= @label || render_slot(@custom_label) %> +
    + <%= render_slot(@description) %> +
    <.select id={@id} name={@name} @@ -219,6 +224,9 @@ defmodule LantternWeb.FormComponents do > <%= @label || render_slot(@custom_label) %> +
    + <%= render_slot(@description) %> +
    <.textarea id={@id} name={@name} errors={@errors} value={@value} {@rest} /> <.error :for={msg <- @errors}><%= msg %>
    @@ -238,6 +246,9 @@ defmodule LantternWeb.FormComponents do > <%= @label || render_slot(@custom_label) %> +
    + <%= render_slot(@description) %> +
    <.base_input type={@type} name={@name} id={@id} value={@value} errors={@errors} {@rest} /> <.error :for={msg <- @errors}><%= msg %>
    diff --git a/lib/lanttern_web/live/pages/grading/grade_reports_live.ex b/lib/lanttern_web/live/pages/grading/grade_reports_live.ex deleted file mode 100644 index 8437c6d3..00000000 --- a/lib/lanttern_web/live/pages/grading/grade_reports_live.ex +++ /dev/null @@ -1,79 +0,0 @@ -defmodule LantternWeb.GradeReportsLive do - use LantternWeb, :live_view - - alias Lanttern.Reporting - alias Lanttern.Reporting.GradeReport - - # live components - alias LantternWeb.Reporting.GradeReportFormComponent - - # shared - import LantternWeb.GradingComponents - - # lifecycle - - @impl true - def handle_params(params, _uri, socket) do - grade_reports = - Reporting.list_grade_reports(preloads: [:school_cycle, [scale: :ordinal_values]]) - - socket = - socket - |> stream(:grade_reports, grade_reports) - |> assign(:has_grade_reports, length(grade_reports) > 0) - |> assign_show_grade_report_form(params) - - {:noreply, socket} - end - - defp assign_show_grade_report_form(socket, %{"is_creating" => "true"}) do - socket - |> assign(:grade_report, %GradeReport{}) - |> assign(:form_overlay_title, gettext("Create grade report")) - |> assign(:show_grade_report_form, true) - end - - defp assign_show_grade_report_form(socket, %{"is_editing" => id}) do - cond do - String.match?(id, ~r/[0-9]+/) -> - case Reporting.get_grade_report(id) do - %GradeReport{} = grade_report -> - socket - |> assign(:form_overlay_title, gettext("Edit grade report")) - |> assign(:grade_report, grade_report) - |> assign(:show_grade_report_form, true) - - _ -> - assign(socket, :show_grade_report_form, false) - end - - true -> - assign(socket, :show_grade_report_form, false) - end - end - - defp assign_show_grade_report_form(socket, _), - do: assign(socket, :show_grade_report_form, false) - - # event handlers - - @impl true - def handle_event("delete_grade_report", _params, socket) do - case Reporting.delete_grade_report(socket.assigns.grade_report) do - {:ok, _grade_report} -> - socket = - socket - |> put_flash(:info, gettext("Grade report deleted")) - |> push_navigate(to: ~p"/grading") - - {:noreply, socket} - - {:error, _changeset} -> - socket = - socket - |> put_flash(:error, gettext("Error deleting grade report")) - - {:noreply, socket} - end - end -end diff --git a/lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex b/lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex new file mode 100644 index 00000000..2acf6ea0 --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grades_report_grid_setup_overlay_component.ex @@ -0,0 +1,342 @@ +defmodule LantternWeb.ReportCardLive.GradesReportGridSetupOverlayComponent do + use LantternWeb, :live_component + + alias Lanttern.Reporting + alias Lanttern.Reporting.GradesReportCycle + alias Lanttern.Schools + alias Lanttern.Taxonomy + + import Lanttern.Utils, only: [swap: 3] + + @impl true + def render(assigns) do + ~H""" +
    + <.slide_over id={@id} show={true} on_cancel={@on_cancel}> + <:title> + <%= gettext("%{grades_report} grid setup", grades_report: @grades_report.name) %> + +
    <%= gettext("Grid sub cycles") %>
    +
    + <.badge_button + :for={cycle <- @cycles} + theme={if cycle.id in @selected_cycles_ids, do: "primary", else: "default"} + icon_name={ + if cycle.id in @selected_cycles_ids, do: "hero-check-mini", else: "hero-plus-mini" + } + phx-click={JS.push("toggle_cycle", value: %{"id" => cycle.id}, target: @myself)} + > + <%= cycle.name %> + +
    + <%= if @grades_report_cycles == [] do %> +
    + <%= gettext("No sub cycles linked") %> +
    + <% else %> +
    + <%= gettext("Sub cycle") %> + <%= gettext("Grading weight") %> +
    + <.grades_report_cycle_form + :for={grades_report_cycle <- @grades_report_cycles} + id={"grades-report-cycle-#{grades_report_cycle.id}"} + grades_report_cycle={grades_report_cycle} + myself={@myself} + /> + <% end %> +
    <%= gettext("Grid subjects") %>
    +
    + <.badge_button + :for={subject <- @subjects} + theme={if subject.id in @selected_subjects_ids, do: "primary", else: "default"} + icon_name={ + if subject.id in @selected_subjects_ids, + do: "hero-check-mini", + else: "hero-plus-mini" + } + phx-click={JS.push("toggle_subject", value: %{"id" => subject.id}, target: @myself)} + > + <%= subject.name %> + +
    + <%= if @sortable_grades_report_subjects == [] do %> +
    + <%= gettext("No subjects linked") %> +
    + <% else %> + <.sortable_card + :for={{grades_report_subject, i} <- @sortable_grades_report_subjects} + id={"sortable-grades-report-subject-#{grades_report_subject.id}"} + class="mt-4" + is_move_up_disabled={i == 0} + on_move_up={ + JS.push("swap_grades_report_subjects_position", + value: %{from: i, to: i - 1}, + target: @myself + ) + } + is_move_down_disabled={i + 1 == length(@sortable_grades_report_subjects)} + on_move_down={ + JS.push("swap_grades_report_subjects_position", + value: %{from: i, to: i + 1}, + target: @myself + ) + } + > + <%= grades_report_subject.subject.name %> + + <% end %> + +
    + """ + end + + # function components + + attr :id, :string, required: true + attr :grades_report_cycle, GradesReportCycle, required: true + attr :myself, :any, required: true + + def grades_report_cycle_form(assigns) do + form = + assigns.grades_report_cycle + |> GradesReportCycle.changeset(%{}) + |> to_form(as: "grades_report_cycle_#{assigns.grades_report_cycle.id}") + + # we use :as option to avoid using hidden id input (which is easy to "hack") + + assigns = + assigns + |> assign(:form, form) + + ~H""" + <.form + id={@id} + for={@form} + class="flex items-center justify-between p-4 rounded mt-4 bg-white shadow-lg" + phx-change={JS.push("update_grades_report_cycle_weight", target: @myself)} + > + <%= @grades_report_cycle.school_cycle.name %> + + + """ + end + + # lifecycle + + @impl true + def update(assigns, socket) do + %{grades_report: grades_report} = assigns + + cycles = + Schools.list_cycles() + |> Enum.filter(&(&1.id != grades_report.school_cycle_id)) + + grades_report_cycles = Reporting.list_grades_report_cycles(grades_report.id) + selected_cycles_ids = grades_report_cycles |> Enum.map(& &1.school_cycle_id) + + subjects = Taxonomy.list_subjects() + grades_report_subjects = Reporting.list_grades_report_subjects(grades_report.id) + selected_subjects_ids = grades_report_subjects |> Enum.map(& &1.subject.id) + sortable_grades_report_subjects = grades_report_subjects |> Enum.with_index() + + socket = + socket + |> assign(assigns) + |> assign(:cycles, cycles) + |> assign(:grades_report_cycles, grades_report_cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + |> assign(:subjects, subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + + {:ok, socket} + end + + # event handlers + + @impl true + def handle_event("toggle_cycle", %{"id" => cycle_id}, socket) do + socket = + case cycle_id in socket.assigns.selected_cycles_ids do + true -> remove_grades_report_cycle(socket, cycle_id) + false -> add_grades_report_cycle(socket, cycle_id) + end + + {:noreply, socket} + end + + def handle_event("toggle_subject", %{"id" => subject_id}, socket) do + # get grades report subjects without index + # (both functions — add and remove — will need it) + grades_report_subjects = + socket.assigns.sortable_grades_report_subjects + |> Enum.map(fn {grades_report_subject, _i} -> grades_report_subject end) + + socket = + case subject_id in socket.assigns.selected_subjects_ids do + true -> remove_grades_report_subject(socket, grades_report_subjects, subject_id) + false -> add_grades_report_subject(socket, grades_report_subjects, subject_id) + end + + {:noreply, socket} + end + + def handle_event("swap_grades_report_subjects_position", %{"from" => i, "to" => j}, socket) do + sortable_grades_report_subjects = + socket.assigns.sortable_grades_report_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject end) + |> swap(i, j) + |> Enum.with_index() + + sortable_grades_report_subjects + |> Enum.map(fn {grade_subject, _i} -> grade_subject.id end) + |> Reporting.update_grades_report_subjects_positions() + |> case do + :ok -> + socket = + socket + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + + {:noreply, socket} + + {:error, msg} -> + {:noreply, put_flash(socket, :error, msg)} + end + end + + def handle_event("update_grades_report_cycle_weight", params, socket) do + # we use :as option to avoid using hidden id input (which is easy to "hack") + # here we need to "extract" the id from params key — which we do with reduce + {grades_report_cycle, weight} = + Enum.reduce(params, fn + {"grades_report_cycle_" <> id, %{"weight" => weight_str}}, _acc -> + grades_report_cycle = + socket.assigns.grades_report_cycles + |> Enum.find(&("#{&1.id}" == id)) + + weight = + case Float.parse(weight_str) do + :error -> grades_report_cycle.weight + {weight, _} -> weight + end + + {grades_report_cycle, weight} + + _, acc -> + acc + end) + + Reporting.update_grades_report_cycle(grades_report_cycle, %{weight: weight}) + |> case do + {:ok, _grades_report_cycle} -> + {:noreply, socket} + + {:error, _changeset} -> + {:noreply, + put_flash(socket, :error, gettext("Error updating grades report cycle weight"))} + end + end + + defp add_grades_report_cycle(socket, cycle_id) do + %{ + grades_report_id: socket.assigns.grades_report.id, + school_cycle_id: cycle_id + } + |> Reporting.add_cycle_to_grades_report() + |> case do + {:ok, _grades_report_cycle} -> + grades_report_cycles = + Reporting.list_grades_report_cycles(socket.assigns.grades_report.id) + + selected_cycles_ids = grades_report_cycles |> Enum.map(& &1.school_cycle_id) + + socket + |> assign(:grades_report_cycles, grades_report_cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error adding cycle to grades report")) + end + end + + defp remove_grades_report_cycle(socket, cycle_id) do + socket.assigns.grades_report_cycles + |> Enum.find(&(&1.school_cycle_id == cycle_id)) + |> Reporting.delete_grades_report_cycle() + |> case do + {:ok, _grades_report_cycle} -> + grades_report_cycles = + socket.assigns.grades_report_cycles + |> Enum.filter(&(&1.school_cycle_id != cycle_id)) + + selected_cycles_ids = + socket.assigns.selected_cycles_ids + |> Enum.filter(&(&1 != cycle_id)) + + socket + |> assign(:grades_report_cycles, grades_report_cycles) + |> assign(:selected_cycles_ids, selected_cycles_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error removing cycle from grades report")) + end + end + + defp add_grades_report_subject(socket, grades_report_subjects, subject_id) do + %{ + grades_report_id: socket.assigns.grades_report.id, + subject_id: subject_id + } + |> Reporting.add_subject_to_grades_report() + |> case do + {:ok, grades_report_subject} -> + sortable_grades_report_subjects = + (grades_report_subjects ++ [grades_report_subject]) + |> Enum.with_index() + + selected_subjects_ids = + [grades_report_subject.subject_id | socket.assigns.selected_subjects_ids] + + socket + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error adding subject to grades report")) + end + end + + defp remove_grades_report_subject(socket, grades_report_subjects, subject_id) do + grades_report_subjects + |> Enum.find(&(&1.subject_id == subject_id)) + |> Reporting.delete_grades_report_subject() + |> case do + {:ok, grades_report_subject} -> + sortable_grades_report_subjects = + grades_report_subjects + |> Enum.filter(&(&1.id != grades_report_subject.id)) + |> Enum.with_index() + + selected_subjects_ids = + sortable_grades_report_subjects + |> Enum.map(fn {grs, _i} -> grs.subject_id end) + + socket + |> assign(:sortable_grades_report_subjects, sortable_grades_report_subjects) + |> assign(:selected_subjects_ids, selected_subjects_ids) + + {:error, _changeset} -> + put_flash(socket, :error, gettext("Error removing subject from grades report")) + end + end +end diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.ex b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex new file mode 100644 index 00000000..9e6dad2c --- /dev/null +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex @@ -0,0 +1,197 @@ +defmodule LantternWeb.GradesReportsLive do + use LantternWeb, :live_view + + alias Lanttern.Reporting + alias Lanttern.Reporting.GradesReport + + # local view components + alias LantternWeb.ReportCardLive.GradesReportGridSetupOverlayComponent + + # live components + alias LantternWeb.Reporting.GradesReportFormComponent + + # shared + import LantternWeb.GradingComponents + + # function components + + attr :grades_report_id, :integer, required: true + attr :grades_report_subjects, :list, required: true + attr :grades_report_cycles, :list, required: true + + def grades_report_grid(assigns) do + grid_template_columns_style = + case length(assigns.grades_report_cycles) do + n when n > 1 -> + "grid-template-columns: 160px repeat(#{n}, minmax(0, 1fr))" + + _ -> + "grid-template-columns: 160px minmax(0, 1fr)" + end + + grid_column_style = + case length(assigns.grades_report_cycles) do + 0 -> "grid-column: span 2 / span 2" + n -> "grid-column: span #{n + 1} / span #{n + 1}" + end + + assigns = + assigns + |> assign(:grid_template_columns_style, grid_template_columns_style) + |> assign(:grid_column_style, grid_column_style) + |> assign(:has_subjects, length(assigns.grades_report_subjects) > 0) + |> assign(:has_cycles, length(assigns.grades_report_cycles) > 0) + + ~H""" +
    + <.button + type="button" + theme="ghost" + icon_name="hero-cog-6-tooth-mini" + phx-click={JS.patch(~p"/grading?is_editing_grid=#{@grades_report_id}")} + > + <%= gettext("Setup") %> + + <%= if @has_cycles do %> +
    + <%= grades_report_cycle.school_cycle.name %> +
    + <% else %> +
    + <%= gettext("No cycles linked to this grades report") %> +
    + <% end %> + <%= if @has_subjects do %> +
    +
    + <%= grades_report_subject.subject.name %> +
    + <%= if @has_cycles do %> +
    +
    + <% else %> +
    + <% end %> +
    + <% else %> +
    +
    + <%= gettext("No subjects linked to this grades report") %> +
    + <%= if @has_cycles do %> +
    +
    + <% else %> +
    + <% end %> +
    + <% end %> +
    + """ + end + + # lifecycle + + @impl true + def handle_params(params, _uri, socket) do + grades_reports = + Reporting.list_grades_reports(preloads: [:school_cycle, [scale: :ordinal_values]]) + + socket = + socket + |> stream(:grades_reports, grades_reports) + |> assign(:has_grades_reports, length(grades_reports) > 0) + |> assign_show_grades_report_form(params) + |> assign_show_grades_report_grid_editor(params) + + {:noreply, socket} + end + + defp assign_show_grades_report_form(socket, %{"is_creating" => "true"}) do + socket + |> assign(:grades_report, %GradesReport{}) + |> assign(:form_overlay_title, gettext("Create grade report")) + |> assign(:show_grades_report_form, true) + end + + defp assign_show_grades_report_form(socket, %{"is_editing" => id}) do + cond do + String.match?(id, ~r/[0-9]+/) -> + case Reporting.get_grades_report(id) do + %GradesReport{} = grades_report -> + socket + |> assign(:form_overlay_title, gettext("Edit grade report")) + |> assign(:grades_report, grades_report) + |> assign(:show_grades_report_form, true) + + _ -> + assign(socket, :show_grades_report_form, false) + end + + true -> + assign(socket, :show_grades_report_form, false) + end + end + + defp assign_show_grades_report_form(socket, _), + do: assign(socket, :show_grades_report_form, false) + + defp assign_show_grades_report_grid_editor(socket, %{"is_editing_grid" => id}) do + cond do + String.match?(id, ~r/[0-9]+/) -> + case Reporting.get_grades_report(id) do + %GradesReport{} = grades_report -> + socket + # |> assign(:form_overlay_title, gettext("Edit grade report")) + |> assign(:grades_report, grades_report) + |> assign(:show_grades_report_grid_editor, true) + + _ -> + assign(socket, :show_grades_report_grid_editor, false) + end + + true -> + assign(socket, :show_grades_report_grid_editor, false) + end + end + + defp assign_show_grades_report_grid_editor(socket, _), + do: assign(socket, :show_grades_report_grid_editor, false) + + # event handlers + + @impl true + def handle_event("delete_grades_report", _params, socket) do + case Reporting.delete_grades_report(socket.assigns.grades_report) do + {:ok, _grades_report} -> + socket = + socket + |> put_flash(:info, gettext("Grade report deleted")) + |> push_navigate(to: ~p"/grading") + + {:noreply, socket} + + {:error, _changeset} -> + socket = + socket + |> put_flash(:error, gettext("Error deleting grade report")) + + {:noreply, socket} + end + end +end diff --git a/lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex similarity index 58% rename from lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex rename to lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex index 17796e9a..1e5ce1f5 100644 --- a/lib/lanttern_web/live/pages/grading/grade_reports_live.html.heex +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex @@ -1,48 +1,48 @@
    - <.page_title_with_menu><%= gettext("Grade reports") %> + <.page_title_with_menu><%= gettext("Grades reports") %>

    - <%= gettext("Viewing all grade reports") %> + <%= gettext("Viewing all grades reports") %>

    <.collection_action type="link" icon_name="hero-plus-circle" patch={~p"/grading?is_creating=true"} > - <%= gettext("Create new grade report") %> + <%= gettext("Create new grades report") %>
    - <%= if @has_grade_reports do %> + <%= if @has_grades_reports do %>
    -
    -

    <%= grade_report.name %>

    +
    +

    <%= grades_report.name %>

    <.button type="button" theme="ghost" icon_name="hero-pencil-mini" - phx-click={JS.patch(~p"/grading?is_editing=#{grade_report.id}")} + phx-click={JS.patch(~p"/grading?is_editing=#{grades_report.id}")} > <%= gettext("Edit") %>
    -
    +
    <.icon name="hero-calendar" class="w-6 h-6 shrink-0 text-ltrn-subtle" /> - <%= gettext("Cycle") %>: <%= grade_report.school_cycle.name %> + <%= gettext("Cycle") %>: <%= grades_report.school_cycle.name %>
    <.icon name="hero-view-columns" class="w-6 h-6 shrink-0 text-ltrn-subtle" /> - <%= gettext("Scale") %>: <%= grade_report.scale.name %> + <%= gettext("Scale") %>: <%= grades_report.scale.name %>
    - <%= for ov <- grade_report.scale.ordinal_values do %> + <%= for ov <- grades_report.scale.ordinal_values do %> <.ordinal_value_badge ordinal_value={ov}> <%= ov.name %> @@ -50,7 +50,12 @@
    - <.markdown :if={grade_report.info} text={grade_report.info} class="mt-6" size="sm" /> + <.markdown :if={grades_report.info} text={grades_report.info} class="mb-6" size="sm" /> + <.grades_report_grid + grades_report_id={grades_report.id} + grades_report_subjects={[]} + grades_report_cycles={[]} + />
    <% else %> @@ -60,24 +65,24 @@ <% end %>
    <.slide_over - :if={@show_grade_report_form} + :if={@show_grades_report_form} id="grade-report-form-overlay" show={true} on_cancel={JS.patch(~p"/grading")} > <:title><%= @form_overlay_title %> <.live_component - module={GradeReportFormComponent} - id={@grade_report.id || :new} - grade_report={@grade_report} - navigate={fn _report_card -> ~p"/grading" end} + module={GradesReportFormComponent} + id={@grades_report.id || :new} + grades_report={@grades_report} + navigate={fn _ -> ~p"/grading" end} hide_submit /> - <:actions_left :if={@grade_report.id}> + <:actions_left :if={@grades_report.id}> <.button type="button" theme="ghost" - phx-click="delete_grade_report" + phx-click="delete_grades_report" data-confirm={gettext("Are you sure?")} > <%= gettext("Delete") %> @@ -96,3 +101,10 @@ +<.live_component + :if={@show_grades_report_grid_editor} + module={GradesReportGridSetupOverlayComponent} + id="grades-report-grid-overlay" + grades_report={@grades_report} + on_cancel={JS.patch(~p"/grading")} +/> diff --git a/lib/lanttern_web/live/shared/menu_component.ex b/lib/lanttern_web/live/shared/menu_component.ex index cc8bf048..5d8fabac 100644 --- a/lib/lanttern_web/live/shared/menu_component.ex +++ b/lib/lanttern_web/live/shared/menu_component.ex @@ -286,7 +286,7 @@ defmodule LantternWeb.MenuComponent do socket.view in [LantternWeb.ReportCardsLive, LantternWeb.ReportCardLive] -> :report_cards - socket.view in [LantternWeb.GradeReportsLive] -> + socket.view in [LantternWeb.GradesReportsLive] -> :grading true -> diff --git a/lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex b/lib/lanttern_web/live/shared/reporting/grades_report_form_component.ex similarity index 60% rename from lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex rename to lib/lanttern_web/live/shared/reporting/grades_report_form_component.ex index 59b248f0..d1110a0c 100644 --- a/lib/lanttern_web/live/shared/reporting/grade_report_form_component.ex +++ b/lib/lanttern_web/live/shared/reporting/grades_report_form_component.ex @@ -1,4 +1,4 @@ -defmodule LantternWeb.Reporting.GradeReportFormComponent do +defmodule LantternWeb.Reporting.GradesReportFormComponent do use LantternWeb, :live_component alias Lanttern.Reporting @@ -36,11 +36,19 @@ defmodule LantternWeb.Reporting.GradeReportFormComponent do <.input field={@form[:school_cycle_id]} type="select" - label="Cycle" + label="Parent cycle" options={@cycle_options} prompt="Select a cycle" class="mb-6" - /> + > + <:description> +

    + <%= gettext( + "The parent cycle grade is calculated based on it's children cycles. E.g. 2024 grade is based on 2024 Q1, Q2, Q3, and Q4 grades." + ) %> +

    + + <.input field={@form[:scale_id]} type="select" @@ -73,8 +81,8 @@ defmodule LantternWeb.Reporting.GradeReportFormComponent do end @impl true - def update(%{grade_report: grade_report} = assigns, socket) do - changeset = Reporting.change_grade_report(grade_report) + def update(%{grades_report: grades_report} = assigns, socket) do + changeset = Reporting.change_grades_report(grades_report) socket = socket @@ -85,28 +93,28 @@ defmodule LantternWeb.Reporting.GradeReportFormComponent do end @impl true - def handle_event("validate", %{"grade_report" => grade_report_params}, socket) do + def handle_event("validate", %{"grades_report" => grades_report_params}, socket) do changeset = - socket.assigns.grade_report - |> Reporting.change_grade_report(grade_report_params) + socket.assigns.grades_report + |> Reporting.change_grades_report(grades_report_params) |> Map.put(:action, :validate) {:noreply, assign_form(socket, changeset)} end - def handle_event("save", %{"grade_report" => grade_report_params}, socket) do - save_grade_report(socket, socket.assigns.grade_report.id, grade_report_params) + def handle_event("save", %{"grades_report" => grades_report_params}, socket) do + save_grades_report(socket, socket.assigns.grades_report.id, grades_report_params) end - defp save_grade_report(socket, nil, grade_report_params) do - case Reporting.create_grade_report(grade_report_params) do - {:ok, grade_report} -> - notify_parent(__MODULE__, {:saved, grade_report}, socket.assigns) + defp save_grades_report(socket, nil, grades_report_params) do + case Reporting.create_grades_report(grades_report_params) do + {:ok, grades_report} -> + notify_parent(__MODULE__, {:saved, grades_report}, socket.assigns) socket = socket - |> put_flash(:info, "Grade report created successfully") - |> handle_navigation(grade_report) + |> put_flash(:info, gettext("Grades report created successfully")) + |> handle_navigation(grades_report) {:noreply, socket} @@ -115,15 +123,15 @@ defmodule LantternWeb.Reporting.GradeReportFormComponent do end end - defp save_grade_report(socket, _grade_report_id, grade_report_params) do - case Reporting.update_grade_report(socket.assigns.grade_report, grade_report_params) do - {:ok, grade_report} -> - notify_parent(__MODULE__, {:saved, grade_report}, socket.assigns) + defp save_grades_report(socket, _grades_report_id, grades_report_params) do + case Reporting.update_grades_report(socket.assigns.grades_report, grades_report_params) do + {:ok, grades_report} -> + notify_parent(__MODULE__, {:saved, grades_report}, socket.assigns) socket = socket - |> put_flash(:info, "Grade report updated successfully") - |> handle_navigation(grade_report) + |> put_flash(:info, gettext("Grades report updated successfully")) + |> handle_navigation(grades_report) {:noreply, socket} diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index 7b10b372..e8dee809 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -110,7 +110,7 @@ defmodule LantternWeb.Router do # grading - live "/grading", GradeReportsLive, :index + live "/grading", GradesReportsLive, :index end end diff --git a/priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs b/priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs new file mode 100644 index 00000000..51eaf965 --- /dev/null +++ b/priv/repo/migrations/20240308111926_rename_grade_reports_to_grades_reports.exs @@ -0,0 +1,28 @@ +defmodule Lanttern.Repo.Migrations.RenameGradeReportsToGradesReports do + use Ecto.Migration + + def change do + # grade_reports to gradeS_reports + + # table + execute "ALTER TABLE grade_reports RENAME TO grades_reports", + "ALTER TABLE grades_reports RENAME TO grade_reports" + + # indexes + execute "ALTER INDEX grade_reports_pkey RENAME TO grades_reports_pkey", + "ALTER INDEX grades_reports_pkey RENAME TO grade_reports_pkey" + + execute "ALTER INDEX grade_reports_scale_id_index RENAME TO grades_reports_scale_id_index", + "ALTER INDEX grades_reports_scale_id_index RENAME TO grade_reports_scale_id_index" + + execute "ALTER INDEX grade_reports_school_cycle_id_index RENAME TO grades_reports_school_cycle_id_index", + "ALTER INDEX grades_reports_school_cycle_id_index RENAME TO grade_reports_school_cycle_id_index" + + # constraints + execute "ALTER TABLE grades_reports RENAME CONSTRAINT grade_reports_scale_id_fkey TO grades_reports_scale_id_fkey", + "ALTER TABLE grades_reports RENAME CONSTRAINT grades_reports_scale_id_fkey TO grade_reports_scale_id_fkey" + + execute "ALTER TABLE grades_reports RENAME CONSTRAINT grade_reports_school_cycle_id_fkey TO grades_reports_school_cycle_id_fkey", + "ALTER TABLE grades_reports RENAME CONSTRAINT grades_reports_school_cycle_id_fkey TO grade_reports_school_cycle_id_fkey" + end +end diff --git a/priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs b/priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs new file mode 100644 index 00000000..b694a432 --- /dev/null +++ b/priv/repo/migrations/20240308130056_create_grade_report_cycles_and_subjects.exs @@ -0,0 +1,27 @@ +defmodule Lanttern.Repo.Migrations.CreateGradesReportCyclesAndSubjects do + use Ecto.Migration + + def change do + create table(:grades_report_cycles) do + add :weight, :float, null: false, default: 1.0 + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :grades_report_id, references(:grades_reports, on_delete: :nothing), null: false + + timestamps() + end + + create index(:grades_report_cycles, [:school_cycle_id]) + create unique_index(:grades_report_cycles, [:grades_report_id, :school_cycle_id]) + + create table(:grades_report_subjects) do + add :position, :integer, null: false, default: 0 + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :grades_report_id, references(:grades_reports, on_delete: :nothing), null: false + + timestamps() + end + + create index(:grades_report_subjects, [:subject_id]) + create unique_index(:grades_report_subjects, [:grades_report_id, :subject_id]) + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index decb88a6..0387d50a 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -790,35 +790,35 @@ defmodule Lanttern.ReportingTest do end end - describe "grade_reports" do - alias Lanttern.Reporting.GradeReport + describe "grades_reports" do + alias Lanttern.Reporting.GradesReport import Lanttern.ReportingFixtures alias Lanttern.SchoolsFixtures @invalid_attrs %{info: "blah", scale_id: nil} - test "list_grade_reports/1 returns all grade_reports" do - grade_report = grade_report_fixture() - assert Reporting.list_grade_reports() == [grade_report] + test "list_grades_reports/1 returns all grades_reports" do + grades_report = grades_report_fixture() + assert Reporting.list_grades_reports() == [grades_report] end - test "get_grade_report!/2 returns the grade_report with given id" do - grade_report = grade_report_fixture() - assert Reporting.get_grade_report!(grade_report.id) == grade_report + test "get_grades_report!/2 returns the grades_report with given id" do + grades_report = grades_report_fixture() + assert Reporting.get_grades_report!(grades_report.id) == grades_report end - test "get_grade_report!/2 with preloads returns the grade report with given id and preloaded data" do + test "get_grades_report!/2 with preloads returns the grade report with given id and preloaded data" do school_cycle = SchoolsFixtures.cycle_fixture() - grade_report = grade_report_fixture(%{school_cycle_id: school_cycle.id}) + grades_report = grades_report_fixture(%{school_cycle_id: school_cycle.id}) - expected = Reporting.get_grade_report!(grade_report.id, preloads: :school_cycle) + expected = Reporting.get_grades_report!(grades_report.id, preloads: :school_cycle) - assert expected.id == grade_report.id + assert expected.id == grades_report.id assert expected.school_cycle == school_cycle end - test "create_grade_report/1 with valid data creates a grade_report" do + test "create_grades_report/1 with valid data creates a grades_report" do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() scale = Lanttern.GradingFixtures.scale_fixture() @@ -828,40 +828,40 @@ defmodule Lanttern.ReportingTest do scale_id: scale.id } - assert {:ok, %GradeReport{} = grade_report} = Reporting.create_grade_report(valid_attrs) - assert grade_report.name == "grade report name abc" - assert grade_report.school_cycle_id == school_cycle.id - assert grade_report.scale_id == scale.id + assert {:ok, %GradesReport{} = grades_report} = Reporting.create_grades_report(valid_attrs) + assert grades_report.name == "grade report name abc" + assert grades_report.school_cycle_id == school_cycle.id + assert grades_report.scale_id == scale.id end - test "create_grade_report/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = Reporting.create_grade_report(@invalid_attrs) + test "create_grades_report/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Reporting.create_grades_report(@invalid_attrs) end - test "update_grade_report/2 with valid data updates the grade_report" do - grade_report = grade_report_fixture() + test "update_grades_report/2 with valid data updates the grades_report" do + grades_report = grades_report_fixture() update_attrs = %{info: "some updated info", is_differentiation: "true"} - assert {:ok, %GradeReport{} = grade_report} = - Reporting.update_grade_report(grade_report, update_attrs) + assert {:ok, %GradesReport{} = grades_report} = + Reporting.update_grades_report(grades_report, update_attrs) - assert grade_report.info == "some updated info" - assert grade_report.is_differentiation + assert grades_report.info == "some updated info" + assert grades_report.is_differentiation end - test "update_grade_report/2 with invalid data returns error changeset" do - grade_report = grade_report_fixture() + test "update_grades_report/2 with invalid data returns error changeset" do + grades_report = grades_report_fixture() assert {:error, %Ecto.Changeset{}} = - Reporting.update_grade_report(grade_report, @invalid_attrs) + Reporting.update_grades_report(grades_report, @invalid_attrs) - assert grade_report == Reporting.get_grade_report!(grade_report.id) + assert grades_report == Reporting.get_grades_report!(grades_report.id) end - test "delete_grade_report/1 deletes the grade_report" do - grade_report = grade_report_fixture() - assert {:ok, %GradeReport{}} = Reporting.delete_grade_report(grade_report) - assert_raise Ecto.NoResultsError, fn -> Reporting.get_grade_report!(grade_report.id) end + test "delete_grades_report/1 deletes the grades_report" do + grades_report = grades_report_fixture() + assert {:ok, %GradesReport{}} = Reporting.delete_grades_report(grades_report) + assert_raise Ecto.NoResultsError, fn -> Reporting.get_grades_report!(grades_report.id) end end test "change_report_card/1 returns a report_card changeset" do @@ -869,4 +869,226 @@ defmodule Lanttern.ReportingTest do assert %Ecto.Changeset{} = Reporting.change_report_card(report_card) end end + + describe "grades report subjects" do + alias Lanttern.Reporting.GradesReportSubject + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures + + test "list_grades_report_subjects/1 returns all grades report subjects ordered by position and subjects preloaded" do + grades_report = grades_report_fixture() + subject_1 = TaxonomyFixtures.subject_fixture() + subject_2 = TaxonomyFixtures.subject_fixture() + + grades_report_subject_1 = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_1.id + }) + + grades_report_subject_2 = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_2.id + }) + + assert [expected_grs_1, expected_grs_2] = + Reporting.list_grades_report_subjects(grades_report.id) + + assert expected_grs_1.id == grades_report_subject_1.id + assert expected_grs_1.subject.id == subject_1.id + + assert expected_grs_2.id == grades_report_subject_2.id + assert expected_grs_2.subject.id == subject_2.id + end + + test "add_subject_to_grades_report/1 with valid data creates a report card grade subject" do + grades_report = grades_report_fixture() + subject = TaxonomyFixtures.subject_fixture() + + valid_attrs = %{ + grades_report_id: grades_report.id, + subject_id: subject.id + } + + assert {:ok, %GradesReportSubject{} = grades_report_subject} = + Reporting.add_subject_to_grades_report(valid_attrs) + + assert grades_report_subject.grades_report_id == grades_report.id + assert grades_report_subject.subject_id == subject.id + assert grades_report_subject.position == 0 + + # insert one more grades report subject in a different grades report to test position auto increment scope + + # extra fixture in different grades report + grades_report_subject_fixture() + + subject = TaxonomyFixtures.subject_fixture() + + valid_attrs = %{ + grades_report_id: grades_report.id, + subject_id: subject.id + } + + assert {:ok, %GradesReportSubject{} = grades_report_subject} = + Reporting.add_subject_to_grades_report(valid_attrs) + + assert grades_report_subject.grades_report_id == grades_report.id + assert grades_report_subject.subject_id == subject.id + assert grades_report_subject.position == 1 + end + + test "update_grades_report_subjects_positions/1 update grades report subjects positions based on list order" do + grades_report = grades_report_fixture() + + grades_report_subject_1 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + grades_report_subject_2 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + grades_report_subject_3 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + grades_report_subject_4 = + grades_report_subject_fixture(%{grades_report_id: grades_report.id}) + + sorted_grades_report_subjects_ids = + [ + grades_report_subject_2.id, + grades_report_subject_3.id, + grades_report_subject_1.id, + grades_report_subject_4.id + ] + + assert :ok == + Reporting.update_grades_report_subjects_positions( + sorted_grades_report_subjects_ids + ) + + assert [ + expected_grs_2, + expected_grs_3, + expected_grs_1, + expected_grs_4 + ] = + Reporting.list_grades_report_subjects(grades_report.id) + + assert expected_grs_1.id == grades_report_subject_1.id + assert expected_grs_2.id == grades_report_subject_2.id + assert expected_grs_3.id == grades_report_subject_3.id + assert expected_grs_4.id == grades_report_subject_4.id + end + + test "delete_grades_report_subject/1 deletes the grades_report_subject" do + grades_report_subject = grades_report_subject_fixture() + + assert {:ok, %GradesReportSubject{}} = + Reporting.delete_grades_report_subject(grades_report_subject) + + assert_raise Ecto.NoResultsError, fn -> + Repo.get!(GradesReportSubject, grades_report_subject.id) + end + end + end + + describe "grades report cycles" do + alias Lanttern.Reporting.GradesReportCycle + + import Lanttern.ReportingFixtures + alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures + + test "list_grades_report_cycles/1 returns all grades report cycles ordered by dates and preloaded cycles" do + grades_report = grades_report_fixture() + + cycle_2023 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2023-01-01], end_at: ~D[2023-12-31]}) + + cycle_2024_q4 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-09-01], end_at: ~D[2024-12-31]}) + + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + + grades_report_cycle_2023 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2023.id + }) + + grades_report_cycle_2024_q4 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_q4.id + }) + + grades_report_cycle_2024 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024.id + }) + + assert [expected_grc_2023, expected_grc_2024_q4, expected_grc_2024] = + Reporting.list_grades_report_cycles(grades_report.id) + + assert expected_grc_2023.id == grades_report_cycle_2023.id + assert expected_grc_2023.school_cycle.id == cycle_2023.id + + assert expected_grc_2024_q4.id == grades_report_cycle_2024_q4.id + assert expected_grc_2024_q4.school_cycle.id == cycle_2024_q4.id + + assert expected_grc_2024.id == grades_report_cycle_2024.id + assert expected_grc_2024.school_cycle.id == cycle_2024.id + end + + test "add_cycle_to_grades_report/1 with valid data creates a grades report cycle" do + grades_report = grades_report_fixture() + school_cycle = SchoolsFixtures.cycle_fixture() + + valid_attrs = %{ + grades_report_id: grades_report.id, + school_cycle_id: school_cycle.id + } + + assert {:ok, %GradesReportCycle{} = grades_report_cycle} = + Reporting.add_cycle_to_grades_report(valid_attrs) + + assert grades_report_cycle.grades_report_id == grades_report.id + assert grades_report_cycle.school_cycle_id == school_cycle.id + end + + test "update_grades_report_cycle/2 with valid data updates the grades_report_cycle" do + grades_report_cycle = grades_report_cycle_fixture() + update_attrs = %{weight: 123.0} + + assert {:ok, %GradesReportCycle{} = grades_report_cycle} = + Reporting.update_grades_report_cycle(grades_report_cycle, update_attrs) + + assert grades_report_cycle.weight == 123.0 + end + + test "update_grades_report_cycle/2 with invalid data returns error changeset" do + grades_report_cycle = grades_report_cycle_fixture() + invalid_attrs = %{weight: "abc"} + + assert {:error, %Ecto.Changeset{}} = + Reporting.update_grades_report_cycle(grades_report_cycle, invalid_attrs) + + assert grades_report_cycle == Repo.get!(GradesReportCycle, grades_report_cycle.id) + end + + test "delete_grades_report_cycle/1 deletes the grades_report_cycle" do + grades_report_cycle = grades_report_cycle_fixture() + + assert {:ok, %GradesReportCycle{}} = + Reporting.delete_grades_report_cycle(grades_report_cycle) + + assert_raise Ecto.NoResultsError, fn -> + Repo.get!(GradesReportCycle, grades_report_cycle.id) + end + end + end end diff --git a/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs b/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs index b9732b48..a2b78840 100644 --- a/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs +++ b/test/lanttern_web/live/pages/grading/grade_reports_live_test.exs @@ -1,4 +1,4 @@ -defmodule LantternWeb.GradeReportsLiveTest do +defmodule LantternWeb.GradesReportsLiveTest do use LantternWeb.ConnCase import Lanttern.ReportingFixtures @@ -13,7 +13,7 @@ defmodule LantternWeb.GradeReportsLiveTest do test "disconnected and connected mount", %{conn: conn} do conn = get(conn, @live_view_path) - assert html_response(conn, 200) =~ ~r"

    \s*Grade reports\s*<\/h1>" + assert html_response(conn, 200) =~ ~r"

    \s*Grades reports\s*<\/h1>" {:ok, _view, _html} = live(conn) end @@ -25,8 +25,8 @@ defmodule LantternWeb.GradeReportsLiveTest do _ordinal_value = GradingFixtures.ordinal_value_fixture(%{name: "Ordinal value A", scale_id: scale.id}) - _grade_report = - grade_report_fixture(%{ + _grades_report = + grades_report_fixture(%{ name: "Some grade report ABC", info: "Some info XYZ", school_cycle_id: cycle.id, diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index c7c2adcb..c8743bce 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -124,8 +124,8 @@ defmodule Lanttern.ReportingFixtures do @doc """ Generate a grade report. """ - def grade_report_fixture(attrs \\ %{}) do - {:ok, grade_report} = + def grades_report_fixture(attrs \\ %{}) do + {:ok, grades_report} = attrs |> Enum.into(%{ name: "some name", @@ -133,9 +133,9 @@ defmodule Lanttern.ReportingFixtures do school_cycle_id: maybe_gen_school_cycle_id(attrs), scale_id: maybe_gen_scale_id(attrs) }) - |> Lanttern.Reporting.create_grade_report() + |> Lanttern.Reporting.create_grades_report() - grade_report + grades_report end defp maybe_gen_scale_id(%{scale_id: scale_id} = _attrs), @@ -143,4 +143,40 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_scale_id(_attrs), do: Lanttern.GradingFixtures.scale_fixture().id + + @doc """ + Generate a grades_report_subject. + """ + def grades_report_subject_fixture(attrs \\ %{}) do + {:ok, grades_report_subject} = + attrs + |> Enum.into(%{ + grades_report_id: maybe_gen_grades_report_id(attrs), + subject_id: maybe_gen_subject_id(attrs) + }) + |> Lanttern.Reporting.add_subject_to_grades_report() + + grades_report_subject + end + + defp maybe_gen_grades_report_id(%{grades_report_id: grades_report_id} = _attrs), + do: grades_report_id + + defp maybe_gen_grades_report_id(_attrs), + do: grades_report_fixture().id + + @doc """ + Generate a grades_report_cycle. + """ + def grades_report_cycle_fixture(attrs \\ %{}) do + {:ok, grades_report_cycle} = + attrs + |> Enum.into(%{ + grades_report_id: maybe_gen_grades_report_id(attrs), + school_cycle_id: maybe_gen_school_cycle_id(attrs) + }) + |> Lanttern.Reporting.add_cycle_to_grades_report() + + grades_report_cycle + end end From eff52e61f8d563b8de99b453c7b69ba7e2d8a277 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 8 Mar 2024 17:57:17 -0300 Subject: [PATCH 12/15] feat: added grades report grid UI in grading page - added suport to `:load_grid` opt in `Reporting.list_grades_reports/1` --- lib/lanttern/reporting.ex | 23 ++++++ lib/lanttern/reporting/grades_report.ex | 7 ++ .../live/pages/grading/grades_reports_live.ex | 43 +++++++----- .../grading/grades_reports_live.html.heex | 6 +- test/lanttern/reporting_test.exs | 70 +++++++++++++++++++ 5 files changed, 128 insertions(+), 21 deletions(-) diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 9d3a6449..98c116be 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -773,6 +773,7 @@ defmodule Lanttern.Reporting do ## Options - `:preloads` – preloads associated data + - `:load_grid` – (bool) preloads grades report cycles (with cycle preloaded) ordered by end_at, and grades report subjects (with subject preloaded) ordered by position ## Examples @@ -782,10 +783,32 @@ defmodule Lanttern.Reporting do """ def list_grades_reports(opts \\ []) do GradesReport + |> apply_list_grades_reports_opts(opts) |> Repo.all() |> maybe_preload(opts) end + defp apply_list_grades_reports_opts(queryable, []), do: queryable + + defp apply_list_grades_reports_opts(queryable, [{:load_grid, true} | opts]) do + from( + gr in queryable, + left_join: grc in assoc(gr, :grades_report_cycles), + left_join: sc in assoc(grc, :school_cycle), + left_join: grs in assoc(gr, :grades_report_subjects), + left_join: s in assoc(grs, :subject), + order_by: [asc: sc.end_at, desc: sc.start_at, asc: grs.position], + preload: [ + grades_report_cycles: {grc, [school_cycle: sc]}, + grades_report_subjects: {grs, [subject: s]} + ] + ) + |> apply_list_grades_reports_opts(opts) + end + + defp apply_list_grades_reports_opts(queryable, [_ | opts]), + do: apply_list_grades_reports_opts(queryable, opts) + @doc """ Gets a single grade report. diff --git a/lib/lanttern/reporting/grades_report.ex b/lib/lanttern/reporting/grades_report.ex index cc395cd9..604a1034 100644 --- a/lib/lanttern/reporting/grades_report.ex +++ b/lib/lanttern/reporting/grades_report.ex @@ -4,6 +4,8 @@ defmodule Lanttern.Reporting.GradesReport do alias Lanttern.Grading.Scale alias Lanttern.Schools.Cycle + alias Lanttern.Reporting.GradesReportCycle + alias Lanttern.Reporting.GradesReportSubject @type t :: %__MODULE__{ id: pos_integer(), @@ -14,6 +16,8 @@ defmodule Lanttern.Reporting.GradesReport do school_cycle_id: pos_integer(), scale: Scale.t(), scale_id: pos_integer(), + grades_report_cycles: [GradesReportCycle.t()], + grades_report_subjects: [GradesReportSubject.t()], inserted_at: DateTime.t(), updated_at: DateTime.t() } @@ -26,6 +30,9 @@ defmodule Lanttern.Reporting.GradesReport do belongs_to :school_cycle, Cycle belongs_to :scale, Scale + has_many :grades_report_cycles, GradesReportCycle + has_many :grades_report_subjects, GradesReportSubject + timestamps() end diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.ex b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex index 9e6dad2c..3ae16db3 100644 --- a/lib/lanttern_web/live/pages/grading/grades_reports_live.ex +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex @@ -15,32 +15,35 @@ defmodule LantternWeb.GradesReportsLive do # function components - attr :grades_report_id, :integer, required: true - attr :grades_report_subjects, :list, required: true - attr :grades_report_cycles, :list, required: true + attr :grades_report, GradesReport, required: true def grades_report_grid(assigns) do + %{ + grades_report_cycles: grades_report_cycles, + grades_report_subjects: grades_report_subjects + } = assigns.grades_report + grid_template_columns_style = - case length(assigns.grades_report_cycles) do - n when n > 1 -> - "grid-template-columns: 160px repeat(#{n}, minmax(0, 1fr))" + case length(grades_report_cycles) do + n when n > 0 -> + "grid-template-columns: 160px repeat(#{n + 1}, minmax(0, 1fr))" _ -> "grid-template-columns: 160px minmax(0, 1fr)" end grid_column_style = - case length(assigns.grades_report_cycles) do + case length(grades_report_cycles) do 0 -> "grid-column: span 2 / span 2" - n -> "grid-column: span #{n + 1} / span #{n + 1}" + n -> "grid-column: span #{n + 2} / span #{n + 2}" end assigns = assigns |> assign(:grid_template_columns_style, grid_template_columns_style) |> assign(:grid_column_style, grid_column_style) - |> assign(:has_subjects, length(assigns.grades_report_subjects) > 0) - |> assign(:has_cycles, length(assigns.grades_report_cycles) > 0) + |> assign(:has_subjects, length(grades_report_subjects) > 0) + |> assign(:has_cycles, length(grades_report_cycles) > 0) ~H"""
    @@ -48,18 +51,21 @@ defmodule LantternWeb.GradesReportsLive do type="button" theme="ghost" icon_name="hero-cog-6-tooth-mini" - phx-click={JS.patch(~p"/grading?is_editing_grid=#{@grades_report_id}")} + phx-click={JS.patch(~p"/grading?is_editing_grid=#{@grades_report.id}")} > <%= gettext("Setup") %> <%= if @has_cycles do %>
    <%= grades_report_cycle.school_cycle.name %>
    +
    + <%= @grades_report.school_cycle.name %> +
    <% else %>
    <%= gettext("No cycles linked to this grades report") %> @@ -67,7 +73,7 @@ defmodule LantternWeb.GradesReportsLive do <% end %> <%= if @has_subjects do %>
    <%= if @has_cycles do %>
    +
    <% else %>
    <% end %> @@ -92,10 +99,11 @@ defmodule LantternWeb.GradesReportsLive do
    <%= if @has_cycles do %>
    +
    <% else %>
    <% end %> @@ -110,7 +118,10 @@ defmodule LantternWeb.GradesReportsLive do @impl true def handle_params(params, _uri, socket) do grades_reports = - Reporting.list_grades_reports(preloads: [:school_cycle, [scale: :ordinal_values]]) + Reporting.list_grades_reports( + preloads: [:school_cycle, [scale: :ordinal_values]], + load_grid: true + ) socket = socket diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex index 1e5ce1f5..b27f1afb 100644 --- a/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex @@ -51,11 +51,7 @@
    <.markdown :if={grades_report.info} text={grades_report.info} class="mb-6" size="sm" /> - <.grades_report_grid - grades_report_id={grades_report.id} - grades_report_subjects={[]} - grades_report_cycles={[]} - /> + <.grades_report_grid grades_report={grades_report} />

    <% else %> diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 0387d50a..6065fadd 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -795,6 +795,7 @@ defmodule Lanttern.ReportingTest do import Lanttern.ReportingFixtures alias Lanttern.SchoolsFixtures + alias Lanttern.TaxonomyFixtures @invalid_attrs %{info: "blah", scale_id: nil} @@ -803,6 +804,75 @@ defmodule Lanttern.ReportingTest do assert Reporting.list_grades_reports() == [grades_report] end + test "list_grades_reports/1 with load grid opt returns all grades_reports with linked and ordered cycles and subjects" do + cycle_2024_1 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-06-30]}) + + cycle_2024_2 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-07-01], end_at: ~D[2024-12-31]}) + + subject_a = TaxonomyFixtures.subject_fixture() + subject_b = TaxonomyFixtures.subject_fixture() + subject_c = TaxonomyFixtures.subject_fixture() + + grades_report = grades_report_fixture() + + grades_report_cycle_2024_1 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_1.id + }) + + grades_report_cycle_2024_2 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_2.id + }) + + # subjects order c, b, a + + grades_report_subject_c = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_c.id + }) + + grades_report_subject_b = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_b.id + }) + + grades_report_subject_a = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_a.id + }) + + assert [expected_grades_report] = Reporting.list_grades_reports(load_grid: true) + assert expected_grades_report.id == grades_report.id + + # check cycles + assert [expected_grc_2024_1, expected_grc_2024_2] = + expected_grades_report.grades_report_cycles + + assert expected_grc_2024_1.id == grades_report_cycle_2024_1.id + assert expected_grc_2024_1.school_cycle.id == cycle_2024_1.id + assert expected_grc_2024_2.id == grades_report_cycle_2024_2.id + assert expected_grc_2024_2.school_cycle.id == cycle_2024_2.id + + # check subjects + assert [expected_grs_c, expected_grs_b, expected_grs_a] = + expected_grades_report.grades_report_subjects + + assert expected_grs_a.id == grades_report_subject_a.id + assert expected_grs_a.subject.id == subject_a.id + assert expected_grs_b.id == grades_report_subject_b.id + assert expected_grs_b.subject.id == subject_b.id + assert expected_grs_c.id == grades_report_subject_c.id + assert expected_grs_c.subject.id == subject_c.id + end + test "get_grades_report!/2 returns the grades_report with given id" do grades_report = grades_report_fixture() assert Reporting.get_grades_report!(grades_report.id) == grades_report From 277c11a4030a37a92aee9f92705912501cb79510 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 8 Mar 2024 19:32:24 -0300 Subject: [PATCH 13/15] feat: connected grades report to report cards - added support to `:load_grid` opt in `get_grades_report/2` and `get_grades_report!/2` in `Reporting` context - moved `<.grades_report_grid>` component to shared module `LantternWeb.ReportingComponents` --- lib/lanttern/reporting.ex | 37 ++++-- lib/lanttern/reporting/report_card.ex | 6 +- .../components/reporting_components.ex | 107 ++++++++++++++++++ lib/lanttern_web/helpers/reporting_helpers.ex | 16 +++ .../live/pages/grading/grades_reports_live.ex | 103 +---------------- .../grading/grades_reports_live.html.heex | 5 +- .../pages/report_cards/id/grades_component.ex | 27 ++++- .../reporting/report_card_form_component.ex | 14 ++- ...1_add_grades_report_id_to_report_cards.exs | 11 ++ test/lanttern/reporting_test.exs | 81 ++++++++++++- 10 files changed, 288 insertions(+), 119 deletions(-) create mode 100644 lib/lanttern_web/helpers/reporting_helpers.ex create mode 100644 priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 98c116be..7cf20141 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -773,7 +773,7 @@ defmodule Lanttern.Reporting do ## Options - `:preloads` – preloads associated data - - `:load_grid` – (bool) preloads grades report cycles (with cycle preloaded) ordered by end_at, and grades report subjects (with subject preloaded) ordered by position + - `:load_grid` – (bool) preloads school cycle and grades report cycles/subjects (with school cycle/subject preloaded) ## Examples @@ -790,25 +790,29 @@ defmodule Lanttern.Reporting do defp apply_list_grades_reports_opts(queryable, []), do: queryable - defp apply_list_grades_reports_opts(queryable, [{:load_grid, true} | opts]) do + defp apply_list_grades_reports_opts(queryable, [{:load_grid, true} | opts]), + do: apply_list_grades_reports_opts(grid_query(queryable), opts) + + defp apply_list_grades_reports_opts(queryable, [_ | opts]), + do: apply_list_grades_reports_opts(queryable, opts) + + defp grid_query(queryable) do from( gr in queryable, + join: sc in assoc(gr, :school_cycle), left_join: grc in assoc(gr, :grades_report_cycles), - left_join: sc in assoc(grc, :school_cycle), + left_join: grc_sc in assoc(grc, :school_cycle), left_join: grs in assoc(gr, :grades_report_subjects), - left_join: s in assoc(grs, :subject), - order_by: [asc: sc.end_at, desc: sc.start_at, asc: grs.position], + left_join: grs_s in assoc(grs, :subject), + order_by: [asc: grc_sc.end_at, desc: grc_sc.start_at, asc: grs.position], preload: [ - grades_report_cycles: {grc, [school_cycle: sc]}, - grades_report_subjects: {grs, [subject: s]} + school_cycle: sc, + grades_report_cycles: {grc, [school_cycle: grc_sc]}, + grades_report_subjects: {grs, [subject: grs_s]} ] ) - |> apply_list_grades_reports_opts(opts) end - defp apply_list_grades_reports_opts(queryable, [_ | opts]), - do: apply_list_grades_reports_opts(queryable, opts) - @doc """ Gets a single grade report. @@ -817,6 +821,7 @@ defmodule Lanttern.Reporting do ## Options: - `:preloads` – preloads associated data + - `:load_grid` – (bool) preloads school cycle and grades report cycles/subjects (with school cycle/subject preloaded) ## Examples @@ -829,10 +834,19 @@ defmodule Lanttern.Reporting do """ def get_grades_report(id, opts \\ []) do GradesReport + |> apply_get_grades_report_opts(opts) |> Repo.get(id) |> maybe_preload(opts) end + defp apply_get_grades_report_opts(queryable, []), do: queryable + + defp apply_get_grades_report_opts(queryable, [{:load_grid, true} | opts]), + do: apply_get_grades_report_opts(grid_query(queryable), opts) + + defp apply_get_grades_report_opts(queryable, [_ | opts]), + do: apply_get_grades_report_opts(queryable, opts) + @doc """ Gets a single grade report. @@ -849,6 +863,7 @@ defmodule Lanttern.Reporting do """ def get_grades_report!(id, opts \\ []) do GradesReport + |> apply_get_grades_report_opts(opts) |> Repo.get!(id) |> maybe_preload(opts) end diff --git a/lib/lanttern/reporting/report_card.ex b/lib/lanttern/reporting/report_card.ex index fbed6445..8d1366b3 100644 --- a/lib/lanttern/reporting/report_card.ex +++ b/lib/lanttern/reporting/report_card.ex @@ -3,6 +3,7 @@ defmodule Lanttern.Reporting.ReportCard do import Ecto.Changeset alias Lanttern.Reporting.StrandReport + alias Lanttern.Reporting.GradesReport alias Lanttern.Schools.Cycle alias Lanttern.Taxonomy.Year @@ -14,6 +15,8 @@ defmodule Lanttern.Reporting.ReportCard do school_cycle_id: pos_integer(), year: Year.t(), year_id: pos_integer(), + grades_report: GradesReport.t(), + grades_report_id: pos_integer(), strand_reports: [StrandReport.t()], inserted_at: DateTime.t(), updated_at: DateTime.t() @@ -25,6 +28,7 @@ defmodule Lanttern.Reporting.ReportCard do belongs_to :school_cycle, Cycle belongs_to :year, Year + belongs_to :grades_report, GradesReport has_many :strand_reports, StrandReport, preload_order: [asc: :position] @@ -34,7 +38,7 @@ defmodule Lanttern.Reporting.ReportCard do @doc false def changeset(report_card, attrs) do report_card - |> cast(attrs, [:name, :description, :school_cycle_id, :year_id]) + |> cast(attrs, [:name, :description, :school_cycle_id, :year_id, :grades_report_id]) |> validate_required([:name, :school_cycle_id, :year_id]) end end diff --git a/lib/lanttern_web/components/reporting_components.ex b/lib/lanttern_web/components/reporting_components.ex index a0082511..d3983987 100644 --- a/lib/lanttern_web/components/reporting_components.ex +++ b/lib/lanttern_web/components/reporting_components.ex @@ -8,6 +8,7 @@ defmodule LantternWeb.ReportingComponents do alias Lanttern.Assessments.AssessmentPointEntry alias Lanttern.Grading.Scale alias Lanttern.Reporting.ReportCard + alias Lanttern.Reporting.GradesReport alias Lanttern.Rubrics.Rubric alias Lanttern.Schools.Cycle alias Lanttern.Taxonomy.Year @@ -181,4 +182,110 @@ defmodule LantternWeb.ReportingComponents do

    """ end + + @doc """ + Renders a grades report grid. + + Expects `[:school_cycle, grades_report_cycles: :school_cycle, grades_report_subjects: :subject]` preloads. + """ + + attr :grades_report, GradesReport, required: true + attr :class, :any, default: nil + attr :id, :string, default: nil + attr :on_setup, JS, default: nil + + def grades_report_grid(assigns) do + %{ + grades_report_cycles: grades_report_cycles, + grades_report_subjects: grades_report_subjects + } = assigns.grades_report + + grid_template_columns_style = + case length(grades_report_cycles) do + n when n > 0 -> + "grid-template-columns: 160px repeat(#{n + 1}, minmax(0, 1fr))" + + _ -> + "grid-template-columns: 160px minmax(0, 1fr)" + end + + grid_column_style = + case length(grades_report_cycles) do + 0 -> "grid-column: span 2 / span 2" + n -> "grid-column: span #{n + 2} / span #{n + 2}" + end + + assigns = + assigns + |> assign(:grid_template_columns_style, grid_template_columns_style) + |> assign(:grid_column_style, grid_column_style) + |> assign(:has_subjects, length(grades_report_subjects) > 0) + |> assign(:has_cycles, length(grades_report_cycles) > 0) + + ~H""" +
    + <%= if @on_setup do %> + <.button type="button" theme="ghost" icon_name="hero-cog-6-tooth-mini" phx-click={@on_setup}> + <%= gettext("Setup") %> + + <% else %> +
    + <% end %> + <%= if @has_cycles do %> +
    + <%= grades_report_cycle.school_cycle.name %> +
    +
    + <%= @grades_report.school_cycle.name %> +
    + <% else %> +
    + <%= gettext("No cycles linked to this grades report") %> +
    + <% end %> + <%= if @has_subjects do %> +
    +
    + <%= grades_report_subject.subject.name %> +
    + <%= if @has_cycles do %> +
    +
    +
    + <% else %> +
    + <% end %> +
    + <% else %> +
    +
    + <%= gettext("No subjects linked to this grades report") %> +
    + <%= if @has_cycles do %> +
    +
    +
    + <% else %> +
    + <% end %> +
    + <% end %> +
    + """ + end end diff --git a/lib/lanttern_web/helpers/reporting_helpers.ex b/lib/lanttern_web/helpers/reporting_helpers.ex new file mode 100644 index 00000000..1ba891dd --- /dev/null +++ b/lib/lanttern_web/helpers/reporting_helpers.ex @@ -0,0 +1,16 @@ +defmodule LantternWeb.ReportingHelpers do + alias Lanttern.Reporting + + @doc """ + Generate list of grades reports to use as `Phoenix.HTML.Form.options_for_select/2` arg + + ## Examples + + iex> generate_grades_report_options() + ["grades report name": 1, ...] + """ + def generate_grades_report_options() do + Reporting.list_grades_reports() + |> Enum.map(fn gr -> {gr.name, gr.id} end) + end +end diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.ex b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex index 3ae16db3..e78896cb 100644 --- a/lib/lanttern_web/live/pages/grading/grades_reports_live.ex +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.ex @@ -12,106 +12,7 @@ defmodule LantternWeb.GradesReportsLive do # shared import LantternWeb.GradingComponents - - # function components - - attr :grades_report, GradesReport, required: true - - def grades_report_grid(assigns) do - %{ - grades_report_cycles: grades_report_cycles, - grades_report_subjects: grades_report_subjects - } = assigns.grades_report - - grid_template_columns_style = - case length(grades_report_cycles) do - n when n > 0 -> - "grid-template-columns: 160px repeat(#{n + 1}, minmax(0, 1fr))" - - _ -> - "grid-template-columns: 160px minmax(0, 1fr)" - end - - grid_column_style = - case length(grades_report_cycles) do - 0 -> "grid-column: span 2 / span 2" - n -> "grid-column: span #{n + 2} / span #{n + 2}" - end - - assigns = - assigns - |> assign(:grid_template_columns_style, grid_template_columns_style) - |> assign(:grid_column_style, grid_column_style) - |> assign(:has_subjects, length(grades_report_subjects) > 0) - |> assign(:has_cycles, length(grades_report_cycles) > 0) - - ~H""" -
    - <.button - type="button" - theme="ghost" - icon_name="hero-cog-6-tooth-mini" - phx-click={JS.patch(~p"/grading?is_editing_grid=#{@grades_report.id}")} - > - <%= gettext("Setup") %> - - <%= if @has_cycles do %> -
    - <%= grades_report_cycle.school_cycle.name %> -
    -
    - <%= @grades_report.school_cycle.name %> -
    - <% else %> -
    - <%= gettext("No cycles linked to this grades report") %> -
    - <% end %> - <%= if @has_subjects do %> -
    -
    - <%= grades_report_subject.subject.name %> -
    - <%= if @has_cycles do %> -
    -
    -
    - <% else %> -
    - <% end %> -
    - <% else %> -
    -
    - <%= gettext("No subjects linked to this grades report") %> -
    - <%= if @has_cycles do %> -
    -
    -
    - <% else %> -
    - <% end %> -
    - <% end %> -
    - """ - end + import LantternWeb.ReportingComponents # lifecycle @@ -119,7 +20,7 @@ defmodule LantternWeb.GradesReportsLive do def handle_params(params, _uri, socket) do grades_reports = Reporting.list_grades_reports( - preloads: [:school_cycle, [scale: :ordinal_values]], + preloads: [scale: :ordinal_values], load_grid: true ) diff --git a/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex index b27f1afb..cee6bad1 100644 --- a/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex +++ b/lib/lanttern_web/live/pages/grading/grades_reports_live.html.heex @@ -51,7 +51,10 @@
    <.markdown :if={grades_report.info} text={grades_report.info} class="mb-6" size="sm" /> - <.grades_report_grid grades_report={grades_report} /> + <.grades_report_grid + grades_report={grades_report} + on_setup={JS.patch(~p"/grading?is_editing_grid=#{grades_report.id}")} + />
    <% else %> diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex index 79eeb1cb..e7f14bef 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -7,14 +7,29 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do import Lanttern.Utils, only: [swap: 3] + # shared + import LantternWeb.ReportingComponents + @impl true def render(assigns) do ~H"""
    -

    - <%= gettext("Grades report grid") %> -

    +
    + <%= if @grades_report do %> +

    + <%= gettext("Grades report grid") %>: <%= @grades_report.name %> +

    + <.grades_report_grid grades_report={@grades_report} /> + <% else %> +

    + <%= gettext("Grades report grid") %> +

    + <.empty_state> + <%= gettext("No grades report linked to this report card.") %> + + <% end %> +

    <%= gettext("Select subjects and cycles to build the grades report grid.") %>

    @@ -183,6 +198,12 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do socket = socket |> assign(assigns) + |> assign_new(:grades_report, fn %{report_card: report_card} -> + case report_card.grades_report_id do + nil -> nil + id -> Reporting.get_grades_report(id, load_grid: true) + end + end) |> assign_new(:sortable_grades_subjects, fn %{report_card: report_card} -> Reporting.list_report_card_grades_subjects(report_card.id) |> Enum.with_index() diff --git a/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex b/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex index a1c63185..6b11c2f0 100644 --- a/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex +++ b/lib/lanttern_web/live/shared/reporting/report_card_form_component.ex @@ -2,6 +2,7 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do use LantternWeb, :live_component alias Lanttern.Reporting + alias LantternWeb.ReportingHelpers alias LantternWeb.SchoolsHelpers alias LantternWeb.TaxonomyHelpers @@ -48,7 +49,16 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do phx-debounce="1500" class="mb-1" /> - <.markdown_supported class={if !@hide_submit, do: "mb-6"} /> + <.markdown_supported class="mb-6" /> + <.input + field={@form[:grades_report_id]} + type="select" + label={gettext("Grades report")} + options={@grades_report_options} + prompt={gettext("Select grades report")} + phx-target={@myself} + class={if !@hide_submit, do: "mb-6"} + /> <.button :if={!@hide_submit} phx-disable-with={gettext("Saving...")}> <%= gettext("Save Report card") %> @@ -61,12 +71,14 @@ defmodule LantternWeb.Reporting.ReportCardFormComponent do def mount(socket) do cycle_options = SchoolsHelpers.generate_cycle_options() year_options = TaxonomyHelpers.generate_year_options() + grades_report_options = ReportingHelpers.generate_grades_report_options() socket = socket |> assign(:class, nil) |> assign(:cycle_options, cycle_options) |> assign(:year_options, year_options) + |> assign(:grades_report_options, grades_report_options) |> assign(:hide_submit, false) {:ok, socket} diff --git a/priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs b/priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs new file mode 100644 index 00000000..22754580 --- /dev/null +++ b/priv/repo/migrations/20240308210351_add_grades_report_id_to_report_cards.exs @@ -0,0 +1,11 @@ +defmodule Lanttern.Repo.Migrations.AddGradesReportIdToReportCards do + use Ecto.Migration + + def change do + alter table(:report_cards) do + add :grades_report_id, references(:grades_reports, on_delete: :nothing) + end + + create index(:report_cards, [:grades_report_id]) + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 6065fadd..7e38120f 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -805,6 +805,9 @@ defmodule Lanttern.ReportingTest do end test "list_grades_reports/1 with load grid opt returns all grades_reports with linked and ordered cycles and subjects" do + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + cycle_2024_1 = SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-06-30]}) @@ -815,7 +818,7 @@ defmodule Lanttern.ReportingTest do subject_b = TaxonomyFixtures.subject_fixture() subject_c = TaxonomyFixtures.subject_fixture() - grades_report = grades_report_fixture() + grades_report = grades_report_fixture(%{school_cycle_id: cycle_2024.id}) grades_report_cycle_2024_1 = grades_report_cycle_fixture(%{ @@ -851,6 +854,7 @@ defmodule Lanttern.ReportingTest do assert [expected_grades_report] = Reporting.list_grades_reports(load_grid: true) assert expected_grades_report.id == grades_report.id + assert expected_grades_report.school_cycle.id == cycle_2024.id # check cycles assert [expected_grc_2024_1, expected_grc_2024_2] = @@ -888,6 +892,81 @@ defmodule Lanttern.ReportingTest do assert expected.school_cycle == school_cycle end + test "get_grades_report!/2 with load grid opt returns the grades report with linked and ordered cycles and subjects" do + cycle_2024 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) + + cycle_2024_1 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-06-30]}) + + cycle_2024_2 = + SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-07-01], end_at: ~D[2024-12-31]}) + + subject_a = TaxonomyFixtures.subject_fixture() + subject_b = TaxonomyFixtures.subject_fixture() + subject_c = TaxonomyFixtures.subject_fixture() + + grades_report = grades_report_fixture(%{school_cycle_id: cycle_2024.id}) + + grades_report_cycle_2024_1 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_1.id + }) + + grades_report_cycle_2024_2 = + grades_report_cycle_fixture(%{ + grades_report_id: grades_report.id, + school_cycle_id: cycle_2024_2.id + }) + + # subjects order c, b, a + + grades_report_subject_c = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_c.id + }) + + grades_report_subject_b = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_b.id + }) + + grades_report_subject_a = + grades_report_subject_fixture(%{ + grades_report_id: grades_report.id, + subject_id: subject_a.id + }) + + assert expected_grades_report = + Reporting.get_grades_report!(grades_report.id, load_grid: true) + + assert expected_grades_report.id == grades_report.id + assert expected_grades_report.school_cycle.id == cycle_2024.id + + # check sub cycles + assert [expected_grc_2024_1, expected_grc_2024_2] = + expected_grades_report.grades_report_cycles + + assert expected_grc_2024_1.id == grades_report_cycle_2024_1.id + assert expected_grc_2024_1.school_cycle.id == cycle_2024_1.id + assert expected_grc_2024_2.id == grades_report_cycle_2024_2.id + assert expected_grc_2024_2.school_cycle.id == cycle_2024_2.id + + # check subjects + assert [expected_grs_c, expected_grs_b, expected_grs_a] = + expected_grades_report.grades_report_subjects + + assert expected_grs_a.id == grades_report_subject_a.id + assert expected_grs_a.subject.id == subject_a.id + assert expected_grs_b.id == grades_report_subject_b.id + assert expected_grs_b.subject.id == subject_b.id + assert expected_grs_c.id == grades_report_subject_c.id + assert expected_grs_c.subject.id == subject_c.id + end + test "create_grades_report/1 with valid data creates a grades_report" do school_cycle = Lanttern.SchoolsFixtures.cycle_fixture() scale = Lanttern.GradingFixtures.scale_fixture() From 9c06897d20a663072180bc0cc5500bd3d9f69f51 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 8 Mar 2024 19:46:15 -0300 Subject: [PATCH 14/15] chore: removed `ReportCardGradeCycle` and `ReportCardGradeSubject` schemas removed schemas, tables, and related functions and UI. schemas removed in favor of `GradesReportCycle` and `GradesReportSubject`. --- lib/lanttern/reporting.ex | 170 ----------- .../reporting/report_card_grade_cycle.ex | 24 -- .../reporting/report_card_grade_subject.ex | 26 -- .../pages/report_cards/id/grades_component.ex | 279 ------------------ ...report_card_grades_cycles_and_subjects.exs | 33 +++ test/lanttern/reporting_test.exs | 195 ------------ test/support/fixtures/reporting_fixtures.ex | 42 +-- 7 files changed, 39 insertions(+), 730 deletions(-) delete mode 100644 lib/lanttern/reporting/report_card_grade_cycle.ex delete mode 100644 lib/lanttern/reporting/report_card_grade_subject.ex create mode 100644 priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs diff --git a/lib/lanttern/reporting.ex b/lib/lanttern/reporting.ex index 7cf20141..0e17efc0 100644 --- a/lib/lanttern/reporting.ex +++ b/lib/lanttern/reporting.ex @@ -11,8 +11,6 @@ defmodule Lanttern.Reporting do alias Lanttern.Reporting.ReportCard alias Lanttern.Reporting.StrandReport alias Lanttern.Reporting.StudentReportCard - alias Lanttern.Reporting.ReportCardGradeSubject - alias Lanttern.Reporting.ReportCardGradeCycle alias Lanttern.Reporting.GradesReport alias Lanttern.Reporting.GradesReportSubject alias Lanttern.Reporting.GradesReportCycle @@ -599,174 +597,6 @@ defmodule Lanttern.Reporting do |> Enum.map(&{&1, Map.get(ast_entries_map, &1.id, [])}) end - @doc """ - Returns the list of report card grades subjects. - - Results are ordered by position and preloaded subjects. - - ## Examples - - iex> list_report_card_grades_subjects(1) - [%ReportCardGradeSubject{}, ...] - - """ - @spec list_report_card_grades_subjects(report_card_id :: integer()) :: [ - ReportCardGradeSubject.t() - ] - - def list_report_card_grades_subjects(report_card_id) do - from(rcgs in ReportCardGradeSubject, - order_by: rcgs.position, - join: s in assoc(rcgs, :subject), - preload: [subject: s], - where: rcgs.report_card_id == ^report_card_id - ) - |> Repo.all() - end - - @doc """ - Add a subject to a report card grades section. - - ## Examples - - iex> add_subject_to_report_card_grades(%{field: value}) - {:ok, %ReportCardGradeSubject{}} - - iex> add_subject_to_report_card_grades(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ - - @spec add_subject_to_report_card_grades(map()) :: - {:ok, ReportCardGradeSubject.t()} | {:error, Ecto.Changeset.t()} - def add_subject_to_report_card_grades(attrs \\ %{}) do - %ReportCardGradeSubject{} - |> ReportCardGradeSubject.changeset(attrs) - |> set_report_card_grade_subject_position() - |> Repo.insert() - end - - # skip if not valid - defp set_report_card_grade_subject_position(%Ecto.Changeset{valid?: false} = changeset), - do: changeset - - # skip if changeset already has position change - defp set_report_card_grade_subject_position( - %Ecto.Changeset{changes: %{position: _position}} = changeset - ), - do: changeset - - defp set_report_card_grade_subject_position(%Ecto.Changeset{} = changeset) do - report_card_id = - Ecto.Changeset.get_field(changeset, :report_card_id) - - position = - from( - rcgs in ReportCardGradeSubject, - where: rcgs.report_card_id == ^report_card_id, - select: rcgs.position, - order_by: [desc: rcgs.position], - limit: 1 - ) - |> Repo.one() - |> case do - nil -> 0 - pos -> pos + 1 - end - - changeset - |> Ecto.Changeset.put_change(:position, position) - end - - @doc """ - Update report card grades subjects positions based on ids list order. - - ## Examples - - iex> update_report_card_grades_subjects_positions([3, 2, 1]) - :ok - - """ - @spec update_report_card_grades_subjects_positions([integer()]) :: :ok | {:error, String.t()} - def update_report_card_grades_subjects_positions(report_card_grades_subjects_ids), - do: Utils.update_positions(ReportCardGradeSubject, report_card_grades_subjects_ids) - - @doc """ - Deletes a report card grade subject. - - ## Examples - - iex> delete_report_card_grade_subject(report_card_grade_subject) - {:ok, %ReportCardGradeSubject{}} - - iex> delete_report_card_grade_subject(report_card_grade_subject) - {:error, %Ecto.Changeset{}} - - """ - def delete_report_card_grade_subject(%ReportCardGradeSubject{} = report_card_grade_subject) do - Repo.delete(report_card_grade_subject) - end - - @doc """ - Returns the list of report card grades cycles. - - Results are ordered asc by cycle `end_at` and desc by cycle `start_at`, and have preloaded school cycles. - - ## Examples - - iex> list_report_card_grades_cycles(1) - [%ReportCardGradeCycle{}, ...] - - """ - @spec list_report_card_grades_cycles(report_card_id :: integer()) :: [ - ReportCardGradeCycle.t() - ] - - def list_report_card_grades_cycles(report_card_id) do - from(rcgc in ReportCardGradeCycle, - join: sc in assoc(rcgc, :school_cycle), - preload: [school_cycle: sc], - where: rcgc.report_card_id == ^report_card_id, - order_by: [asc: sc.end_at, desc: sc.start_at] - ) - |> Repo.all() - end - - @doc """ - Add a cycle to a report card grades section. - - ## Examples - - iex> add_cycle_to_report_card_grades(%{field: value}) - {:ok, %ReportCardGradeCycle{}} - - iex> add_cycle_to_report_card_grades(%{field: bad_value}) - {:error, %Ecto.Changeset{}} - """ - - @spec add_cycle_to_report_card_grades(map()) :: - {:ok, ReportCardGradeCycle.t()} | {:error, Ecto.Changeset.t()} - def add_cycle_to_report_card_grades(attrs \\ %{}) do - %ReportCardGradeCycle{} - |> ReportCardGradeCycle.changeset(attrs) - |> Repo.insert() - end - - @doc """ - Deletes a report card grade cycle. - - ## Examples - - iex> delete_report_card_grade_cycle(report_card_grade_cycle) - {:ok, %ReportCardGradeCycle{}} - - iex> delete_report_card_grade_cycle(report_card_grade_cycle) - {:error, %Ecto.Changeset{}} - - """ - def delete_report_card_grade_cycle(%ReportCardGradeCycle{} = report_card_grade_cycle) do - Repo.delete(report_card_grade_cycle) - end - @doc """ Returns the list of grade reports. diff --git a/lib/lanttern/reporting/report_card_grade_cycle.ex b/lib/lanttern/reporting/report_card_grade_cycle.ex deleted file mode 100644 index fe5cbd4e..00000000 --- a/lib/lanttern/reporting/report_card_grade_cycle.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule Lanttern.Reporting.ReportCardGradeCycle do - use Ecto.Schema - import Ecto.Changeset - - import LantternWeb.Gettext - - schema "report_card_grades_cycles" do - belongs_to :school_cycle, Lanttern.Schools.Cycle - belongs_to :report_card, Lanttern.Reporting.ReportCard - - timestamps() - end - - @doc false - def changeset(report_card_grade_cycle, attrs) do - report_card_grade_cycle - |> cast(attrs, [:school_cycle_id, :report_card_id]) - |> validate_required([:school_cycle_id, :report_card_id]) - |> unique_constraint(:school_cycle_id, - name: "report_card_grades_cycles_report_card_id_school_cycle_id_index", - message: gettext("Cycle already added to this report card grades report") - ) - end -end diff --git a/lib/lanttern/reporting/report_card_grade_subject.ex b/lib/lanttern/reporting/report_card_grade_subject.ex deleted file mode 100644 index 17ad584d..00000000 --- a/lib/lanttern/reporting/report_card_grade_subject.ex +++ /dev/null @@ -1,26 +0,0 @@ -defmodule Lanttern.Reporting.ReportCardGradeSubject do - use Ecto.Schema - import Ecto.Changeset - - import LantternWeb.Gettext - - schema "report_card_grades_subjects" do - field :position, :integer, default: 0 - - belongs_to :subject, Lanttern.Taxonomy.Subject - belongs_to :report_card, Lanttern.Reporting.ReportCard - - timestamps() - end - - @doc false - def changeset(report_card_grade_subject, attrs) do - report_card_grade_subject - |> cast(attrs, [:position, :subject_id, :report_card_id]) - |> validate_required([:subject_id, :report_card_id]) - |> unique_constraint(:subject_id, - name: "report_card_grades_subjects_report_card_id_subject_id_index", - message: gettext("Subject already added to this report card grades report") - ) - end -end diff --git a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex index e7f14bef..64066f27 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/grades_component.ex @@ -2,10 +2,6 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do use LantternWeb, :live_component alias Lanttern.Reporting - alias Lanttern.Schools - alias Lanttern.Taxonomy - - import Lanttern.Utils, only: [swap: 3] # shared import LantternWeb.ReportingComponents @@ -30,169 +26,13 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do <% end %>
    -

    - <%= gettext("Select subjects and cycles to build the grades report grid.") %> -

    -
    -
    - <.badge_button - :for={subject <- @subjects} - theme={if subject.id in @selected_subjects_ids, do: "primary", else: "default"} - icon_name={ - if subject.id in @selected_subjects_ids, do: "hero-check-mini", else: "hero-plus-mini" - } - phx-click={JS.push("toggle_subject", value: %{"id" => subject.id}, target: @myself)} - > - <%= subject.name %> - -
    -
    - <.badge_button - :for={cycle <- @cycles} - theme={if cycle.id in @selected_cycles_ids, do: "primary", else: "default"} - icon_name={ - if cycle.id in @selected_cycles_ids, do: "hero-check-mini", else: "hero-plus-mini" - } - phx-click={JS.push("toggle_cycle", value: %{"id" => cycle.id}, target: @myself)} - > - <%= cycle.name %> - -
    -
    -
    - <.grades_grid - sortable_grades_subjects={@sortable_grades_subjects} - grades_cycles={@grades_cycles} - myself={@myself} - /> -
    """ end - # function components - - attr :sortable_grades_subjects, :list, required: true - attr :grades_cycles, :list, required: true - attr :myself, :any, required: true - - def grades_grid(assigns) do - grid_template_columns_style = - case length(assigns.grades_cycles) do - n when n > 1 -> - "grid-template-columns: 160px repeat(#{n}, minmax(0, 1fr))" - - _ -> - "grid-template-columns: 160px minmax(0, 1fr)" - end - - grid_column_style = - case length(assigns.grades_cycles) do - 0 -> "grid-column: span 2 / span 2" - n -> "grid-column: span #{n + 1} / span #{n + 1}" - end - - assigns = - assigns - |> assign(:grid_template_columns_style, grid_template_columns_style) - |> assign(:grid_column_style, grid_column_style) - |> assign(:has_subjects, length(assigns.sortable_grades_subjects) > 0) - |> assign(:has_cycles, length(assigns.grades_cycles) > 0) - - ~H""" -
    -
    - <%= if @has_cycles do %> -
    - <%= grade_cycle.school_cycle.name %> -
    - <% else %> -
    - <%= gettext("Use the buttons above to add cycles to this grid") %> -
    - <% end %> - <%= if @has_subjects do %> -
    - <.sortable_card - is_move_up_disabled={i == 0} - on_move_up={ - JS.push("swap_grades_subjects_position", - value: %{from: i, to: i - 1}, - target: @myself - ) - } - is_move_down_disabled={i + 1 == length(@sortable_grades_subjects)} - on_move_down={ - JS.push("swap_grades_subjects_position", - value: %{from: i, to: i + 1}, - target: @myself - ) - } - > - <%= grade_subject.subject.name %> - - <%= if @has_cycles do %> -
    -
    - <% else %> -
    - <% end %> -
    - <% else %> -
    -
    - <%= gettext("Use the buttons above to add subjects to this grid") %> -
    - <%= if @has_cycles do %> -
    -
    - <% else %> -
    - <% end %> -
    - <% end %> - <%!--

    Subjects

    -
    - <%= grade_subject.subject.name %> -
    - -

    Cycles

    -
    - <%= grade_cycle.school_cycle.name %> -
    --%> -
    - """ - end - # lifecycle - @impl true - def mount(socket) do - socket = - socket - |> assign(:subjects, Taxonomy.list_subjects()) - |> assign(:cycles, Schools.list_cycles()) - |> assign(:has_grades_subjects_order_change, false) - - {:ok, socket} - end - @impl true def update(assigns, socket) do socket = @@ -204,126 +44,7 @@ defmodule LantternWeb.ReportCardLive.GradesComponent do id -> Reporting.get_grades_report(id, load_grid: true) end end) - |> assign_new(:sortable_grades_subjects, fn %{report_card: report_card} -> - Reporting.list_report_card_grades_subjects(report_card.id) - |> Enum.with_index() - end) - |> assign_new(:selected_subjects_ids, fn %{ - sortable_grades_subjects: - sortable_grades_subjects - } -> - Enum.map(sortable_grades_subjects, fn {grade_subject, _i} -> grade_subject.subject.id end) - end) - |> assign_new(:grades_cycles, fn %{report_card: report_card} -> - Reporting.list_report_card_grades_cycles(report_card.id) - end) - |> assign_new(:selected_cycles_ids, fn %{grades_cycles: grades_cycles} -> - Enum.map(grades_cycles, & &1.school_cycle.id) - end) {:ok, socket} end - - # event handlers - - @impl true - def handle_event("toggle_subject", %{"id" => subject_id}, socket) do - socket = - case subject_id in socket.assigns.selected_subjects_ids do - true -> remove_subject_grade_report(socket, subject_id) - false -> add_subject_grade_report(socket, subject_id) - end - - {:noreply, socket} - end - - def handle_event("toggle_cycle", %{"id" => cycle_id}, socket) do - socket = - case cycle_id in socket.assigns.selected_cycles_ids do - true -> remove_cycle_grade_report(socket, cycle_id) - false -> add_cycle_grade_report(socket, cycle_id) - end - - {:noreply, socket} - end - - def handle_event("swap_grades_subjects_position", %{"from" => i, "to" => j}, socket) do - sortable_grades_subjects = - socket.assigns.sortable_grades_subjects - |> Enum.map(fn {grade_subject, _i} -> grade_subject end) - |> swap(i, j) - |> Enum.with_index() - - sortable_grades_subjects - |> Enum.map(fn {grade_subject, _i} -> grade_subject.id end) - |> Reporting.update_report_card_grades_subjects_positions() - |> case do - :ok -> - socket = - socket - |> assign(:sortable_grades_subjects, sortable_grades_subjects) - - {:noreply, socket} - - {:error, msg} -> - {:noreply, put_flash(socket, :error, msg)} - end - end - - defp add_subject_grade_report(socket, subject_id) do - %{ - report_card_id: socket.assigns.report_card.id, - subject_id: subject_id - } - |> Reporting.add_subject_to_report_card_grades() - |> case do - {:ok, _report_card_grade_subject} -> - push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") - - {:error, _changeset} -> - put_flash(socket, :error, gettext("Error adding subject to report card grades")) - end - end - - defp remove_subject_grade_report(socket, subject_id) do - socket.assigns.sortable_grades_subjects - |> Enum.map(fn {grade_subject, _i} -> grade_subject end) - |> Enum.find(&(&1.subject_id == subject_id)) - |> Reporting.delete_report_card_grade_subject() - |> case do - {:ok, _report_card_grade_subject} -> - push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") - - {:error, _changeset} -> - put_flash(socket, :error, gettext("Error removing subject from report card grades")) - end - end - - defp add_cycle_grade_report(socket, cycle_id) do - %{ - report_card_id: socket.assigns.report_card.id, - school_cycle_id: cycle_id - } - |> Reporting.add_cycle_to_report_card_grades() - |> case do - {:ok, _report_card_grade_cycle} -> - push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") - - {:error, _changeset} -> - put_flash(socket, :error, gettext("Error adding cycle to report card grades")) - end - end - - defp remove_cycle_grade_report(socket, cycle_id) do - socket.assigns.grades_cycles - |> Enum.find(&(&1.school_cycle_id == cycle_id)) - |> Reporting.delete_report_card_grade_cycle() - |> case do - {:ok, _report_card_grade_cycle} -> - push_navigate(socket, to: ~p"/report_cards/#{socket.assigns.report_card}?tab=grades") - - {:error, _changeset} -> - put_flash(socket, :error, gettext("Error removing cycle from report card grades")) - end - end end diff --git a/priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs b/priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs new file mode 100644 index 00000000..dec1b737 --- /dev/null +++ b/priv/repo/migrations/20240308223550_drop_report_card_grades_cycles_and_subjects.exs @@ -0,0 +1,33 @@ +defmodule Lanttern.Repo.Migrations.DropReportCardGradesCyclesAndSubjects do + use Ecto.Migration + + def up do + drop table(:report_card_grades_subjects) + drop table(:report_card_grades_cycles) + end + + def down do + # report card grades subjects + create table(:report_card_grades_subjects) do + add :position, :integer, null: false, default: 0 + add :subject_id, references(:subjects, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_subjects, [:subject_id]) + create unique_index(:report_card_grades_subjects, [:report_card_id, :subject_id]) + + # report card grades cycles + create table(:report_card_grades_cycles) do + add :school_cycle_id, references(:school_cycles, on_delete: :nothing), null: false + add :report_card_id, references(:report_cards, on_delete: :nothing), null: false + + timestamps() + end + + create index(:report_card_grades_cycles, [:school_cycle_id]) + create unique_index(:report_card_grades_cycles, [:report_card_id, :school_cycle_id]) + end +end diff --git a/test/lanttern/reporting_test.exs b/test/lanttern/reporting_test.exs index 7e38120f..113109a5 100644 --- a/test/lanttern/reporting_test.exs +++ b/test/lanttern/reporting_test.exs @@ -595,201 +595,6 @@ defmodule Lanttern.ReportingTest do end end - describe "report card grade subject and cycle" do - alias Lanttern.Reporting.ReportCardGradeSubject - alias Lanttern.Reporting.ReportCardGradeCycle - - import Lanttern.ReportingFixtures - alias Lanttern.SchoolsFixtures - alias Lanttern.TaxonomyFixtures - - test "list_report_card_grades_subjects/1 returns all report card grades subjects ordered by position and subjects preloaded" do - report_card = report_card_fixture() - subject_1 = TaxonomyFixtures.subject_fixture() - subject_2 = TaxonomyFixtures.subject_fixture() - - report_card_grade_subject_1 = - report_card_grade_subject_fixture(%{ - report_card_id: report_card.id, - subject_id: subject_1.id - }) - - report_card_grade_subject_2 = - report_card_grade_subject_fixture(%{ - report_card_id: report_card.id, - subject_id: subject_2.id - }) - - assert [expected_rcgs_1, expected_rcgs_2] = - Reporting.list_report_card_grades_subjects(report_card.id) - - assert expected_rcgs_1.id == report_card_grade_subject_1.id - assert expected_rcgs_1.subject.id == subject_1.id - - assert expected_rcgs_2.id == report_card_grade_subject_2.id - assert expected_rcgs_2.subject.id == subject_2.id - end - - test "add_subject_to_report_card_grades/1 with valid data creates a report card grade subject" do - report_card = report_card_fixture() - subject = TaxonomyFixtures.subject_fixture() - - valid_attrs = %{ - report_card_id: report_card.id, - subject_id: subject.id - } - - assert {:ok, %ReportCardGradeSubject{} = report_card_grade_subject} = - Reporting.add_subject_to_report_card_grades(valid_attrs) - - assert report_card_grade_subject.report_card_id == report_card.id - assert report_card_grade_subject.subject_id == subject.id - assert report_card_grade_subject.position == 0 - - # insert one more report card grade subject in a different report card to test position auto increment scope - - # extra fixture in different report card - report_card_grade_subject_fixture() - - subject = TaxonomyFixtures.subject_fixture() - - valid_attrs = %{ - report_card_id: report_card.id, - subject_id: subject.id - } - - assert {:ok, %ReportCardGradeSubject{} = report_card_grade_subject} = - Reporting.add_subject_to_report_card_grades(valid_attrs) - - assert report_card_grade_subject.report_card_id == report_card.id - assert report_card_grade_subject.subject_id == subject.id - assert report_card_grade_subject.position == 1 - end - - test "update_report_card_grades_subjects_positions/1 update report card grades subjects positions based on list order" do - report_card = report_card_fixture() - - report_card_grade_subject_1 = - report_card_grade_subject_fixture(%{report_card_id: report_card.id}) - - report_card_grade_subject_2 = - report_card_grade_subject_fixture(%{report_card_id: report_card.id}) - - report_card_grade_subject_3 = - report_card_grade_subject_fixture(%{report_card_id: report_card.id}) - - report_card_grade_subject_4 = - report_card_grade_subject_fixture(%{report_card_id: report_card.id}) - - sorted_report_card_grades_subjects_ids = - [ - report_card_grade_subject_2.id, - report_card_grade_subject_3.id, - report_card_grade_subject_1.id, - report_card_grade_subject_4.id - ] - - assert :ok == - Reporting.update_report_card_grades_subjects_positions( - sorted_report_card_grades_subjects_ids - ) - - assert [ - expected_rcgs_2, - expected_rcgs_3, - expected_rcgs_1, - expected_rcgs_4 - ] = - Reporting.list_report_card_grades_subjects(report_card.id) - - assert expected_rcgs_1.id == report_card_grade_subject_1.id - assert expected_rcgs_2.id == report_card_grade_subject_2.id - assert expected_rcgs_3.id == report_card_grade_subject_3.id - assert expected_rcgs_4.id == report_card_grade_subject_4.id - end - - test "delete_report_card_grade_subject/1 deletes the report_card_grade_subject" do - report_card_grade_subject = report_card_grade_subject_fixture() - - assert {:ok, %ReportCardGradeSubject{}} = - Reporting.delete_report_card_grade_subject(report_card_grade_subject) - - assert_raise Ecto.NoResultsError, fn -> - Repo.get!(ReportCardGradeSubject, report_card_grade_subject.id) - end - end - - test "list_report_card_grades_cycles/1 returns all report card grades cycles ordered by dates and preloaded cycles" do - report_card = report_card_fixture() - - cycle_2023 = - SchoolsFixtures.cycle_fixture(%{start_at: ~D[2023-01-01], end_at: ~D[2023-12-31]}) - - cycle_2024_q4 = - SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-09-01], end_at: ~D[2024-12-31]}) - - cycle_2024 = - SchoolsFixtures.cycle_fixture(%{start_at: ~D[2024-01-01], end_at: ~D[2024-12-31]}) - - report_card_grade_cycle_2023 = - report_card_grade_cycle_fixture(%{ - report_card_id: report_card.id, - school_cycle_id: cycle_2023.id - }) - - report_card_grade_cycle_2024_q4 = - report_card_grade_cycle_fixture(%{ - report_card_id: report_card.id, - school_cycle_id: cycle_2024_q4.id - }) - - report_card_grade_cycle_2024 = - report_card_grade_cycle_fixture(%{ - report_card_id: report_card.id, - school_cycle_id: cycle_2024.id - }) - - assert [expected_rcgc_2023, expected_rcgc_2024_q4, expected_rcgc_2024] = - Reporting.list_report_card_grades_cycles(report_card.id) - - assert expected_rcgc_2023.id == report_card_grade_cycle_2023.id - assert expected_rcgc_2023.school_cycle.id == cycle_2023.id - - assert expected_rcgc_2024_q4.id == report_card_grade_cycle_2024_q4.id - assert expected_rcgc_2024_q4.school_cycle.id == cycle_2024_q4.id - - assert expected_rcgc_2024.id == report_card_grade_cycle_2024.id - assert expected_rcgc_2024.school_cycle.id == cycle_2024.id - end - - test "add_cycle_to_report_card_grades/1 with valid data creates a report card grade cycle" do - report_card = report_card_fixture() - school_cycle = SchoolsFixtures.cycle_fixture() - - valid_attrs = %{ - report_card_id: report_card.id, - school_cycle_id: school_cycle.id - } - - assert {:ok, %ReportCardGradeCycle{} = report_card_grade_cycle} = - Reporting.add_cycle_to_report_card_grades(valid_attrs) - - assert report_card_grade_cycle.report_card_id == report_card.id - assert report_card_grade_cycle.school_cycle_id == school_cycle.id - end - - test "delete_report_card_grade_cycle/1 deletes the report_card_grade_cycle" do - report_card_grade_cycle = report_card_grade_cycle_fixture() - - assert {:ok, %ReportCardGradeCycle{}} = - Reporting.delete_report_card_grade_cycle(report_card_grade_cycle) - - assert_raise Ecto.NoResultsError, fn -> - Repo.get!(ReportCardGradeCycle, report_card_grade_cycle.id) - end - end - end - describe "grades_reports" do alias Lanttern.Reporting.GradesReport diff --git a/test/support/fixtures/reporting_fixtures.ex b/test/support/fixtures/reporting_fixtures.ex index c8743bce..a5bfcc7a 100644 --- a/test/support/fixtures/reporting_fixtures.ex +++ b/test/support/fixtures/reporting_fixtures.ex @@ -85,42 +85,6 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_student_id(_attrs), do: Lanttern.SchoolsFixtures.student_fixture().id - @doc """ - Generate a report_card_grade_subject. - """ - def report_card_grade_subject_fixture(attrs \\ %{}) do - {:ok, report_card_grade_subject} = - attrs - |> Enum.into(%{ - report_card_id: maybe_gen_report_card_id(attrs), - subject_id: maybe_gen_subject_id(attrs) - }) - |> Lanttern.Reporting.add_subject_to_report_card_grades() - - report_card_grade_subject - end - - defp maybe_gen_subject_id(%{subject_id: subject_id} = _attrs), - do: subject_id - - defp maybe_gen_subject_id(_attrs), - do: Lanttern.TaxonomyFixtures.subject_fixture().id - - @doc """ - Generate a report_card_grade_cycle. - """ - def report_card_grade_cycle_fixture(attrs \\ %{}) do - {:ok, report_card_grade_cycle} = - attrs - |> Enum.into(%{ - report_card_id: maybe_gen_report_card_id(attrs), - school_cycle_id: maybe_gen_school_cycle_id(attrs) - }) - |> Lanttern.Reporting.add_cycle_to_report_card_grades() - - report_card_grade_cycle - end - @doc """ Generate a grade report. """ @@ -165,6 +129,12 @@ defmodule Lanttern.ReportingFixtures do defp maybe_gen_grades_report_id(_attrs), do: grades_report_fixture().id + defp maybe_gen_subject_id(%{subject_id: subject_id} = _attrs), + do: subject_id + + defp maybe_gen_subject_id(_attrs), + do: Lanttern.TaxonomyFixtures.subject_fixture().id + @doc """ Generate a grades_report_cycle. """ From a2cbfd28188472383db17ea0e60c0b4d9bc9dd15 Mon Sep 17 00:00:00 2001 From: endoooo Date: Fri, 8 Mar 2024 20:05:36 -0300 Subject: [PATCH 15/15] chore: adjusted report card edit UX keep the current tab when editing report card overlay --- .../pages/report_cards/id/report_card_live.ex | 32 +++++++------------ .../id/report_card_live.html.heex | 10 +++--- lib/lanttern_web/router.ex | 1 - 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex index fcd28590..805f7bbe 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.ex @@ -20,24 +20,10 @@ defmodule LantternWeb.ReportCardLive do # lifecycle @impl true - def mount(params, _session, socket) do - socket = - socket - |> maybe_redirect(params) - + def mount(_params, _session, socket) do {:ok, socket, layout: {LantternWeb.Layouts, :app_logged_in_blank}} end - # prevent user from navigating directly to nested views - - defp maybe_redirect(%{assigns: %{live_action: :edit}} = socket, params), - do: redirect(socket, to: ~p"/report_cards/#{params["id"]}?tab=students") - - defp maybe_redirect(%{assigns: %{live_action: :edit_strand_report}} = socket, params), - do: redirect(socket, to: ~p"/report_cards/#{params["id"]}?tab=strands") - - defp maybe_redirect(socket, _params), do: socket - @impl true def handle_params(%{"id" => id} = params, _url, socket) do socket = @@ -46,20 +32,24 @@ defmodule LantternWeb.ReportCardLive do |> assign_new(:report_card, fn -> Reporting.get_report_card!(id, preloads: [:school_cycle, :year]) end) - |> set_current_tab(params, socket.assigns.live_action) + |> assign_current_tab(params) + |> assign_is_editing(params) {:noreply, socket} end - defp set_current_tab(socket, _params, :edit_strand_report), - do: assign(socket, :current_tab, @tabs["strands"]) - - defp set_current_tab(socket, %{"tab" => tab}, _live_action), + defp assign_current_tab(socket, %{"tab" => tab}), do: assign(socket, :current_tab, Map.get(@tabs, tab, :students)) - defp set_current_tab(socket, _params, _live_action), + defp assign_current_tab(socket, _params), do: assign(socket, :current_tab, :students) + defp assign_is_editing(socket, %{"is_editing" => "true"}), + do: assign(socket, :is_editing, true) + + defp assign_is_editing(socket, _), + do: assign(socket, :is_editing, false) + # event handlers @impl true diff --git a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex index afefe2be..672369b0 100644 --- a/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex +++ b/lib/lanttern_web/live/pages/report_cards/id/report_card_live.html.heex @@ -44,7 +44,9 @@ <:menu_items> <.menu_button_item id={"edit-report-card-#{@report_card.id}"} - phx-click={JS.patch(~p"/report_cards/#{@report_card}/edit")} + phx-click={ + JS.patch(~p"/report_cards/#{@report_card}?tab=#{@current_tab}&is_editing=true") + } > <%= gettext("Edit report card") %> @@ -88,17 +90,17 @@ /> <.slide_over - :if={@live_action == :edit} + :if={@is_editing} id="report-card-form-overlay" show={true} - on_cancel={JS.patch(~p"/report_cards/#{@report_card}")} + on_cancel={JS.patch(~p"/report_cards/#{@report_card}?tab=#{@current_tab}")} > <:title><%= gettext("Edit card") %> <.live_component module={ReportCardFormComponent} id={@report_card.id} report_card={@report_card} - navigate={~p"/report_cards/#{@report_card}"} + navigate={~p"/report_cards/#{@report_card}?tab=#{@current_tab}"} hide_submit /> <:actions> diff --git a/lib/lanttern_web/router.ex b/lib/lanttern_web/router.ex index e8dee809..cbec3f11 100644 --- a/lib/lanttern_web/router.ex +++ b/lib/lanttern_web/router.ex @@ -100,7 +100,6 @@ defmodule LantternWeb.Router do live "/report_cards", ReportCardsLive, :index live "/report_cards/new", ReportCardsLive, :new live "/report_cards/:id", ReportCardLive, :show - live "/report_cards/:id/edit", ReportCardLive, :edit live "/student_report_card/:id", StudentReportCardLive, :show