diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fad798522e0..41c30d9eee3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,6 +13,7 @@ on: - tokamak.oli.cmu.edu - stellarator.oli.cmu.edu - heliotron.oli.cmu.edu + - neutron.oli.cmu.edu - loadtest.oli.cmu.edu - proton.oli.cmu.edu diff --git a/assets/css/app.css b/assets/css/app.css index d2e2c4b9308..0635f7e4138 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -127,6 +127,10 @@ https://tailwindcss.com/docs/utility-first @apply mb-[20px] text-2xl tracking-[0.02px] font-light dark:text-white; } +.truncate-form-control p { + @apply truncate; +} + .scrollbar-hide::-webkit-scrollbar { display: none; } diff --git a/assets/src/apps/Components.tsx b/assets/src/apps/Components.tsx index 27d0abdd6ba..973b0cdcc85 100644 --- a/assets/src/apps/Components.tsx +++ b/assets/src/apps/Components.tsx @@ -4,6 +4,7 @@ import { Navbar } from 'components/common/Navbar'; import { SelectTimezone } from 'components/common/SelectTimezone'; import { TechSupportButton } from 'components/common/TechSupportButton'; import { UserAccountMenu } from 'components/common/UserAccountMenu'; +import { RichTextEditor } from 'components/content/RichTextEditor'; import { AlternativesPreferenceSelector } from 'components/delivery/AlternativesPreferenceSelector'; import { CourseContentOutline } from 'components/delivery/CourseContentOutline'; import { SurveyControls } from 'components/delivery/SurveyControls'; @@ -35,3 +36,4 @@ registerApplication('SelectTimezone', SelectTimezone, globalStore); registerApplication('YoutubePlayer', YoutubePlayer, globalStore); registerApplication('TechSupportButton', TechSupportButton, globalStore); registerApplication('OfflineDetector', OfflineDetector, globalStore); +registerApplication('RichTextEditor', RichTextEditor, globalStore); diff --git a/assets/src/apps/delivery/layouts/deck/LessonFinishedDialog.tsx b/assets/src/apps/delivery/layouts/deck/LessonFinishedDialog.tsx index 36be17dc5ae..dc39348dfc4 100644 --- a/assets/src/apps/delivery/layouts/deck/LessonFinishedDialog.tsx +++ b/assets/src/apps/delivery/layouts/deck/LessonFinishedDialog.tsx @@ -113,10 +113,10 @@ const LessonFinishedDialog: React.FC = ({ aria-hidden={!isOpen} style={{ display: isOpen ? 'block' : 'none', - minHeight: '350px', + minHeight: '250px', height: 'unset', width: '500px', - top: '20%', + top: '25%', backgroundImage: imageUrl ? `url('${imageUrl}')` : '', left: '50%', }} diff --git a/assets/src/apps/delivery/layouts/deck/components/ReviewModeHistoryPanel.tsx b/assets/src/apps/delivery/layouts/deck/components/ReviewModeHistoryPanel.tsx index d6763488e07..5c431c8fc99 100644 --- a/assets/src/apps/delivery/layouts/deck/components/ReviewModeHistoryPanel.tsx +++ b/assets/src/apps/delivery/layouts/deck/components/ReviewModeHistoryPanel.tsx @@ -109,6 +109,7 @@ const ReviewModeHistoryPanel: React.FC = ({ items }
Lesson History handleToggleHistory(false)} style={{ float: 'right', color: 'white', cursor: 'pointer' }} > @@ -116,7 +117,7 @@ const ReviewModeHistoryPanel: React.FC = ({ items }
= (props) => { updateScore={(_id, score) => dispatch(ResponseActions.editResponseScore(response.id, score)) } - customScoring={hasCustomScoring(model, props.input.partId)} + customScoring={model.customScoring} removeResponse={(id) => dispatch(ResponseActions.removeResponse(id))} key={response.id} > diff --git a/assets/src/components/content/RichTextEditor.tsx b/assets/src/components/content/RichTextEditor.tsx index 90bef13a8a1..3926ea3c8c4 100644 --- a/assets/src/components/content/RichTextEditor.tsx +++ b/assets/src/components/content/RichTextEditor.tsx @@ -25,6 +25,12 @@ type Props = { onChangeTextDirection?: (textDirection: 'ltr' | 'rtl') => void; onEdit: (value: Descendant[], editor: SlateEditor, operations: Operation[]) => void; onRequestMedia?: (request: MediaItemRequest) => Promise; + // the name of the event to be pushed back to the liveview (or live_component) when rendered with React.component + onEditEvent?: string; + // the name of the event target element (if the target is a live_component, ex: "#my-live-component-id") + onEditTarget?: string; + pushEvent?: (event: string, payload: any) => void; + pushEventTo?: (selectorOrTarget: string, event: string, payload: any) => void; }; export const RichTextEditor: React.FC = ({ projectSlug, @@ -42,10 +48,29 @@ export const RichTextEditor: React.FC = ({ children, textDirection, onChangeTextDirection, + onEditEvent, + onEditTarget, + pushEvent, + pushEventTo, }) => { // Support content persisted when RichText had a `model` property. value = (value as any).model ? (value as any).model : value; + // Support for rendering the component within a LiveView or a LiveComponent: + // if onEditEvent is not null it means this react component is rendered within a LiveView or a live_component + // using the React.component wrapper + // If so, events need to be pushed back to the LiveView or the live_component (the optional onEditTarget is used to target the event to a live_component) + + if (onEditEvent && pushEvent && pushEventTo) { + onEdit = (values) => { + if (onEditTarget) { + pushEventTo(onEditTarget, onEditEvent, { values: values }); + } else { + pushEvent(onEditEvent, { values: values }); + } + }; + } + return (
diff --git a/assets/src/hooks/point_markers.ts b/assets/src/hooks/point_markers.ts index 62c315360f8..a36e1057488 100644 --- a/assets/src/hooks/point_markers.ts +++ b/assets/src/hooks/point_markers.ts @@ -5,9 +5,14 @@ export const PointMarkers = { mounted() { const el = this.el as HTMLElement; + const pageHeaderOffset = + document.getElementById('page_header')?.getBoundingClientRect().height || 0; + const UPDATE_DEBOUNCE_INTERVAL = 200; const updatePointMarkers = debounce(() => { - this.pushEvent('update_point_markers', { ['point_markers']: queryPointMarkers(el) }); + this.pushEvent('update_point_markers', { + ['point_markers']: queryPointMarkers(el, pageHeaderOffset), + }); }, UPDATE_DEBOUNCE_INTERVAL); // update the marker positions immediately when the page is mounted @@ -46,14 +51,12 @@ export const PointMarkers = { }, }; -function queryPointMarkers(el: HTMLElement) { +function queryPointMarkers(el: HTMLElement, pageHeaderOffset: number) { const markerElements = el.querySelectorAll('[data-point-marker]'); - const OFFSET_TOP = 110; - return Array.from(markerElements).map((markerEl) => ({ id: markerEl.getAttribute('data-point-marker'), - top: markerEl.getBoundingClientRect().top - el.getBoundingClientRect().top + OFFSET_TOP, + top: markerEl.getBoundingClientRect().top - el.getBoundingClientRect().top + pageHeaderOffset, })); } diff --git a/assets/yarn.lock b/assets/yarn.lock index d39fd051c19..96d1b4fb3e0 100644 --- a/assets/yarn.lock +++ b/assets/yarn.lock @@ -7054,13 +7054,13 @@ bn.js@^5.0.0, bn.js@^5.1.1: resolved "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz" integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== -body-parser@1.20.1: - version "1.20.1" - resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz" - integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== +body-parser@1.20.2: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== dependencies: bytes "3.1.2" - content-type "~1.0.4" + content-type "~1.0.5" debug "2.6.9" depd "2.0.0" destroy "1.2.0" @@ -7068,7 +7068,7 @@ body-parser@1.20.1: iconv-lite "0.4.24" on-finished "2.4.1" qs "6.11.0" - raw-body "2.5.1" + raw-body "2.5.2" type-is "~1.6.18" unpipe "1.0.0" @@ -7972,7 +7972,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4: +content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -7989,10 +7989,10 @@ cookie-signature@1.0.6: resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== +cookie@0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" + integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== copy-concurrently@^1.0.0: version "1.0.5" @@ -9945,16 +9945,16 @@ expect@^26.6.2: jest-regex-util "^26.0.0" express@^4.17.1: - version "4.18.2" - resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" - integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== + version "4.19.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" + integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "1.20.1" + body-parser "1.20.2" content-disposition "0.5.4" content-type "~1.0.4" - cookie "0.5.0" + cookie "0.6.0" cookie-signature "1.0.6" debug "2.6.9" depd "2.0.0" @@ -15630,10 +15630,10 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== dependencies: bytes "3.1.2" http-errors "2.0.0" diff --git a/lib/oli/activities/realizer/query/builder.ex b/lib/oli/activities/realizer/query/builder.ex index b4be1f9cecc..88082baa511 100644 --- a/lib/oli/activities/realizer/query/builder.ex +++ b/lib/oli/activities/realizer/query/builder.ex @@ -169,7 +169,7 @@ defmodule Oli.Activities.Realizer.Query.Builder do :does_not_equal -> [ - "(NOT ((objectives_count = #{length(value)} AND (#{build_objectives_conjunction(value)})))" + "NOT (objectives_count = #{length(value)} AND #{build_objectives_conjunction(value)})" ] end @@ -206,7 +206,7 @@ defmodule Oli.Activities.Realizer.Query.Builder do Enum.map(objective_ids, fn id -> "@ == #{id}" end) |> Enum.join(" || ") - "jsonb_path_match(objectives, 'exists($.** ? (#{id_filter}))')" + "jsonb_path_exists(objectives, '$.** ? (#{id_filter})')" end defp build_objectives_conjunction(objective_ids) do @@ -214,6 +214,6 @@ defmodule Oli.Activities.Realizer.Query.Builder do Enum.map(objective_ids, fn id -> build_objectives_disjunction([id]) end) |> Enum.join(" AND ") - "(#{clauses})" + "#{clauses}" end end diff --git a/lib/oli/delivery/settings.ex b/lib/oli/delivery/settings.ex index cea1c99ca8f..49adee66f30 100644 --- a/lib/oli/delivery/settings.ex +++ b/lib/oli/delivery/settings.ex @@ -46,6 +46,61 @@ defmodule Oli.Delivery.Settings do end) end + @doc """ + For a course section id and user id, return a map of resource_id to student exception settings. + The third argument allows to specific the field/s to be returned in the map. + If no fields are specified, all fields from the Oli.Delivery.Settings.Combined struct are returned. + + If the are no student exception for a specific resource id, that resource id won't be included in the map. + (so if there are no student exceptions for any resources, an empty map will be returned) + + Example: + + ``` + iex> Oli.Delivery.Settings.get_student_exception_setting_for_all_resources(1, 2) + %{ + 22433 => %{ + max_attempts: nil, + password: nil, + end_date: ~U[2024-05-25 13:41:00Z], + time_limit: 30, + collab_space_config: nil, + start_date: nil, + resource_id: 22433, + retake_mode: nil, + late_submit: nil, + late_start: nil, + grace_period: nil, + scoring_strategy_id: nil, + review_submission: nil, + feedback_mode: nil, + feedback_scheduled_date: nil, + explanation_strategy: nil + } + } + + iex> Oli.Delivery.Settings.get_student_exception_setting_for_all_resources(1, 2, [:end_date, :time_limit]) + %{22433 => %{end_date: ~U[2024-05-25 13:41:00Z], time_limit: 30}} + + iex> Oli.Delivery.Settings.get_student_exception_setting_for_all_resources(1, 5) + %{} + """ + + def get_student_exception_setting_for_all_resources(section_id, user_id, fields \\ nil) + + def get_student_exception_setting_for_all_resources(section_id, user_id, nil) do + fields = %Oli.Delivery.Settings.StudentException{} |> Map.from_struct() |> Map.keys() + + get_all_student_exceptions(section_id, user_id) + |> Enum.reduce(%{}, fn se, acc -> Map.put(acc, se.resource_id, Map.take(se, fields)) end) + end + + def get_student_exception_setting_for_all_resources(section_id, user_id, fields) + when is_list(fields) do + get_all_student_exceptions(section_id, user_id) + |> Enum.reduce(%{}, fn se, acc -> Map.put(acc, se.resource_id, Map.take(se, fields)) end) + end + defp get_page_resources_with_settings(section_slug) do page_id = Oli.Resources.ResourceType.id_for_page() diff --git a/lib/oli/resources/collaboration.ex b/lib/oli/resources/collaboration.ex index e00b02a3320..8613c2a80c1 100644 --- a/lib/oli/resources/collaboration.ex +++ b/lib/oli/resources/collaboration.ex @@ -7,7 +7,7 @@ defmodule Oli.Resources.Collaboration do alias Oli.Delivery.Sections.{Section, SectionResource, SectionsProjectsPublications} alias Oli.Resources alias Oli.Resources.{ResourceType, Revision} - alias Oli.Resources.Collaboration.{CollabSpaceConfig, Post, UserReadPost} + alias Oli.Resources.Collaboration.{CollabSpaceConfig, Post, UserReadPost, UserReactionPost} alias Oli.Repo alias Oli.Accounts.User @@ -491,6 +491,40 @@ defmodule Oli.Resources.Collaboration do ) end + # Define a subquery for root thread post replies count + defp replies_subquery() do + from(p in Post, + group_by: p.thread_root_id, + select: %{ + thread_root_id: p.thread_root_id, + count: count(p.id), + last_reply: max(p.updated_at) + } + ) + end + + # Define a subquery for root thread post read replies count + # (replies by the user are counted as read) + defp read_replies_subquery(user_id) do + from( + p in Post, + left_join: urp in UserReadPost, + on: urp.post_id == p.id, + where: + not is_nil(p.thread_root_id) and + (p.user_id == ^user_id or (urp.user_id == ^user_id and not is_nil(urp.post_id))), + group_by: p.thread_root_id, + select: %{ + thread_root_id: p.thread_root_id, + # Counting both user's posts and read posts + count: count(p.id) + } + ) + end + + @doc """ + Returns the list of root posts for a section. + """ def list_root_posts_for_section( user_id, section_id, @@ -500,35 +534,6 @@ defmodule Oli.Resources.Collaboration do sort_by, sort_order ) do - # Define a subquery for root thread post replies count - replies_subquery = - from(p in Post, - group_by: p.thread_root_id, - select: %{ - thread_root_id: p.thread_root_id, - count: count(p.id), - last_reply: max(p.updated_at) - } - ) - - # Define a subquery for root thread post read replies count - # (replies by the user are counted as read) - read_replies_subquery = - from( - p in Post, - left_join: urp in UserReadPost, - on: urp.post_id == p.id, - where: - not is_nil(p.thread_root_id) and - (p.user_id == ^user_id or (urp.user_id == ^user_id and not is_nil(urp.post_id))), - group_by: p.thread_root_id, - select: %{ - thread_root_id: p.thread_root_id, - # Counting both user's posts and read posts - count: count(p.id) - } - ) - order_clause = case {sort_by, sort_order} do {"popularity", :desc} -> @@ -566,9 +571,9 @@ defmodule Oli.Resources.Collaboration do on: rev.id == pr.revision_id, join: user in User, on: post.user_id == user.id, - left_join: replies in subquery(replies_subquery), + left_join: replies in subquery(replies_subquery()), on: replies.thread_root_id == post.id, - left_join: read_replies in subquery(read_replies_subquery), + left_join: read_replies in subquery(read_replies_subquery(user_id)), on: read_replies.thread_root_id == post.id, where: post.section_id == ^section_id and @@ -1099,10 +1104,10 @@ defmodule Oli.Resources.Collaboration do ## Examples - iex> list_posts_for_user_in_point_block(1, 1, 1, "1")) + iex> list_posts_for_user_in_point_block(1, 1, 1, :private, "1")) [%Post{status: :archived}, ...] - iex> list_posts_for_user_in_point_block(2, 2, 2, "2") + iex> list_posts_for_user_in_point_block(2, 2, 2, :private, "2") [] """ def list_posts_for_user_in_point_block( @@ -1122,17 +1127,53 @@ defmodule Oli.Resources.Collaboration do Repo.all( from( post in Post, + left_join: replies in subquery(replies_subquery()), + on: replies.thread_root_id == post.id, + left_join: read_replies in subquery(read_replies_subquery(user_id)), + on: read_replies.thread_root_id == post.id, + left_join: reactions in assoc(post, :reactions), + left_join: user in assoc(post, :user), where: post.section_id == ^section_id and post.resource_id == ^resource_id and + is_nil(post.parent_post_id) and is_nil(post.thread_root_id) and (post.status in [:approved, :archived] or (post.status == :submitted and post.user_id == ^user_id)), where: ^filter_by_point_block_id, where: post.visibility == ^visibility, - select: post, order_by: [desc: :inserted_at], - preload: [:user] + preload: [user: user, reactions: reactions], + select: %{ + post + | replies_count: coalesce(replies.count, 0), + read_replies_count: coalesce(read_replies.count, 0) + } ) ) + |> summarize_reactions(user_id) + end + + defp summarize_reactions(posts, current_user_id) do + Enum.map(posts, fn post -> + %{ + post + | reaction_summaries: + Enum.reduce(post.reactions, %{}, fn r, acc -> + reacted_by_current_user = r.user_id == current_user_id + + Map.update( + acc, + r.reaction, + %{count: 1, reacted: reacted_by_current_user}, + fn %{ + count: count, + reacted: reacted + } -> + %{count: count + 1, reacted: reacted_by_current_user || reacted} + end + ) + end) + } + end) end @doc """ @@ -1144,6 +1185,7 @@ defmodule Oli.Resources.Collaboration do post in Post, where: post.section_id == ^section_id and post.resource_id == ^resource_id and + is_nil(post.parent_post_id) and is_nil(post.thread_root_id) and post.visibility == ^visibility and (post.status in [:approved, :archived] or (post.status == :submitted and post.user_id == ^user_id)), @@ -1153,4 +1195,61 @@ defmodule Oli.Resources.Collaboration do |> Repo.all() |> Enum.into(%{}) end + + def list_replies_for_post_in_point_block(user_id, post_id) do + Repo.all( + from( + post in Post, + join: user in User, + on: post.user_id == user.id, + left_join: urp in UserReadPost, + on: urp.post_id == post.id and urp.user_id == ^user_id, + left_join: reactions in assoc(post, :reactions), + where: + post.parent_post_id == ^post_id and + (post.status in [:approved, :archived] or + (post.status == :submitted and post.user_id == ^user_id)), + order_by: [asc: :updated_at], + preload: [user: user, reactions: reactions], + select: post + ) + ) + |> build_metrics_for_reply_posts(user_id) + |> summarize_reactions(user_id) + end + + def toggle_reaction(post_id, user_id, reaction) do + case get_reaction(post_id, user_id, reaction) do + nil -> + case create_reaction(post_id, user_id, reaction) do + {:ok, _} -> + {:ok, 1} + + error -> + error + end + + reaction -> + case delete_reaction(reaction) do + {:ok, _} -> + {:ok, -1} + + error -> + error + end + end + end + + def get_reaction(post_id, user_id, reaction) do + Repo.get_by(UserReactionPost, post_id: post_id, user_id: user_id, reaction: reaction) + end + + def create_reaction(post_id, user_id, reaction) do + %UserReactionPost{post_id: post_id, user_id: user_id, reaction: reaction} + |> Repo.insert() + end + + def delete_reaction(%UserReactionPost{} = reaction) do + Repo.delete(reaction) + end end diff --git a/lib/oli/resources/collaboration/post.ex b/lib/oli/resources/collaboration/post.ex index 033f9f3a5cc..d187c099fa4 100644 --- a/lib/oli/resources/collaboration/post.ex +++ b/lib/oli/resources/collaboration/post.ex @@ -36,6 +36,10 @@ defmodule Oli.Resources.Collaboration.Post do field :anonymous, :boolean, default: false + has_many :reactions, Oli.Resources.Collaboration.UserReactionPost + + field :reaction_summaries, :map, virtual: true + timestamps(type: :utc_datetime) end diff --git a/lib/oli/resources/collaboration/user_reaction_post.ex b/lib/oli/resources/collaboration/user_reaction_post.ex new file mode 100644 index 00000000000..6c0239927d8 --- /dev/null +++ b/lib/oli/resources/collaboration/user_reaction_post.ex @@ -0,0 +1,28 @@ +defmodule Oli.Resources.Collaboration.UserReactionPost do + use Ecto.Schema + + import Ecto.Changeset + + schema "user_reaction_posts" do + field :reaction, Ecto.Enum, values: [:like], default: :like + + belongs_to :user, Oli.Accounts.User + belongs_to :post, Oli.Resources.Collaboration.Post + + timestamps(type: :utc_datetime) + end + + def changeset(post, attrs \\ %{}) do + post + |> cast(attrs, [ + :reaction, + :user_id, + :post_id + ]) + |> validate_required([ + :reaction, + :user_id, + :post_id + ]) + end +end diff --git a/lib/oli_web/common/react.ex b/lib/oli_web/common/react.ex index 04eeaa65d2f..0201c2f6df2 100644 --- a/lib/oli_web/common/react.ex +++ b/lib/oli_web/common/react.ex @@ -1,5 +1,15 @@ defmodule OliWeb.Common.React do - # use Phoenix.Component + @moduledoc """ + React component wrappers. It wraps the `ReactPhoenix.ClientSide.react_component` function and the + `PhoenixLiveReact.live_react_component` function to provide a single `component` function that can be used in non LiveView + and LiveViews respectively (that is why the OliWeb.Common.SessionContext (@ctx) is passed as first argument, to distinguish liveview from non-liveview) + + ## Usage in a template + + <%= React.component(@ctx, "Components.MyComponent", %{name: "Bob"}, id: "my-component-1") %> + + Remember to import and register the component in assets/src/apps/Components.tsx + """ import PhoenixLiveReact diff --git a/lib/oli_web/components/common.ex b/lib/oli_web/components/common.ex index 423db9a0e9d..949c4853981 100644 --- a/lib/oli_web/components/common.ex +++ b/lib/oli_web/components/common.ex @@ -857,6 +857,7 @@ defmodule OliWeb.Components.Common do def loading_spinner(assigns) do ~H""" id}) do + %{email: email} = _user = Repo.get(User, id) + params = %{"user" => %{"email" => email}} + + @basic_conn_for_pow + |> use_pow_config(:user) + |> put_reset_password_token_store_into_pow_config() + |> ResetPasswordController.process_create(params) + |> generate_password_reset_url() + end def send_user_password_reset_link(conn, %{"id" => id}) do user = Repo.get(User, id) @@ -66,6 +89,18 @@ defmodule OliWeb.PowController do |> redirect(to: Routes.live_path(conn, OliWeb.Users.AuthorsDetailView, author.id)) end + defp generate_password_reset_url({:ok, %{token: token, user: _user}, conn}) do + Controller.routes(conn, PowRoutes).url_for(conn, ResetPasswordController, :edit, [token]) + end + + defp put_reset_password_token_store_into_pow_config(conn) do + update_in( + conn, + [Access.key(:private), Access.key(:pow_config)], + &Keyword.put(&1, :reset_password_token_store, @cache_config) + ) + end + defp resend_user_confirmation_email(conn, user) do PowEmailConfirmation.Phoenix.ControllerCallbacks.send_confirmation_email(user, conn) conn diff --git a/lib/oli_web/live/curriculum/container/container_live.ex b/lib/oli_web/live/curriculum/container/container_live.ex index e32a3edc83c..96b9ac1fc6d 100644 --- a/lib/oli_web/live/curriculum/container/container_live.ex +++ b/lib/oli_web/live/curriculum/container/container_live.ex @@ -276,8 +276,21 @@ defmodule OliWeb.Curriculum.ContainerLive do %{"explanation_strategy" => %{"type" => "none"}} -> Map.put(revision_params, "explanation_strategy", nil) + %{"intro_content" => ""} -> + Map.put( + revision_params, + "intro_content", + %{} + ) + _ -> - revision_params + intro_content = Jason.decode!(revision_params["intro_content"]) + + Map.put( + revision_params, + "intro_content", + intro_content + ) end case ContainerEditor.edit_page(project, revision.slug, revision_params) do diff --git a/lib/oli_web/live/curriculum/container/container_live.html.heex b/lib/oli_web/live/curriculum/container/container_live.html.heex index 900469ea2cb..62f7e7a99bb 100644 --- a/lib/oli_web/live/curriculum/container/container_live.html.heex +++ b/lib/oli_web/live/curriculum/container/container_live.html.heex @@ -13,6 +13,7 @@ <%= if @options_modal_assigns do %> <.live_component module={OptionsModalContent} + ctx={@ctx} id="modal_content" redirect_url={@options_modal_assigns.redirect_url} revision={@options_modal_assigns.revision} diff --git a/lib/oli_web/live/curriculum/entries/options_modal.ex b/lib/oli_web/live/curriculum/entries/options_modal.ex index f13fff8ba42..16125efb0f4 100644 --- a/lib/oli_web/live/curriculum/entries/options_modal.ex +++ b/lib/oli_web/live/curriculum/entries/options_modal.ex @@ -8,6 +8,7 @@ defmodule OliWeb.Curriculum.OptionsModalContent do alias Oli.Resources.ScoringStrategy alias Oli.Utils.S3Storage alias OliWeb.Components.HierarchySelector + alias OliWeb.Common.React @attempt_options [ "1": 1, @@ -70,6 +71,59 @@ defmodule OliWeb.Curriculum.OptionsModalContent do attr(:attempt_options, :list, default: @attempt_options) attr(:selected_resources, :list, default: []) + def render(%{step: :intro_content} = assigns) do + ~H""" +
+
+ +
+
+ <%= React.component( + @ctx, + "Components.RichTextEditor", + %{ + projectSlug: @project.slug, + onEdit: "initial_function_that_will_be_overwritten", + onEditEvent: "intro_content_change", + onEditTarget: "#intro_content_step", + editMode: true, + value: + (fetch_field(@changeset, :intro_content) && + fetch_field(@changeset, :intro_content)["children"]) || [], + fixedToolbar: true, + allowBlockElements: false + }, + id: "rich_text_editor_react_component" + ) %> +
+ + +
+ """ + end + def render(%{step: step} = assigns) when step in [:poster_image, :intro_video] do ~H"""
@@ -498,17 +552,50 @@ defmodule OliWeb.Curriculum.OptionsModalContent do The title is used to identify this <%= resource_type_label(@revision) %>.
- <.poster_image_selection - target={@myself} - poster_image={fetch_field(@changeset, :poster_image) || @default_poster_image} - delete_button_enabled={ - fetch_field(@changeset, :poster_image) not in [nil, @default_poster_image] - } - /> - <.intro_video_selection - target={@myself} - intro_video={fetch_field(@changeset, :intro_video)} - /> +
+ + +
+
+ <%= Phoenix.HTML.raw( + Oli.Rendering.Content.render( + %Oli.Rendering.Context{}, + fetch_field(@changeset, :intro_content)[ + "children" + ], + Oli.Rendering.Content.Html + ) + ) %> +
+
+ + <.button + phx-click="change_step" + phx-target={@myself} + phx-value-target_step="intro_content" + type="button" + class="btn btn-primary mt-2" + > + Edit + +
+
+ <.poster_image_selection + target={@myself} + poster_image={fetch_field(@changeset, :poster_image) || @default_poster_image} + delete_button_enabled={ + fetch_field(@changeset, :poster_image) not in [nil, @default_poster_image] + } + /> + <.intro_video_selection + target={@myself} + intro_video={fetch_field(@changeset, :intro_video)} + /> +
<% end %>