diff --git a/app/controllers/discussion_topics_controller.rb b/app/controllers/discussion_topics_controller.rb
index 93cb26ab31e..4db3ef15bee 100644
--- a/app/controllers/discussion_topics_controller.rb
+++ b/app/controllers/discussion_topics_controller.rb
@@ -793,7 +793,8 @@ class DiscussionTopicsController < ApplicationController
# GRADED_RUBRICS_URL must be within DISCUSSION to avoid page error
DISCUSSION: {
GRADED_RUBRICS_URL: (@topic.assignment ? context_url(@topic.assignment.context, :context_assignment_rubric_url, @topic.assignment.id) : nil),
- CONTEXT_RUBRICS_URL: can_do(@topic.assignment, @current_user, :update) ? context_url(@topic.assignment.context, :context_rubrics_url) : ""
+ CONTEXT_RUBRICS_URL: can_do(@topic.assignment, @current_user, :update) ? context_url(@topic.assignment.context, :context_rubrics_url) : "",
+ ATTACHMENTS_FOLDER_ID: (@topic.for_assignment? && !@current_user.nil?) ? @current_user.submissions_folder(@context).id : Folder.unfiled_folder(@context).id
},
apollo_caching: @current_user &&
Account.site_admin.feature_enabled?(:apollo_caching),
diff --git a/spec/controllers/discussion_topics_controller_spec.rb b/spec/controllers/discussion_topics_controller_spec.rb
index e84f80c981e..5ff2d78f909 100644
--- a/spec/controllers/discussion_topics_controller_spec.rb
+++ b/spec/controllers/discussion_topics_controller_spec.rb
@@ -551,6 +551,46 @@ describe DiscussionTopicsController do
user_session(user)
end
+ it "sets ATTACHMENTS_FOLDER_ID" do
+ subject
+
+ expect(discussion).not_to be_for_assignment
+ expect(assigns[:js_env][:DISCUSSION][:ATTACHMENTS_FOLDER_ID]).to eq Folder.unfiled_folder(discussion.course).id.to_s
+ end
+
+ context "no current user" do
+ it "public course sets ATTACHMENTS_FOLDER_ID" do
+ Account.default.enable_feature! :react_discussions_post
+ # in the controller 'can_read_and_visible' must be true, which is a complex flow to simulate
+ allow_any_instance_of(DiscussionTopic).to receive(:grants_right?).and_return(true)
+ allow_any_instance_of(DiscussionTopic).to receive(:visible_for?).and_return(true)
+
+ course.update(is_public: true)
+ discussion.assignment = course.assignments.build(submission_types: "discussion_topic", title: discussion.title)
+ discussion.assignment.infer_times
+ discussion.assignment.saved_by = :discussion_topic
+ discussion.save
+ remove_user_session
+
+ subject
+ expect(discussion).to be_for_assignment
+ expect(assigns[:js_env][:DISCUSSION][:ATTACHMENTS_FOLDER_ID]).to eq Folder.unfiled_folder(discussion.course).id.to_s
+ end
+ end
+
+ context "for_assignment" do
+ it "sets ATTACHMENTS_FOLDER_ID" do
+ discussion.assignment = course.assignments.build(submission_types: "discussion_topic", title: discussion.title)
+ discussion.assignment.infer_times
+ discussion.assignment.saved_by = :discussion_topic
+ discussion.save
+
+ subject
+ expect(discussion).to be_for_assignment
+ expect(assigns[:js_env][:DISCUSSION][:ATTACHMENTS_FOLDER_ID]).to eq user.submissions_folder(discussion.course).id.to_s
+ end
+ end
+
it "sets @page_title to Topic: @topic.title" do
subject
expect(assigns(:page_title)).to eq "Topic: #{discussion.title}"
diff --git a/ui/features/discussion_topics_post/graphql/Mocks.js b/ui/features/discussion_topics_post/graphql/Mocks.js
index eed3a1b0685..6bc0feae563 100644
--- a/ui/features/discussion_topics_post/graphql/Mocks.js
+++ b/ui/features/discussion_topics_post/graphql/Mocks.js
@@ -353,7 +353,8 @@ export const updateDiscussionEntryParticipantMock = ({
export const updateDiscussionEntryMock = ({
discussionEntryId = 'DiscussionEntry-default-mock',
message = '
This is the parent reply
',
- removeAttachment = !'7',
+ fileId = '7',
+ removeAttachment = !fileId,
} = {}) => [
{
request: {
@@ -361,6 +362,7 @@ export const updateDiscussionEntryMock = ({
variables: {
discussionEntryId,
message,
+ ...(fileId !== null && {fileId}),
removeAttachment,
},
},
@@ -413,6 +415,7 @@ export const createDiscussionEntryMock = ({
discussionTopicId = 'Discussion-default-mock',
message = '',
replyFromEntryId = null,
+ fileId = null,
includeReplyPreview = null,
isAnonymousAuthor = false,
courseID = '1',
@@ -425,6 +428,7 @@ export const createDiscussionEntryMock = ({
message,
isAnonymousAuthor,
...(replyFromEntryId !== null && {replyFromEntryId}),
+ ...(fileId !== null && {fileId}),
...(includeReplyPreview !== null && {includeReplyPreview}),
...(courseID !== null && {courseID}),
},
diff --git a/ui/features/discussion_topics_post/graphql/Mutations.js b/ui/features/discussion_topics_post/graphql/Mutations.js
index 7856b982d5f..848347f2ee2 100644
--- a/ui/features/discussion_topics_post/graphql/Mutations.js
+++ b/ui/features/discussion_topics_post/graphql/Mutations.js
@@ -132,6 +132,7 @@ export const CREATE_DISCUSSION_ENTRY = gql`
$discussionTopicId: ID!
$message: String!
$replyFromEntryId: ID
+ $fileId: ID
$includeReplyPreview: Boolean
$isAnonymousAuthor: Boolean
$courseID: String
@@ -141,6 +142,7 @@ export const CREATE_DISCUSSION_ENTRY = gql`
discussionTopicId: $discussionTopicId
message: $message
parentEntryId: $replyFromEntryId
+ fileId: $fileId
includeReplyPreview: $includeReplyPreview
isAnonymousAuthor: $isAnonymousAuthor
}
@@ -172,12 +174,14 @@ export const UPDATE_DISCUSSION_ENTRY = gql`
mutation UpdateDiscussionEntry(
$discussionEntryId: ID!
$message: String
+ $fileId: ID
$removeAttachment: Boolean
) {
updateDiscussionEntry(
input: {
discussionEntryId: $discussionEntryId
message: $message
+ fileId: $fileId
removeAttachment: $removeAttachment
}
) {
diff --git a/ui/features/discussion_topics_post/react/DiscussionTopicManager.js b/ui/features/discussion_topics_post/react/DiscussionTopicManager.js
index 0ac5fbe2392..74e37e20e69 100644
--- a/ui/features/discussion_topics_post/react/DiscussionTopicManager.js
+++ b/ui/features/discussion_topics_post/react/DiscussionTopicManager.js
@@ -306,11 +306,12 @@ const DiscussionTopicManager = props => {
{
+ createDiscussionEntry={(message, fileId, isAnonymousAuthor) => {
createDiscussionEntry({
variables: {
discussionTopicId: ENV.discussion_topic_id,
message,
+ fileId,
courseID: ENV.course_id,
isAnonymousAuthor,
},
diff --git a/ui/features/discussion_topics_post/react/__tests__/DiscussionsAttachment.test.js b/ui/features/discussion_topics_post/react/__tests__/DiscussionsAttachment.test.js
index d64b051181f..b04d2b60899 100644
--- a/ui/features/discussion_topics_post/react/__tests__/DiscussionsAttachment.test.js
+++ b/ui/features/discussion_topics_post/react/__tests__/DiscussionsAttachment.test.js
@@ -33,7 +33,7 @@ jest.mock('../utils', () => ({
responsiveQuerySizes: jest.fn(),
}))
-describe('DiscussionThreadAttachment', () => {
+describe('DiscussionsAttachment', () => {
const onFailureStub = jest.fn()
const onSuccessStub = jest.fn()
const openMock = jest.fn()
@@ -90,6 +90,7 @@ describe('DiscussionThreadAttachment', () => {
updateDiscussionEntryMock({
discussionEntryId: 'DiscussionEntry-default-mock',
message: 'This is the parent reply
',
+ fileId: null,
removeAttachment: true,
})
)
diff --git a/ui/features/discussion_topics_post/react/components/AttachmentDisplay/AttachmentDisplay.js b/ui/features/discussion_topics_post/react/components/AttachmentDisplay/AttachmentDisplay.js
index 338dbfa3fb6..c9dba2edcb6 100644
--- a/ui/features/discussion_topics_post/react/components/AttachmentDisplay/AttachmentDisplay.js
+++ b/ui/features/discussion_topics_post/react/components/AttachmentDisplay/AttachmentDisplay.js
@@ -17,17 +17,55 @@
*/
import PropTypes from 'prop-types'
-import React from 'react'
+import React, {useContext} from 'react'
import {AttachmentButton} from './AttachmentButton'
-
+import {AlertManagerContext} from '@canvas/alerts/react/AlertManager'
import {Responsive} from '@instructure/ui-responsive'
import {responsiveQuerySizes} from '../../utils'
+import {UploadButton} from './UploadButton'
+import {uploadFiles} from '@canvas/upload-file'
+import {useScope as useI18nScope} from '@canvas/i18n'
+const I18n = useI18nScope('discussion_topics_post')
export function AttachmentDisplay(props) {
+ const {setOnFailure, setOnSuccess} = useContext(AlertManagerContext)
+
const removeAttachment = () => {
props.setAttachment(null)
}
+ const fileUploadUrl = attachmentFolderId => {
+ return `/api/v1/folders/${attachmentFolderId}/files`
+ }
+
+ const addAttachment = async e => {
+ const files = Array.from(e.currentTarget?.files)
+ if (files.length !== 1) {
+ setOnFailure(I18n.t('Error adding file to discussion message'))
+ }
+
+ props.setAttachmentToUpload(true)
+
+ setOnSuccess(I18n.t('Uploading file'))
+
+ try {
+ const newFiles = await uploadFiles(
+ files,
+ fileUploadUrl(ENV.DISCUSSION?.ATTACHMENTS_FOLDER_ID)
+ )
+ const newFile = {
+ _id: newFiles[0].id,
+ url: newFiles[0].url,
+ displayName: newFiles[0].display_name,
+ }
+ props.setAttachment(newFile)
+ } catch (err) {
+ setOnFailure(I18n.t('Error uploading file'))
+ } finally {
+ props.setAttachmentToUpload(false)
+ }
+ }
+
return (
- props.attachment?._id && (
+ props.attachment?._id ? (
+ ) : (
+
)
}
/>
@@ -61,6 +104,14 @@ AttachmentDisplay.propTypes = {
* Used to set the attachments prop, if no attachment is set
*/
setAttachment: PropTypes.func.isRequired,
+ /**
+ * Used to set the setAttachmentsToUpload prop, allows for returning loading state
+ */
+ setAttachmentToUpload: PropTypes.func.isRequired,
+ /**
+ * toggles loading state
+ */
+ attachmentToUpload: PropTypes.bool,
}
export default AttachmentDisplay
diff --git a/ui/features/discussion_topics_post/react/components/AttachmentDisplay/UploadButton.js b/ui/features/discussion_topics_post/react/components/AttachmentDisplay/UploadButton.js
new file mode 100644
index 00000000000..29ab98fceca
--- /dev/null
+++ b/ui/features/discussion_topics_post/react/components/AttachmentDisplay/UploadButton.js
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2022 - present Instructure, Inc.
+ *
+ * This file is part of Canvas.
+ *
+ * Canvas is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU Affero General Public License as published by the Free
+ * Software Foundation, version 3 of the License.
+ *
+ * Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ * details.
+ *
+ * You should have received a copy of the GNU Affero General Public License along
+ * with this program. If not, see .
+ */
+
+import PropTypes from 'prop-types'
+import React from 'react'
+
+import {CondensedButton} from '@instructure/ui-buttons'
+import {IconPaperclipLine} from '@instructure/ui-icons'
+import {Spinner} from '@instructure/ui-spinner'
+import {Text} from '@instructure/ui-text'
+import {useScope as useI18nScope} from '@canvas/i18n'
+
+const I18n = useI18nScope('discussion_topics_post')
+
+export const UploadButton = ({...props}) => {
+ let attachmentInput = null
+ const handleAttachmentClick = () => attachmentInput?.click()
+ return props.attachmentToUpload ? (
+ <>
+
+ >
+ ) : (
+ <>
+ }
+ onClick={handleAttachmentClick}
+ data-testid="attach-btn"
+ >
+ {I18n.t('Attach')}
+
+ (attachmentInput = input)}
+ type="file"
+ style={{display: 'none'}}
+ aria-hidden={true}
+ onChange={props.onAttachmentUpload}
+ />
+ >
+ )
+}
+
+UploadButton.propTypes = {
+ /**
+ * function that performs on the file after button click, then upload file, upload
+ */
+ onAttachmentUpload: PropTypes.func.isRequired,
+ /**
+ * toggles loading state
+ */
+ attachmentToUpload: PropTypes.bool,
+}
diff --git a/ui/features/discussion_topics_post/react/components/AttachmentDisplay/__tests__/AttachmentDisplay.test.js b/ui/features/discussion_topics_post/react/components/AttachmentDisplay/__tests__/AttachmentDisplay.test.js
index f496ed0a9c0..a0157041e73 100644
--- a/ui/features/discussion_topics_post/react/components/AttachmentDisplay/__tests__/AttachmentDisplay.test.js
+++ b/ui/features/discussion_topics_post/react/components/AttachmentDisplay/__tests__/AttachmentDisplay.test.js
@@ -21,10 +21,23 @@ import {render} from '@testing-library/react'
import {AttachmentDisplay} from '../AttachmentDisplay'
const setup = props => {
- return render( {}} {...props} />)
+ return render(
+ {}} setAttachmentToUpload={() => {}} {...props} />
+ )
}
describe('AttachmentDisplay', () => {
+ it('displays AttachButton when there is no attachment', () => {
+ const {queryByText} = setup()
+ expect(queryByText('Attach')).toBeTruthy()
+ })
+
+ it('only allows one attachment at a time', () => {
+ const {queryByTestId} = setup()
+ expect(queryByTestId('attachment-input')).toHaveAttribute('type', 'file')
+ expect(queryByTestId('attachment-input')).not.toHaveAttribute('multiple')
+ })
+
it('displays AttachmentButton when there is an attachment', () => {
const {queryByText} = setup({
attachment: {
@@ -34,6 +47,7 @@ describe('AttachmentDisplay', () => {
},
})
+ expect(queryByText('Attach')).toBeFalsy()
expect(queryByText('file_name.file')).toBeTruthy()
})
@@ -46,6 +60,7 @@ describe('AttachmentDisplay', () => {
},
})
+ expect(queryByText('Attach')).toBeFalsy()
expect(queryByText('Fundamentals of Differential E...')).toBeTruthy()
})
})
diff --git a/ui/features/discussion_topics_post/react/components/DiscussionEdit/DiscussionEdit.js b/ui/features/discussion_topics_post/react/components/DiscussionEdit/DiscussionEdit.js
index b177b3f7f15..313c8130166 100644
--- a/ui/features/discussion_topics_post/react/components/DiscussionEdit/DiscussionEdit.js
+++ b/ui/features/discussion_topics_post/react/components/DiscussionEdit/DiscussionEdit.js
@@ -52,6 +52,7 @@ export const DiscussionEdit = props => {
)
const [attachment, setAttachment] = useState(null)
+ const [attachmentToUpload, setAttachmentToUpload] = useState(false)
const rceMentionsIsEnabled = () => {
return !!ENV.rce_mentions_in_discussions
@@ -226,6 +227,7 @@ export const DiscussionEdit = props => {
color="primary"
data-testid="DiscussionEdit-submit"
key="rce-reply-button"
+ interaction={attachmentToUpload ? 'disabled' : 'enabled'}
>
{props.isEdit ? I18n.t('Save') : I18n.t('Reply')}
@@ -235,7 +237,12 @@ export const DiscussionEdit = props => {
return matches.includes('mobile') ? (
-
+
{rceButtons.reverse()}
@@ -243,7 +250,12 @@ export const DiscussionEdit = props => {
-
+
{ENV.draft_discussions && (
diff --git a/ui/features/discussion_topics_post/react/containers/DiscussionThreadContainer/DiscussionThreadContainer.js b/ui/features/discussion_topics_post/react/containers/DiscussionThreadContainer/DiscussionThreadContainer.js
index 006d03de4a3..ae3a854f2c1 100644
--- a/ui/features/discussion_topics_post/react/containers/DiscussionThreadContainer/DiscussionThreadContainer.js
+++ b/ui/features/discussion_topics_post/react/containers/DiscussionThreadContainer/DiscussionThreadContainer.js
@@ -288,6 +288,7 @@ export const DiscussionThreadContainer = props => {
variables: {
discussionEntryId: props.discussionEntry._id,
message,
+ fileId,
removeAttachment: !fileId,
},
})
@@ -325,7 +326,7 @@ export const DiscussionThreadContainer = props => {
}
}, [threadRefCurrent, props.discussionEntry.entryParticipant.read, props])
- const onReplySubmit = (message, isAnonymousAuthor, includeReplyPreview) => {
+ const onReplySubmit = (message, fileId, isAnonymousAuthor, includeReplyPreview) => {
createDiscussionEntry({
variables: {
discussionTopicId: ENV.discussion_topic_id,
@@ -334,6 +335,7 @@ export const DiscussionThreadContainer = props => {
props.discussionEntry.rootEntryId !== props.discussionEntry.parentId
? props.discussionEntry.parentId
: props.discussionEntry._id,
+ fileId,
isAnonymousAuthor,
includeReplyPreview,
message,
@@ -490,8 +492,8 @@ export const DiscussionThreadContainer = props => {
{
- onReplySubmit(message, anonymousAuthorState, includeReplyPreview)
+ onSubmit={(message, includeReplyPreview, fileId, anonymousAuthorState) => {
+ onReplySubmit(message, fileId, anonymousAuthorState, includeReplyPreview)
}}
onCancel={() => setEditorExpanded(false)}
quotedEntry={buildQuotedReply([props.discussionEntry], replyFromId)}
diff --git a/ui/features/discussion_topics_post/react/containers/DiscussionTopicContainer/DiscussionTopicContainer.js b/ui/features/discussion_topics_post/react/containers/DiscussionTopicContainer/DiscussionTopicContainer.js
index 2118be67ca0..a5c3d1cab40 100644
--- a/ui/features/discussion_topics_post/react/containers/DiscussionTopicContainer/DiscussionTopicContainer.js
+++ b/ui/features/discussion_topics_post/react/containers/DiscussionTopicContainer/DiscussionTopicContainer.js
@@ -496,11 +496,11 @@ export const DiscussionTopicContainer = ({createDiscussionEntry, ...props}) => {
onSubmit={(
message,
_includeReplyPreview,
- _fileId,
+ fileId,
anonymousAuthorState
) => {
if (createDiscussionEntry) {
- createDiscussionEntry(message, anonymousAuthorState)
+ createDiscussionEntry(message, fileId, anonymousAuthorState)
setExpandedReply(false)
props.onDiscussionReplyPost()
}
diff --git a/ui/features/discussion_topics_post/react/containers/IsolatedThreadsContainer/IsolatedThreadsContainer.js b/ui/features/discussion_topics_post/react/containers/IsolatedThreadsContainer/IsolatedThreadsContainer.js
index e9a61171ccb..f0b427a09f4 100644
--- a/ui/features/discussion_topics_post/react/containers/IsolatedThreadsContainer/IsolatedThreadsContainer.js
+++ b/ui/features/discussion_topics_post/react/containers/IsolatedThreadsContainer/IsolatedThreadsContainer.js
@@ -263,6 +263,7 @@ const IsolatedThreadContainer = props => {
variables: {
discussionEntryId: props.discussionEntry._id,
message,
+ fileId,
removeAttachment: !fileId,
},
})
diff --git a/ui/features/discussion_topics_post/react/containers/IsolatedViewContainer/IsolatedViewContainer.js b/ui/features/discussion_topics_post/react/containers/IsolatedViewContainer/IsolatedViewContainer.js
index 71837f54793..b2afe00c590 100644
--- a/ui/features/discussion_topics_post/react/containers/IsolatedViewContainer/IsolatedViewContainer.js
+++ b/ui/features/discussion_topics_post/react/containers/IsolatedViewContainer/IsolatedViewContainer.js
@@ -197,13 +197,14 @@ export const IsolatedViewContainer = props => {
window.open(getSpeedGraderUrl(discussionEntry.author._id), '_blank')
}
- const onReplySubmit = (message, includeReplyPreview, replyId, isAnonymousAuthor) => {
+ const onReplySubmit = (message, fileId, includeReplyPreview, replyId, isAnonymousAuthor) => {
createDiscussionEntry({
variables: {
discussionTopicId: props.discussionTopic._id,
replyFromEntryId: replyId,
isAnonymousAuthor,
message,
+ fileId,
includeReplyPreview,
courseID: ENV.course_id,
},
@@ -420,9 +421,10 @@ export const IsolatedViewContainer = props => {
{
+ onSubmit={(message, includeReplyPreview, fileId, anonymousAuthorState) => {
onReplySubmit(
message,
+ fileId,
includeReplyPreview,
props.replyFromId,
anonymousAuthorState
diff --git a/ui/features/discussion_topics_post/react/containers/SplitScreenThreadsContainer/SplitScreenThreadsContainer.js b/ui/features/discussion_topics_post/react/containers/SplitScreenThreadsContainer/SplitScreenThreadsContainer.js
index 278a647899e..344ea0e6a42 100644
--- a/ui/features/discussion_topics_post/react/containers/SplitScreenThreadsContainer/SplitScreenThreadsContainer.js
+++ b/ui/features/discussion_topics_post/react/containers/SplitScreenThreadsContainer/SplitScreenThreadsContainer.js
@@ -263,6 +263,7 @@ const SplitScreenThreadContainer = props => {
variables: {
discussionEntryId: props.discussionEntry._id,
message,
+ fileId,
removeAttachment: !fileId,
},
})
diff --git a/ui/features/discussion_topics_post/react/containers/SplitScreenViewContainer/SplitScreenViewContainer.js b/ui/features/discussion_topics_post/react/containers/SplitScreenViewContainer/SplitScreenViewContainer.js
index b59f4112cd8..65286c72ea6 100644
--- a/ui/features/discussion_topics_post/react/containers/SplitScreenViewContainer/SplitScreenViewContainer.js
+++ b/ui/features/discussion_topics_post/react/containers/SplitScreenViewContainer/SplitScreenViewContainer.js
@@ -198,13 +198,14 @@ export const SplitScreenViewContainer = props => {
window.open(getSpeedGraderUrl(discussionEntry.author._id), '_blank')
}
- const onReplySubmit = (message, includeReplyPreview, replyId, isAnonymousAuthor) => {
+ const onReplySubmit = (message, fileId, includeReplyPreview, replyId, isAnonymousAuthor) => {
createDiscussionEntry({
variables: {
discussionTopicId: props.discussionTopic._id,
replyFromEntryId: replyId,
isAnonymousAuthor,
message,
+ fileId,
includeReplyPreview,
courseID: ENV.course_id,
},
@@ -421,9 +422,10 @@ export const SplitScreenViewContainer = props => {
{
+ onSubmit={(message, includeReplyPreview, fileId, anonymousAuthorState) => {
onReplySubmit(
message,
+ fileId,
includeReplyPreview,
props.replyFromId,
anonymousAuthorState