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