Skip to content

Commit

Permalink
Allow locking topics in a community (pupilfirst#547)
Browse files Browse the repository at this point in the history
Co-authored-by: Hari Gopal <[email protected]>
  • Loading branch information
Mahesh Krishna Kumar and harigopal authored Dec 2, 2020
1 parent 88d4bb5 commit 5b8c649
Show file tree
Hide file tree
Showing 17 changed files with 451 additions and 30 deletions.
22 changes: 22 additions & 0 deletions app/graphql/mutations/lock_topic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Mutations
class LockTopic < GraphQL::Schema::Mutation
argument :id, ID, required: true

description "Lock a topic in community."

field :success, Boolean, null: false

def resolve(params)
mutator = LockTopicMutator.new(context, params)

if mutator.valid?
mutator.lock_topic
mutator.notify(:success, I18n.t('shared.done_exclamation'), I18n.t('mutations.lock_topic.success_notification'))
{ success: true }
else
mutator.notify_errors
{ success: false }
end
end
end
end
22 changes: 22 additions & 0 deletions app/graphql/mutations/unlock_topic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Mutations
class UnlockTopic < GraphQL::Schema::Mutation
argument :id, ID, required: true

description "Unlock a topic in community."

field :success, Boolean, null: false

def resolve(params)
mutator = UnlockTopicMutator.new(context, params)

if mutator.valid?
mutator.unlock_topic
mutator.notify(:success, I18n.t('shared.done_exclamation'), I18n.t('mutations.unlock_topic.success_notification'))
{ success: true }
else
mutator.notify_errors
{ success: false }
end
end
end
end
2 changes: 2 additions & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,7 @@ class MutationType < Types::BaseObject
field :update_topic_category, mutation: Mutations::UpdateTopicCategory, null: false
field :issue_certificate, mutation: Mutations::IssueCertificate, null: false
field :revoke_issued_certificate, mutation: Mutations::RevokeIssuedCertificate, null: false
field :lock_topic, mutation: Mutations::LockTopic, null: false
field :unlock_topic, mutation: Mutations::UnlockTopic, null: false
end
end
151 changes: 125 additions & 26 deletions app/javascript/topics/TopicsShow__Root.res
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
%bs.raw(`require("./TopicsShow__Root.css")`)

let t = I18n.t(~scope="components.TopicsShow__Root")

open TopicsShow__Types

let str = React.string
Expand All @@ -11,6 +13,7 @@ type state = {
replyToPostId: option<string>,
topicTitle: string,
savingTopic: bool,
changingLockedStatus: bool,
showTopicEditor: bool,
topicCategory: option<TopicCategory.t>,
}
Expand All @@ -31,6 +34,9 @@ type action =
| ShowTopicEditor(bool)
| UpdateSavingTopic(bool)
| MarkReplyAsSolution(string)
| StartChangingLockStatus
| FinishLockingTopic(string)
| FinishUnlockingTopic
| UpdateTopicCategory(option<TopicCategory.t>)

let reducer = (state, action) =>
Expand Down Expand Up @@ -99,6 +105,17 @@ let reducer = (state, action) =>
replies: state.replies |> Post.markAsSolution(postId),
}
| UpdateTopicCategory(topicCategory) => {...state, topicCategory: topicCategory}
| StartChangingLockStatus => {...state, changingLockedStatus: true}
| FinishLockingTopic(currentUserId) => {
...state,
changingLockedStatus: false,
topic: Topic.lock(currentUserId, state.topic),
}
| FinishUnlockingTopic => {
...state,
changingLockedStatus: false,
topic: Topic.unlock(state.topic),
}
}

let addNewReply = (send, replyToPostId, ()) => send(AddNewReply(replyToPostId))
Expand Down Expand Up @@ -149,6 +166,47 @@ let updateTopic = (state, send, event) => {
|> ignore
}

module LockTopicQuery = %graphql(
`
mutation LockTopicMutation($id: ID!) {
lockTopic(id: $id) {
success
}
}
`
)

module UnlockTopicQuery = %graphql(
`
mutation UnlockTopicMutation($id: ID!) {
unlockTopic(id: $id) {
success
}
}
`
)

let lockTopic = (topicId, currentUserId, send) =>
WindowUtils.confirm("Are you sure you want to lock this topic?", () => {
send(StartChangingLockStatus)
LockTopicQuery.make(~id=topicId, ()) |> GraphqlQuery.sendQuery |> Js.Promise.then_(response => {
response["lockTopic"]["success"] ? send(FinishLockingTopic(currentUserId)) : ()
Js.Promise.resolve()
}) |> ignore
})

