From 5e30dda3c7220094738a60951ff871b0b3deb79e Mon Sep 17 00:00:00 2001 From: Martin Manelli Date: Tue, 22 Sep 2026 13:24:56 -0300 Subject: [PATCH 01/19] Implemented the learning-objective detach/delete workflow. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Association rows now use the Figma unlink icon with “Detach from learning objective” tooltip and distinct accessible naming. - Detaching preserves the sub-objective, course references, and other LO associations. - Added persisted objective_type so final-detached sub-objectives remain distinguishable and recoverable. - Add Existing now supports Status → Unassociated, searchable results, re-adding, and permanent deletion. - Permanent deletion is server-guarded against LO associations and page/activity/selection references. - Added explicit confirmation copy, success/error announcements, and dialog focus restoration. - Top-level delete tooltip is now exactly “Delete Learning Objective”. --- .../objectives/ObjectivesSelection.tsx | 5 +- .../components/resource/objectives/sort.ts | 2 +- assets/src/data/content/objective.ts | 1 + assets/src/hooks/modal.ts | 30 +- .../objectives/objectives_selection_test.ts | 2 + lib/oli/authoring/editing/objective_editor.ex | 48 ++- lib/oli/authoring/editing/page_editor.ex | 4 +- lib/oli/resources.ex | 1 + lib/oli/resources/revision.ex | 7 + lib/oli_web/icons.ex | 28 ++ .../course_author/objectives/listing.ex | 105 +++--- .../objectives/select_existing_sub_modal.ex | 197 ++++++++--- .../objectives/sub_objective_delete_modal.ex | 44 ++- .../course_author/objectives_live.ex | 329 ++++++++++-------- ...154029_add_objective_type_to_revisions.exs | 15 + test/oli/editing/objective_editor_test.exs | 6 + .../course_author/objectives_live_test.exs | 232 +++++++++--- 17 files changed, 739 insertions(+), 317 deletions(-) create mode 100644 priv/repo/migrations/20260922154029_add_objective_type_to_revisions.exs diff --git a/assets/src/components/resource/objectives/ObjectivesSelection.tsx b/assets/src/components/resource/objectives/ObjectivesSelection.tsx index 14f536e15b2..19ef4412bc6 100644 --- a/assets/src/components/resource/objectives/ObjectivesSelection.tsx +++ b/assets/src/components/resource/objectives/ObjectivesSelection.tsx @@ -35,7 +35,9 @@ export const objectivesForAttachment = ( switch (attachmentType) { case 'page': - return objectives.filter((objective) => !objective.parentIds?.length); + return objectives.filter( + (objective) => objective.objectiveType !== 'sub_objective' && !objective.parentIds?.length, + ); case 'activity': return objectives.map((objective) => ({ ...objective, @@ -187,6 +189,7 @@ export const ObjectivesSelection = (props: ObjectivesProps) => { id: result.resourceId, title: createdObjective.title, parentIds: null, + objectiveType: 'objective', }); // Use the newly created resource id instead of the id of diff --git a/assets/src/components/resource/objectives/sort.ts b/assets/src/components/resource/objectives/sort.ts index a1c9c609584..291230d147e 100644 --- a/assets/src/components/resource/objectives/sort.ts +++ b/assets/src/components/resource/objectives/sort.ts @@ -37,7 +37,7 @@ export function arrangeObjectives(objectives: Objective[]): Immutable.List { // Initialize top-level objectives (those that are not children of anyone) - if (!childIds.has(o.id)) { + if (!childIds.has(o.id) && o.objectiveType !== 'sub_objective') { if (bucketed[o.id] === undefined) { bucketed[o.id] = []; } diff --git a/assets/src/data/content/objective.ts b/assets/src/data/content/objective.ts index 4d2a4283eef..468301b3d43 100644 --- a/assets/src/data/content/objective.ts +++ b/assets/src/data/content/objective.ts @@ -6,4 +6,5 @@ export type Objective = { id: ResourceId; title: string; parentIds: ResourceId[] | null; + objectiveType?: 'objective' | 'sub_objective'; }; diff --git a/assets/src/hooks/modal.ts b/assets/src/hooks/modal.ts index 367121f552b..41449d81018 100644 --- a/assets/src/hooks/modal.ts +++ b/assets/src/hooks/modal.ts @@ -2,6 +2,9 @@ import { lockScroll, unlockScroll } from 'components/modal/utils'; export const ModalLaunch = { mounted(): void { + (this as any).trigger = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + // initialize the bootstrap modal const id = this.el.getAttribute('id'); this.id = id; @@ -9,20 +12,45 @@ export const ModalLaunch = { // ($(this.el) as any).modal({}); this.modal = new (window as any).Modal(this.el, {}); + + const initialFocus = this.el.dataset.initialFocus; + if (initialFocus) { + $(`#${id}`).on('shown.bs.modal', () => { + const target = this.el.querySelector(initialFocus); + if (target instanceof HTMLElement) target.focus(); + }); + } + this.modal.show(); const scrollPosition = lockScroll(); // wire up server-side hide event (this as any).handleEvent('phx_modal.hide', () => { + (this as any).serverHiding = true; this.modal.hide(); }); // handle hiding of a modal as a result of many different methods // (modal close button, escape key, etc...) $(`#${id}`).on('hidden.bs.modal', () => { - (this as any).pushEvent('phx_modal.unmount'); + const dismissEvent = this.el.dataset.dismissEvent; + + if (dismissEvent && !(this as any).serverHiding) { + (this as any).pushEvent(dismissEvent, { + parent_slug: this.el.dataset.parentSlug, + focus_delete_slug: this.el.dataset.focusDeleteSlug, + }); + } else { + (this as any).pushEvent('phx_modal.unmount'); + } + unlockScroll(scrollPosition); + + const trigger = (this as any).trigger; + if (trigger?.isConnected) { + window.requestAnimationFrame(() => trigger.focus()); + } }); }, destroyed(): void { diff --git a/assets/test/components/resource/objectives/objectives_selection_test.ts b/assets/test/components/resource/objectives/objectives_selection_test.ts index 5c731dabb4c..9b871fa2fd7 100644 --- a/assets/test/components/resource/objectives/objectives_selection_test.ts +++ b/assets/test/components/resource/objectives/objectives_selection_test.ts @@ -9,6 +9,7 @@ const objectives: Objective[] = [ { id: 1, title: 'Parent', parentIds: null }, { id: 2, title: 'Another parent', parentIds: [] }, { id: 3, title: 'Child', parentIds: [1] }, + { id: 4, title: 'Unassociated child', parentIds: null, objectiveType: 'sub_objective' }, ]; describe('objective attachment restrictions', () => { @@ -21,6 +22,7 @@ describe('objective attachment restrictions', () => { { ...objectives[0], disabled: true }, { ...objectives[1], disabled: true }, { ...objectives[2], disabled: false }, + { ...objectives[3], disabled: true }, ]); }); diff --git a/lib/oli/authoring/editing/objective_editor.ex b/lib/oli/authoring/editing/objective_editor.ex index b985241a61f..9a1a613b299 100644 --- a/lib/oli/authoring/editing/objective_editor.ex +++ b/lib/oli/authoring/editing/objective_editor.ex @@ -14,11 +14,18 @@ defmodule Oli.Authoring.Editing.ObjectiveEditor do import Oli.Utils + @doc """ + Creates a learning objective or sub-objective and optionally associates it with a parent. + + Passing a non-empty `container_slug` creates a sub-objective. The persisted type remains + stable if that final parent association is later removed. + """ def add_new(attrs, %Author{} = author, %Project{} = project, container_slug \\ nil) do attrs = Map.merge(attrs, %{ author_id: author.id, - resource_type_id: Oli.Resources.ResourceType.id_for_objective() + resource_type_id: Oli.Resources.ResourceType.id_for_objective(), + objective_type: objective_type(container_slug) }) result = @@ -203,24 +210,43 @@ defmodule Oli.Authoring.Editing.ObjectiveEditor do end) end + @doc """ + Removes only the association between a sub-objective and one parent objective. + + The sub-objective remains published in the project, retains its course-content + attachments and other parent associations, and is marked as a sub-objective even + when this was its final parent association. + """ def remove_sub_objective_from_parent( revision_slug, %Author{} = author, %Project{} = project, parent_objective ) do - resource = Resources.get_resource_from_slug(revision_slug) - - edit( - parent_objective.slug, - %{ - children: Enum.filter(parent_objective.children, fn id -> id != resource.id end) - }, - author, - project - ) + Repo.transaction(fn -> + with %{} = resource <- Resources.get_resource_from_slug(revision_slug), + {:ok, _sub_objective} <- + edit(revision_slug, %{objective_type: :sub_objective}, author, project), + {:ok, parent} <- + edit( + parent_objective.slug, + %{ + children: Enum.reject(parent_objective.children, &(&1 == resource.id)) + }, + author, + project + ) do + parent + else + nil -> Repo.rollback({:error, :not_found}) + error -> Repo.rollback(error) + end + end) end + defp objective_type(container_slug) when container_slug in [nil, ""], do: :objective + defp objective_type(_container_slug), do: :sub_objective + @doc """ Detaches an objective from all unlocked pages and activites that currently reference it. diff --git a/lib/oli/authoring/editing/page_editor.ex b/lib/oli/authoring/editing/page_editor.ex index 62ff2d705b2..5fbe0a9a18b 100644 --- a/lib/oli/authoring/editing/page_editor.ex +++ b/lib/oli/authoring/editing/page_editor.ex @@ -876,6 +876,7 @@ defmodule Oli.Authoring.Editing.PageEditor do # id: the slug of the objective # title: the title of the objective # parentIds: a list of parent objective ids, nil if no parent objectives + # objectiveType: whether the item is a top-level objective or sub-objective # } # # Note: This function returns a unique entry per objective, with parentIds as a list @@ -900,7 +901,8 @@ defmodule Oli.Authoring.Editing.PageEditor do %{ id: revision.resource_id, title: revision.title, - parentIds: parent_ids + parentIds: parent_ids, + objectiveType: Map.get(revision, :objective_type, :objective) } end) end diff --git a/lib/oli/resources.ex b/lib/oli/resources.ex index bae8a081f39..5c9395deacb 100644 --- a/lib/oli/resources.ex +++ b/lib/oli/resources.ex @@ -331,6 +331,7 @@ defmodule Oli.Resources do content: previous_revision.content, objectives: previous_revision.objectives, children: previous_revision.children, + objective_type: previous_revision.objective_type, deleted: previous_revision.deleted, ids_added: previous_revision.ids_added, slug: previous_revision.slug, diff --git a/lib/oli/resources/revision.ex b/lib/oli/resources/revision.ex index bf4b479410b..0a8b2a5ffcb 100644 --- a/lib/oli/resources/revision.ex +++ b/lib/oli/resources/revision.ex @@ -23,6 +23,7 @@ defmodule Oli.Resources.Revision do :scoring_strategy_id, :activity_type_id, :title, + :objective_type, :resource_id, :intro_video, :poster_image, @@ -50,6 +51,11 @@ defmodule Oli.Resources.Revision do # fields that apply to only a subset of the types field :content, :map, default: %{} field :children, {:array, :id}, default: [] + + field :objective_type, Ecto.Enum, + values: [:objective, :sub_objective], + default: :objective + field :tags, {:array, :id}, default: [] field :activity_refs, {:array, :id}, default: [] field :objectives, :map, default: %{} @@ -121,6 +127,7 @@ defmodule Oli.Resources.Revision do :resource_type_id, :content, :children, + :objective_type, :tags, :objectives, :graded, diff --git a/lib/oli_web/icons.ex b/lib/oli_web/icons.ex index f2cd4987945..6ea28478d35 100644 --- a/lib/oli_web/icons.ex +++ b/lib/oli_web/icons.ex @@ -976,6 +976,34 @@ defmodule OliWeb.Icons do attr :stroke_width, :string, default: "2" attr :variant, :string, default: "default" + def unlink(assigns) do + ~H""" + + """ + end + + attr :class, :string, default: "stroke-black dark:stroke-white" + attr :width, :string, default: "24" + attr :height, :string, default: "24" + attr :stroke_width, :string, default: "2" + attr :variant, :string, default: "default" + def trash(assigns) do ~H""" - + + + + <.link :if={@revision_history_link} @@ -263,8 +269,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.Objectives.Listing do :if={!is_nil(sub_objective)} class={[ "group/item flex flex-wrap items-center gap-[10px] rounded-md border bg-Background-bg-secondary p-3", - issue_border_class(sub_objective.any_issue), - MapSet.member?(@pending_delete_slugs, sub_objective.slug) && "opacity-50" + issue_border_class(sub_objective.any_issue) ]} > <% child_expanded? = MapSet.member?(@expanded_slugs, sub_objective.slug) %> @@ -295,10 +300,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.Objectives.Listing do ]} /> - + <.highlighted_title title={sub_objective.title} regex={@highlight_regex} @@ -306,15 +308,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.Objectives.Listing do /> - <.loader - :if={MapSet.member?(@pending_delete_slugs, sub_objective.slug)} - class="ml-2" - icon_class="text-secondary" - /> -
+
- + + + +
assign( add: assigns.add, - filtered_sub_objectives: assigns.sub_objectives, + delete: assigns.delete, + focus_delete_slug: assigns.focus_delete_slug, id: assigns.id, parent_slug: assigns.parent_slug, + query: query, + status: status, sub_objectives: assigns.sub_objectives - )} + ) + |> assign_filtered_sub_objectives()} end attr(:add, :string, required: true) + attr(:delete, :string, required: true) attr(:filtered_sub_objectives, :list, default: []) + attr(:focus_delete_slug, :string, default: nil) attr(:id, :string) attr(:parent_slug, :string, required: true) attr(:query, :string, default: "") + attr(:status, :string, default: "all") attr(:sub_objectives, :list, default: []) def render(assigns) do @@ -29,43 +40,119 @@ defmodule OliWeb.Workspaces.CourseAuthor.Objectives.SelectExistingSubModal do style="display: block" tabindex="-1" role="dialog" - aria-labelledby="show-existing-sub-modal" - aria-hidden="true" + aria-modal="true" + aria-labelledby={"#{@id}-title"} + data-initial-focus={@focus_delete_slug && "#delete-sub-objective-#{@focus_delete_slug}"} phx-hook="ModalLaunch" > -