let unlockTopic = (topicId, send) =>
WindowUtils.confirm("Are you sure you want to unlock this topic?", () => {
send(StartChangingLockStatus)
UnlockTopicQuery.make(~id=topicId, ())
|> GraphqlQuery.sendQuery
|> Js.Promise.then_(response => {
response["unlockTopic"]["success"] ? send(FinishUnlockingTopic) : ()
Js.Promise.resolve()
})
|> ignore
})

let communityLink = community =>
<a href={Community.path(community)} className="btn btn-subtle">
<i className="fas fa-users" />
Expand Down Expand Up @@ -250,6 +308,7 @@ let make = (
topicTitle: topic |> Topic.title,
savingTopic: false,
showTopicEditor: false,
changingLockedStatus: false,
topicCategory: topicCategory(topicCategories, Topic.topicCategoryId(topic)),
})

Expand All @@ -264,12 +323,16 @@ let make = (
<div
className="flex py-4 px-4 md:px-5 mx-3 lg:mx-0 bg-white border border-primary-500 shadow-md rounded-lg justify-between items-center">
<p className="w-3/5 md:w-4/5 text-sm">
<span className="font-semibold block text-xs"> {"Linked Target: " |> str} </span>
<span className="font-semibold block text-xs">
{t("linked_target_label") |> str}
</span>
<span> {target |> LinkedTarget.title |> str} </span>
</p>
{switch target |> LinkedTarget.id {
| Some(id) =>
<a href={"/targets/" ++ id} className="btn btn-default"> {"View Target" |> str} </a>
<a href={"/targets/" ++ id} className="btn btn-default">
{t("view_target_button") |> str}
</a>
| None => React.null
}}
</div>
Expand All @@ -293,7 +356,7 @@ let make = (
<div className="flex flex-col md:flex-row md:justify-between md:items-end">
<div className="flex flex-col items-left flex-shrink-0">
<span className="inline-block text-gray-700 text-tiny font-semibold mr-2">
{"Topic Category: " |> str}
{t("topic_category_label") |> str}
</span>
<Dropdown
selected={categoryDropdownSelected(state.topicCategory)}
Expand All @@ -304,13 +367,13 @@ let make = (
<div className="flex justify-end pt-4 md:pt-0">
<button
onClick={_ => send(ShowTopicEditor(false))} className="btn btn-subtle mr-3">
{"Cancel" |> str}
{t("topic_editor_cancel_button") |> str}
</button>
<button
onClick={updateTopic(state, send)}
disabled={state.topicTitle |> Js.String.trim == ""}
className="btn btn-primary">
{"Update Topic" |> str}
{t("update_topic_button") |> str}
</button>
</div>
</div>
Expand All @@ -321,17 +384,41 @@ let make = (
className="topics-show__title-container flex items-center md:items-start justify-between mb-2">
<h3
ariaLabel="Topic Title"
className="leading-snug lg:pl-14 text-base lg:text-2xl w-5/6">
className="leading-snug lg:pl-14 text-base lg:text-2xl w-9/12">
{state.topic |> Topic.title |> str}
</h3>
{moderator || isTopicCreator(firstPost, currentUserId)
? <button
onClick={_ => send(ShowTopicEditor(true))}
className="topics-show__title-edit-button inline-flex items-center font-semibold p-2 md:py-1 bg-gray-100 hover:bg-gray-300 border rounded text-xs flex-shrink-0 mt-2 ml-3">
<i className="far fa-edit" />
<span className="hidden md:inline-block ml-1"> {"Edit Topic" |> str} </span>
</button>
: React.null}
<span className="flex">
{moderator || isTopicCreator(firstPost, currentUserId)
? <button
onClick={_ => send(ShowTopicEditor(true))}
className="topics-show__title-edit-button inline-flex items-center font-semibold p-2 md:py-1 bg-gray-100 hover:bg-gray-300 border rounded text-xs flex-shrink-0 mt-2 ml-3">
<i className="far fa-edit" />
<span className="hidden md:inline-block ml-1">
{t("edit_topic_button") |> str}
</span>
</button>
: React.null}
{
let isLocked = Topic.lockedAt(state.topic)->Belt.Option.isSome
let topicId = state.topic->Topic.id
moderator
? <button
disabled=state.changingLockedStatus
onClick={_ =>
isLocked
? unlockTopic(topicId, send)
: lockTopic(topicId, currentUserId, send)}
className="topics-show__title-edit-button inline-flex items-center font-semibold p-2 md:py-1 bg-gray-100 hover:bg-gray-300 border rounded text-xs flex-shrink-0 mt-2 ml-2">
<PfIcon className={"fa fa-" ++ (isLocked ? "unlock" : "lock")} />
<span className="hidden md:inline-block ml-1">
{(
isLocked ? t("unlock_topic_button") : t("lock_topic_button")
) |> str}
</span>
</button>
: React.null
}
</span>
</div>
{switch state.topicCategory {
| Some(topicCategory) =>
Expand All @@ -347,7 +434,7 @@ let make = (
<TopicsShow__PostShow
key={Post.id(state.firstPost)}
post=state.firstPost
topic
topic=state.topic
users
posts=state.replies
currentUserId
Expand Down Expand Up @@ -375,7 +462,7 @@ let make = (
<div key={Post.id(reply)} className="topics-show__replies-wrapper">
<TopicsShow__PostShow
post=reply
topic
topic=state.topic
users
posts=state.replies
currentUserId
Expand All @@ -393,16 +480,28 @@ let make = (
|> React.array}
</div>
<div className="mt-4 px-4">
<TopicsShow__PostEditor
id="add-reply-to-topic"
topic
currentUserId
handlePostCB={saveReply(send, state.replyToPostId)}
replyToPostId=?state.replyToPostId
replies=state.replies
users
removeReplyToPostCB={() => send(RemoveReplyToPost)}
/>
{switch Topic.lockedAt(state.topic) {
| Some(_lockedAt) =>
<div
className="flex p-4 bg-yellow-100 text-yellow-900 border border-yellow-500 border-l-4 rounded-r-md mt-2 mx-auto w-full max-w-4xl mb-4 text-sm justify-center items-center">
<div className="w-6 h-6 text-yellow-500 flex-shrink-0">
<i className="fa fa-lock" />
</div>
<span className="ml-2"> {t("locked_topic_notice")->React.string} </span>
</div>

| None =>
<TopicsShow__PostEditor
id="add-reply-to-topic"
topic
currentUserId
handlePostCB={saveReply(send, state.replyToPostId)}
replyToPostId=?state.replyToPostId
replies=state.replies
users
removeReplyToPostCB={() => send(RemoveReplyToPost)}
/>
}}
</div>
</div>
</div>
Expand Down
20 changes: 20 additions & 0 deletions app/javascript/topics/types/TopicsShow__Topic.res
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ type rec t = {
id: id,
title: string,
topicCategoryId: option<string>,
lockedAt: option<Js.Date.t>,
lockedById: option<string>,
}
and id = string

let title = t => t.title

let lockedAt = t => t.lockedAt

let lockedById = t => t.lockedById

let id = t => t.id

let topicCategoryId = t => t.topicCategoryId
Expand All @@ -16,11 +22,25 @@ let updateTitle = (title, t) => {
title: title,
}

let lock = (lockedById, t) => {
...t,
lockedAt: Some(Js.Date.make()),
lockedById: Some(lockedById),
}

let unlock = t => {
...t,
lockedAt: None,
lockedById: None,
}

let decode = json => {
open Json.Decode
{
id: json |> field("id", string),
title: json |> field("title", string),
topicCategoryId: json |> optional(field("topicCategoryId", string)),
lockedAt: json |> optional(field("lockedAt", DateFns.decodeISO)),
lockedById: json |> optional(field("lockedById", string)),
}
}
1 change: 1 addition & 0 deletions app/models/topic.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Topic < ApplicationRecord
belongs_to :community
belongs_to :target, optional: true
belongs_to :topic_category, optional: true
belongs_to :locked_by, class_name: 'User', optional: true

has_many :posts, dependent: :restrict_with_error
has_one :first_post, -> { where post_number: 1 }, class_name: 'Post', inverse_of: :topic
Expand Down
1 change: 1 addition & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class User < ApplicationRecord
has_one :school_admin, dependent: :restrict_with_error
has_many :markdown_attachments, dependent: :nullify
has_many :issued_certificates, dependent: :nullify
has_many :locked_topics, class_name: 'Topic', foreign_key: 'locked_by_id', inverse_of: :locked_by, dependent: :nullify
has_many :post_likes, dependent: :nullify
has_many :text_versions, dependent: :nullify
has_many :course_exports, dependent: :nullify
Expand Down
3 changes: 2 additions & 1 deletion app/presenters/topics/show_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def page_title
private

def topic_details
@topic.attributes.slice('id', 'title', 'topic_category_id')
@topic.attributes.slice('id', 'title', 'topic_category_id', 'locked_at', 'locked_by_id')
end

def topic_categories
Expand Down Expand Up @@ -76,6 +76,7 @@ def users
user_ids = [
first_post.creator_id,
first_post.editor_id,
@topic.locked_by_id,
replies.pluck(:creator_id),
replies.pluck(:editor_id),
current_user.id
Expand Down
4 changes: 4 additions & 0 deletions app/queries/concerns/authorize_community_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ def authorized_archive?
end
end

def authorized_moderate?
moderator? && community&.school == current_school
end

def current_coach
current_user.faculty
end
Expand Down
Loading

0 comments on commit 5b8c649

Please sign in to comment.