diff --git a/.eslintrc.js b/.eslintrc.js index 11558200..eec4ee7f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,16 +4,7 @@ const { createConfig } = require('@edx/frontend-build'); const config = createConfig('eslint', { rules: { 'import/no-unresolved': 'off', - 'import/no-named-as-default': 'off', }, - overrides: [ - { - files: ['*{h,H}ooks.js'], - rules: { - 'react-hooks/rules-of-hooks': 'off', - }, - }, - ], }); config.rules['react/function-component-definition'][1].unnamedComponents = 'arrow-function'; diff --git a/jest.config.js b/jest.config.js index 7b415fde..d85cac22 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,10 +13,7 @@ const config = createConfig('jest', { }); config.moduleDirectories = ['node_modules', 'src']; -<<<<<<< HEAD -======= ->>>>>>> 8f4b2e6 (chore: package updates) // add axios to the list of modules to not transform config.transformIgnorePatterns = ['/node_modules/(?!@edx|axios)']; diff --git a/package.json b/package.json index 8067315f..3ee3201b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dependencies": { "@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/frontend-component-footer": "12.2.1", - "@edx/frontend-component-header": "4.6.1", + "@edx/frontend-component-header": "4.6.0", "@edx/frontend-platform": "5.4.0", "@edx/paragon": "^20.20.0", "@edx/react-unit-test-utils": "1.7.0", @@ -51,7 +51,7 @@ "@tinymce/tinymce-react": "3.14.0", "axios": "^1.5.1", "classnames": "^2.3.2", - "core-js": "3.33.0", + "core-js": "3.32.2", "filesize": "^8.0.6", "jest-when": "^3.6.0", "pdfjs-dist": "^3.11.174", diff --git a/src/App.jsx b/src/App.jsx index b76dde99..81b65e69 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,27 +12,41 @@ import SelfAssessmentView from 'views/SelfAssessmentView'; import StudentTrainingView from 'views/StudentTrainingView'; import SubmissionView from 'views/SubmissionView'; import XBlockView from 'views/XBlockView'; -import FilePreviewView from 'views/FilePreviewView'; +import PageDataProvider from 'components/PageDataProvider'; + import messages from './messages'; import routes from './routes'; const RouterRoot = () => { const { formatMessage } = useIntl(); const appRoute = (route, Component) => ( - } /> + + + + )} + /> ); const modalRoute = (route, Component, title) => ( - } /> + + + + )} + /> ); const embeddedRoutes = [ - } />, - modalRoute(routes.embedded.peerAssessment, PeerAssessmentView, 'ORA Peer Assessment'), - modalRoute(routes.embedded.selfAssessment, SelfAssessmentView, 'ORA Self Assessment'), - modalRoute(routes.embedded.studentTraining, StudentTrainingView, 'ORA Student Training'), - modalRoute(routes.embedded.submission, SubmissionView, 'ORA Submission'), - modalRoute(routes.preview, FilePreviewView, 'File Preview'), - } />, + } />, + modalRoute(routes.peerAssessmentEmbed, PeerAssessmentView, 'ORA Peer Assessment'), + modalRoute(routes.selfAssessmentEmbed, SelfAssessmentView, 'ORA Self Assessment'), + modalRoute(routes.studentTrainingEmbed, StudentTrainingView, 'ORA Student Training'), + modalRoute(routes.submissionEmbed, SubmissionView, 'ORA Submission'), + } />, ]; const baseRoutes = [ appRoute(routes.xblock, PeerAssessmentView), @@ -40,14 +54,12 @@ const RouterRoot = () => { appRoute(routes.selfAssessment, SelfAssessmentView), appRoute(routes.studentTraining, StudentTrainingView), appRoute(routes.submission, SubmissionView), - appRoute(routes.preview, FilePreviewView), } />, ]; const isConfigLoaded = useIsORAConfigLoaded(); - const isPageLoaded = useIsPageDataLoaded(); - if (!isConfigLoaded || !isPageLoaded) { + if (!isConfigLoaded) { return (
render default 1`] = ` "accessor": "fileSize", }, Object { - "Cell": "ActionCell", + "Cell": [Function], "Header": "Actions", "accessor": "actions", }, @@ -50,177 +50,42 @@ exports[` render default 1`] = ` itemCount={2} /> - - -
-`; - -exports[` render no uploaded files 1`] = ` -
-

- File Upload -

- - -
-`; - -exports[` render read only 1`] = ` -
-

- File Upload -

- - Uploaded Files - - + -
`; -exports[` renders default 1`] = ` +exports[` render no uploaded files 1`] = `

File Upload

- - Uploaded Files - - + - -
`; -exports[` renders no uploaded files 1`] = ` -
-

- File Upload -

- - -
-`; - -exports[` renders read only 1`] = ` +exports[` render read only 1`] = `

File Upload @@ -270,10 +135,5 @@ exports[` renders read only 1`] = ` itemCount={2} /> -

`; diff --git a/src/components/FileUpload/index.jsx b/src/components/FileUpload/index.jsx index 251ca570..a4365c2b 100644 --- a/src/components/FileUpload/index.jsx +++ b/src/components/FileUpload/index.jsx @@ -1,29 +1,37 @@ import React from 'react'; import PropTypes from 'prop-types'; +import filesize from 'filesize'; import { DataTable, Dropzone } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import filesize from 'filesize'; +import { nullMethod } from 'hooks'; import UploadConfirmModal from './UploadConfirmModal'; import ActionCell from './ActionCell'; - import { useFileUploadHooks } from './hooks'; import messages from './messages'; import './styles.scss'; +export const createFileActionCell = ({ onDeletedFile, isReadOnly }) => (props) => ( + +); + const FileUpload = ({ - isReadOnly, uploadedFiles, onFileUploaded, onDeletedFile, + isReadOnly, + uploadedFiles, + onFileUploaded, + onDeletedFile, }) => { const { formatMessage } = useIntl(); const { - uploadState, confirmUpload, closeUploadModal, + isModalOpen, onProcessUpload, + uploadArgs, } = useFileUploadHooks({ onFileUploaded, }); @@ -57,26 +65,27 @@ const FileUpload = ({ { Header: formatMessage(messages.fileActionsTitle), accessor: 'actions', - // eslint-disable-next-line react/no-unstable-nested-components - Cell: (props) => , + Cell: createFileActionCell({ onDeletedFile, isReadOnly }), }, ]} /> )} {!isReadOnly && ( - + <> + + + )} - ); }; @@ -84,8 +93,8 @@ const FileUpload = ({ FileUpload.defaultProps = { isReadOnly: false, uploadedFiles: [], - onFileUploaded: () => { }, - onDeletedFile: () => { }, + onFileUploaded: nullMethod, + onDeletedFile: nullMethod, }; FileUpload.propTypes = { isReadOnly: PropTypes.bool, diff --git a/src/components/FileUpload/index.test.jsx b/src/components/FileUpload/index.test.jsx index 7a973495..81076b3a 100644 --- a/src/components/FileUpload/index.test.jsx +++ b/src/components/FileUpload/index.test.jsx @@ -25,28 +25,33 @@ describe('', () => { fileSize: 200, }, ], - onFileUploaded: jest.fn(), - onDeletedFile: jest.fn().mockName('onDeletedFile'), + onFileUploaded: jest.fn().mockName('props.onFileUploaded'), }; const mockHooks = (overrides) => { useFileUploadHooks.mockReturnValueOnce({ - uploadState: { - onProcessUploadArgs: {}, - openModal: false, - }, + isModalOpen: false, + uploadArgs: {}, confirmUpload: jest.fn().mockName('confirmUpload'), closeUploadModal: jest.fn().mockName('closeUploadModal'), onProcessUpload: jest.fn().mockName('onProcessUpload'), ...overrides, }); }; - describe('renders', () => { + describe('behavior', () => { + it('initializes data from hook', () => { + mockHooks(); + shallow(); + expect(useFileUploadHooks).toHaveBeenCalledWith({ onFileUploaded: props.onFileUploaded }); + }); + }); + describe('render', () => { + // TODO: examine files in the table + // TODO: examine dropzone args test('default', () => { mockHooks(); const wrapper = shallow(); expect(wrapper.snapshot).toMatchSnapshot(); - expect(wrapper.instance.findByType('Dropzone')).toHaveLength(1); expect(wrapper.instance.findByType('DataTable')).toHaveLength(1); }); diff --git a/src/components/ProgressBar/index.jsx b/src/components/ProgressBar/index.jsx index ce38a9a7..835c9f23 100644 --- a/src/components/ProgressBar/index.jsx +++ b/src/components/ProgressBar/index.jsx @@ -119,13 +119,13 @@ export const ProgressBar = () => { {stepConfig.order.map(step => { if (step === 'peer') { - return ; + return ; } if (step === 'training') { - return ; + return ; } if (step === 'self') { - return ; + return ; } return null; })} diff --git a/src/components/TextResponse/index.jsx b/src/components/TextResponse/index.jsx index 58149ffe..ecee0ed6 100644 --- a/src/components/TextResponse/index.jsx +++ b/src/components/TextResponse/index.jsx @@ -1,48 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import TextEditor from 'components/TextResponse/TextEditor'; -import RichTextEditor from 'components/TextResponse/RichTextEditor'; - import './index.scss'; -const TextResponse = ({ - submissionConfig, value, onChange, isReadOnly, -}) => { - const { textResponseConfig } = submissionConfig; - const { optional, enabled } = textResponseConfig; - const props = { - optional, - disabled: !enabled || isReadOnly, - value, - onChange, - }; - - return ( -
- { - textResponseConfig?.editorType === 'text' ? : - } -
- ); -}; - -TextResponse.defaultProps = { - onChange: () => {}, - isReadOnly: false, -}; +const TextResponse = ({ response }) => ( +
+
+
+); TextResponse.propTypes = { - submissionConfig: PropTypes.shape({ - textResponseConfig: PropTypes.shape({ - optional: PropTypes.bool, - enabled: PropTypes.bool, - editorType: PropTypes.string, - }), - }).isRequired, - value: PropTypes.string.isRequired, - onChange: PropTypes.func, - isReadOnly: PropTypes.bool, + response: PropTypes.string.isRequired, }; export default TextResponse; diff --git a/src/components/TextResponse/index.scss b/src/components/TextResponse/index.scss index 3a0f44e5..b4477cfd 100644 --- a/src/components/TextResponse/index.scss +++ b/src/components/TextResponse/index.scss @@ -1,13 +1,5 @@ -@import "@edx/paragon/scss/core/core.scss"; - .textarea-response { min-height: 200px; max-height: 300px; overflow-y: scroll; -} - -.tox-tinymce--disabled { - background-color: $input-disabled-bg; - // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526. - opacity: 1; } \ No newline at end of file diff --git a/src/components/TextResponseEditor/index.test.jsx b/src/components/TextResponseEditor/index.test.jsx index d80f3eff..8f278295 100644 --- a/src/components/TextResponseEditor/index.test.jsx +++ b/src/components/TextResponseEditor/index.test.jsx @@ -15,7 +15,6 @@ describe('', () => { }, value: 'value', onChange: jest.fn().mockName('onChange'), - isReadOnly: false, }; it('render Text Editor ', () => { diff --git a/src/data/services/lms/hooks/actions.ts b/src/data/services/lms/hooks/actions.ts index daa3d3ba..404ac249 100644 --- a/src/data/services/lms/hooks/actions.ts +++ b/src/data/services/lms/hooks/actions.ts @@ -1,19 +1,18 @@ import { useQueryClient, useMutation } from '@tanstack/react-query'; import { queryKeys } from '../constants'; -import { ActionMutationFunction, RubricData } from '../types'; +import { ActionMutationFunction, AssessmentData } from '../types'; import fakeData from '../fakeData'; -export const createMutationAction = (mutationFn: ActionMutationFunction) => { +export const useCreateMutationAction = (mutationFn: ActionMutationFunction) => { const queryClient = useQueryClient(); - return useMutation({ mutationFn: (...args) => mutationFn(...args, queryClient), }); }; -export const submitRubric = () => - createMutationAction(async (data: RubricData, queryClient) => { +export const useSubmitRubric = () => useCreateMutationAction( + async (data: AssessmentData, queryClient) => { // TODO: submit rubric await new Promise((resolve) => setTimeout(() => { fakeData.pageData.shapes.peerAssessment.rubric = { @@ -24,13 +23,13 @@ export const submitRubric = () => resolve(null); }, 1000)); - queryClient.invalidateQueries([queryKeys.pageData, true]) - + queryClient.invalidateQueries([queryKeys.pageData, true]); return Promise.resolve(data); - }); + }, +); -export const submitResponse = () => - createMutationAction(async (data: any, queryClient) => { +export const useSubmitResponse = () => useCreateMutationAction( + async (data: any, queryClient) => { // TODO: submit response await new Promise((resolve) => setTimeout(() => { fakeData.pageData.shapes.emptySubmission.submission.response = { @@ -44,13 +43,13 @@ export const submitResponse = () => resolve(null); }, 1000)); - queryClient.invalidateQueries([queryKeys.pageData, false]) - + queryClient.invalidateQueries([queryKeys.pageData, false]); return Promise.resolve(data); - }); + }, +); -export const saveResponse = () => - createMutationAction(async (data: any, queryClient) => { +export const useSaveResponse = () => useCreateMutationAction( + async (data: any, queryClient) => { // TODO: save response for later await new Promise((resolve) => setTimeout(() => { fakeData.pageData.shapes.emptySubmission.submission.response = { @@ -64,41 +63,36 @@ export const saveResponse = () => resolve(null); }, 1000)); - queryClient.invalidateQueries([queryKeys.pageData, false]) - + queryClient.invalidateQueries([queryKeys.pageData, false]); return Promise.resolve(data); - }); + }, +); + +export const fakeProgress = async (requestConfig) => { + for (let i = 0; i <= 50; i++) { + // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + requestConfig.onUploadProgress({ loaded: i, total: 50 }); + } +}; -export const uploadFiles = () => - createMutationAction(async (data: any, queryClient) => { +export const useUploadFiles = () => useCreateMutationAction( + async (data: any) => { const { fileData, requestConfig } = data; - // TODO: upload files const files = fileData.getAll('file'); - for (let i = 0; i <= 50; i++) { - // eslint-disable-next-line no-await-in-loop, no-promise-executor-return - await new Promise((resolve) => setTimeout(resolve, 100)); - requestConfig.onUploadProgress({ loaded: i, total: 50 }); - } - - fakeData.pageData.shapes.emptySubmission.submission.response = { - ...fakeData.pageData.shapes.emptySubmission.submission.response, - uploaded_files: [ - ...fakeData.pageData.shapes.emptySubmission.submission.response.uploaded_files, - ...files.map((file: any) => ({ - fileDescription: file.description, - fileName: file.name, - fileSize: file.size, - })), - ], - } as any; - - queryClient.invalidateQueries([queryKeys.pageData, false]) - - return Promise.resolve(files); - }); - -export const deleteFile = () => - createMutationAction(async (fileIndex, queryClient) => { + // TODO: upload files + /* + * const addFileResponse = await post(`{xblock_id}/handler/file/add`, file); + * const uploadResponse = await(post(response.fileUrl, file)); + * post(`${xblock_id}/handler/download_url', (response)); + */ + await fakeProgress(requestConfig); + return Promise.resolve(); + }, +); + +export const useDeleteFile = () => useCreateMutationAction( + async (fileIndex, queryClient) => { await new Promise((resolve) => setTimeout(() => { fakeData.pageData.shapes.emptySubmission.submission.response = { ...fakeData.pageData.shapes.emptySubmission.submission.response, @@ -110,6 +104,8 @@ export const deleteFile = () => }, 1000)); queryClient.invalidateQueries([queryKeys.pageData, false]); - - return Promise.resolve(fakeData.pageData.shapes.emptySubmission.submission.response.uploaded_files); - }); + return Promise.resolve( + fakeData.pageData.shapes.emptySubmission.submission.response.uploaded_files, + ); + }, +); diff --git a/src/data/services/lms/hooks/data.ts b/src/data/services/lms/hooks/data.ts index 8b4e361a..110164d1 100644 --- a/src/data/services/lms/hooks/data.ts +++ b/src/data/services/lms/hooks/data.ts @@ -1,6 +1,6 @@ import { useQuery, useMutation } from '@tanstack/react-query'; -import { useMatch } from 'react-router-dom'; +import { useSearchParams, useParams, matchRoutes, useLocation, useMatch } from 'react-router-dom'; import { camelCaseObject } from '@edx/frontend-platform'; import routes from 'routes'; @@ -8,50 +8,24 @@ import * as types from '../types'; import { queryKeys } from '../constants'; import fakeData from '../fakeData'; -export const useORAConfig = (): types.QueryData => { - const { data, ...status } = useQuery({ - queryKey: [queryKeys.oraConfig], - // queryFn: () => getAuthenticatedClient().get(...), - queryFn: () => { - const result = window.location.pathname.endsWith('text') - ? fakeData.oraConfig.assessmentText - : fakeData.oraConfig.assessmentTinyMCE; - return Promise.resolve(result); - }, - }); - return { - ...status, - data: data ? camelCaseObject(data) : {}, - }; -}; - -export const usePageData = (): types.QueryData => { - const route = useMatch(routes.peerAssessment); - const isAssessment = !!route && [routes.peerAssessment, routes.selfAssessment].includes(route.pattern.path) +import { loadState } from '../fakeData/dataStates'; - const { data, ...status } = useQuery({ - queryKey: [queryKeys.pageData, isAssessment], - // queryFn: () => getAuthenticatedClient().get(...), - queryFn: () => { - const assessmentData = isAssessment - ? fakeData.pageData.shapes.peerAssessment - : fakeData.pageData.shapes.emptySubmission; +export const useORAConfig = (): types.QueryData => useQuery({ + queryKey: [queryKeys.oraConfig], + queryFn: () => Promise.resolve(camelCaseObject(fakeData.oraConfig.assessmentTinyMCE)), +}); - const returnData = assessmentData ? { - ...camelCaseObject(assessmentData), - rubric: { - optionsSelected: { ...assessmentData.rubric.options_selected }, - criterionFeedback: { ...assessmentData.rubric.criterion_feedback }, - overallFeedback: assessmentData.rubric.overall_feedback, - }, - } : {}; - return Promise.resolve(returnData as any); - }, +export const usePageData = (): types.QueryData => { + const location = useLocation(); + const view = location.pathname.split('/')[1]; + const { xblockId, progressKey } = useParams(); + return useQuery({ + queryKey: [queryKeys.pageData], + queryFn: () => Promise.resolve(camelCaseObject(loadState({ + view, + progressKey, + }))), }); - return { - ...status, - data, - }; }; export const useSubmitResponse = () => useMutation({ diff --git a/src/index.scss b/src/index.scss index 1bf84429..a8d423a5 100644 --- a/src/index.scss +++ b/src/index.scss @@ -43,4 +43,4 @@ .gap-4 { gap: map-get($spacers, 4); -} +} diff --git a/src/routes.ts b/src/routes.ts index 57be9efb..e4cbd00d 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -1,17 +1,14 @@ export default { - embedded: { - xblock: '/embedded/xblock/:id', - peerAssessment: '/embedded/peer_assessment/:id', - selfAssessment: '/embedded/self_assessment/:id', - studentTraining: '/embedded/student_training/:id', - submission: '/embedded/submission/:id', - root: '/embedded/*', - }, - xblock: '/xblock/:id', - peerAssessment: '/peer_assessment/:id', - selfAssessment: '/self_assessment/:id', - studentTraining: '/student_training/:id', - submission: '/submission/:id', - preview: '/preview/:id', + xblockEmbed: 'embedded/xblock/:xblockId/:progressKey', + peerAssessmentEmbed: 'embedded/peer_assessment/:xblockId/:progressKey', + selfAssessmentEmbed: 'embedded/self_assessment/:xblockId/:progressKey', + studentTrainingEmbed: 'embedded/student_training/:xblockId/:progressKey', + submissionEmbed: 'embedded/submission/:xblockId/:progressKey', + rootEmbed: 'embedded/*', + xblock: 'xblock/:xblockId/:progressKey', + peerAssessment: 'peer_assessment/:xblockId/:progressKey', + selfAssessment: 'self_assessment/:xblockId/:progressKey', + studentTraining: 'student_training/:xblockId/:progressKey', + submission: 'submission/:xblockId/:progressKey?', root: '/*', }; diff --git a/src/views/SelfAssessmentView/Content.jsx b/src/views/SelfAssessmentView/Content.jsx index 9a841c55..ea33088f 100644 --- a/src/views/SelfAssessmentView/Content.jsx +++ b/src/views/SelfAssessmentView/Content.jsx @@ -1,20 +1,24 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { + usePrompts, + useSubmissionConfig, + useSubmissionResponse, +} from 'data/services/lms/hooks/selectors'; + import Prompt from 'components/Prompt'; import TextResponse from 'components/TextResponse'; import FileUpload from 'components/FileUpload'; import messages from './messages'; -const AssessmentContent = ({ - submission, - oraConfigData, -}) => { +const AssessmentContent = () => { + const prompts = usePrompts(); + const response = useSubmissionResponse(); + const submissionConfig = useSubmissionConfig(); const { formatMessage } = useIntl(); - return (
@@ -24,43 +28,20 @@ const AssessmentContent = ({ {formatMessage(messages.instructions)}: {formatMessage(messages.instructionsText)}

- {oraConfigData.prompts.map((prompt, index) => ( + {prompts.map((prompt, index) => ( // eslint-disable-next-line react/no-array-index-key
))} - +
); }; -AssessmentContent.propTypes = { - submission: PropTypes.shape({ - response: PropTypes.shape({ - textResponses: PropTypes.arrayOf(PropTypes.string), - uploadedFiles: PropTypes.arrayOf( - PropTypes.shape({ - fileDescription: PropTypes.string, - fileName: PropTypes.string, - fileSize: PropTypes.number, - }), - ), - }), - }).isRequired, - oraConfigData: PropTypes.shape({ - prompts: PropTypes.arrayOf(PropTypes.string), - // eslint-disable-next-line react/forbid-prop-types - submissionConfig: PropTypes.any, - }).isRequired, -}; - export default AssessmentContent; diff --git a/src/views/SelfAssessmentView/index.jsx b/src/views/SelfAssessmentView/index.jsx index 04d5f23d..5b9b14f7 100644 --- a/src/views/SelfAssessmentView/index.jsx +++ b/src/views/SelfAssessmentView/index.jsx @@ -1,21 +1,20 @@ import React from 'react'; -import ProgressBar from 'components/ProgressBar'; +import { Button } from '@edx/paragon'; +import { useIsORAConfigLoaded } from 'data/services/lms/hooks/selectors'; +import BaseAssessmentView from 'components/BaseAssessmentView'; +import AssessmentContent from './Content'; -import AssessmentContentLayout from './AssessmentContentLayout'; -import useAssessmentViewHooks from './hooks'; +export const SelfAssessmentView = () => useIsORAConfigLoaded() && ( + Cancel, + , + ]} + submitAssessment={() => {}} + > + + +); -export const AssessmentView = () => { - const { submission, oraConfigData } = useAssessmentViewHooks(); - return ( - <> - - - - ); -}; - -export default AssessmentView; +export default SelfAssessmentView; diff --git a/src/views/SubmissionView/SubmissionContent.jsx b/src/views/SubmissionView/SubmissionContent.jsx index 035c8f06..27badadf 100644 --- a/src/views/SubmissionView/SubmissionContent.jsx +++ b/src/views/SubmissionView/SubmissionContent.jsx @@ -5,6 +5,8 @@ import { Icon } from '@edx/paragon'; import { CheckCircle } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { usePrompts, useSubmissionConfig } from 'data/services/lms/hooks/selectors'; + import Prompt from 'components/Prompt'; import TextResponseEditor from 'components/TextResponseEditor'; import FileUpload from 'components/FileUpload'; @@ -12,20 +14,18 @@ import FileUpload from 'components/FileUpload'; import messages from './messages'; const SubmissionContent = ({ - submission, - oraConfigData, - onTextResponseChange, - onFileUploaded, - onDeletedFile, - draftSaved, + textResponses, + uploadedFiles, }) => { + const submissionConfig = useSubmissionConfig(); + const prompts = usePrompts(); const { formatMessage } = useIntl(); return (

{formatMessage(messages.yourResponse)}

- {draftSaved && ( + {textResponses.draftSaved && (

{formatMessage(messages.draftSaved)} @@ -36,48 +36,41 @@ const SubmissionContent = ({ {formatMessage(messages.instructions)}: {formatMessage(messages.instructionsText)}

- {oraConfigData.prompts.map((prompt, index) => ( + {prompts.map((prompt, index) => ( // eslint-disable-next-line react/no-array-index-key
))}
); }; SubmissionContent.propTypes = { - submission: PropTypes.shape({ - response: PropTypes.shape({ - textResponses: PropTypes.arrayOf(PropTypes.string), - uploadedFiles: PropTypes.arrayOf( - PropTypes.shape({ - fileDescription: PropTypes.string, - fileName: PropTypes.string, - fileSize: PropTypes.number, - }), - ), - }), + textResponses: PropTypes.shape({ + value: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, + draftSaved: PropTypes.bool.isRequired, }).isRequired, - oraConfigData: PropTypes.shape({ - prompts: PropTypes.arrayOf(PropTypes.string), - // eslint-disable-next-line react/forbid-prop-types - submissionConfig: PropTypes.any, + uploadedFiles: PropTypes.shape({ + value: PropTypes.shape({ + fileDescription: PropTypes.string, + fileName: PropTypes.string, + fileSize: PropTypes.number, + }), + onDeletedFile: PropTypes.func.isRequired, + onFileUploaded: PropTypes.func.isRequired, }).isRequired, - onTextResponseChange: PropTypes.func.isRequired, - onFileUploaded: PropTypes.func.isRequired, - onDeletedFile: PropTypes.func.isRequired, - draftSaved: PropTypes.bool.isRequired, }; export default SubmissionContent; diff --git a/src/views/SubmissionView/SubmissionContent.test.jsx b/src/views/SubmissionView/SubmissionContent.test.jsx index b30f9c76..62c6e153 100644 --- a/src/views/SubmissionView/SubmissionContent.test.jsx +++ b/src/views/SubmissionView/SubmissionContent.test.jsx @@ -25,9 +25,8 @@ describe.skip('', () => { maxFileSize: 100, }, }, - onTextResponseChange: () => jest.fn().mockName('onTextResponseChange'), + onTextResponseChange: jest.fn().mockName('onTextResponseChange'), onFileUploaded: jest.fn().mockName('onFileUploaded'), - onDeletedFile: jest.fn().mockName('onDeletedFile'), draftSaved: true, }; diff --git a/src/views/SubmissionView/__snapshots__/SubmissionContent.test.jsx.snap b/src/views/SubmissionView/__snapshots__/SubmissionContent.test.jsx.snap index 642b7975..7b07e027 100644 --- a/src/views/SubmissionView/__snapshots__/SubmissionContent.test.jsx.snap +++ b/src/views/SubmissionView/__snapshots__/SubmissionContent.test.jsx.snap @@ -36,7 +36,7 @@ exports[` render default 1`] = ` prompt="

test

" /> render default 1`] = ` />
render no prompts 1`] = ` it.

renders 1`] = ` - - +
+
+ + + + + +
+
+
`; diff --git a/src/views/SubmissionView/hooks.js b/src/views/SubmissionView/hooks.js index acd4d812..69568e69 100644 --- a/src/views/SubmissionView/hooks.js +++ b/src/views/SubmissionView/hooks.js @@ -1,73 +1,113 @@ -import { useEffect, useReducer } from 'react'; +import { useCallback, useEffect } from 'react'; -import { useORAConfigData, usePageData } from 'data/services/lms/hooks/selectors'; +import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils'; +import { + usePageData, + usePrompts, + useRubricConfig, +} from 'data/services/lms/hooks/selectors'; import { - submitResponse, saveResponse, uploadFiles, deleteFile, + useSubmitResponse, useSaveResponse, useUploadFiles, useDeleteFile, } from 'data/services/lms/hooks/actions'; import { MutationStatus } from 'data/services/lms/constants'; -const useSubmissionViewHooks = () => { - const submitResponseMutation = submitResponse(); - const saveResponseMutation = saveResponse(); - const uploadFilesMutation = uploadFiles(); - const deleteFileMutation = deleteFile(); - const pageData = usePageData(); - const oraConfigData = useORAConfigData(); - - const [submission, dispatchSubmission] = useReducer( - (state, payload) => ({ ...state, isDirty: true, ...payload }), - { ...pageData?.submission, isDirty: false }, +export const stateKeys = StrictDict({ + textResponses: 'textResponses', + uploadedFiles: 'uploadedFiles', + isDirty: 'isDirty', +}); + +export const useTextResponses = () => { + const { response } = usePageData().submission; + const prompts = usePrompts(); + + const [isDirty, setIsDirty] = useKeyedState(stateKeys.isDirty, false); + const [value, setValue] = useKeyedState( + stateKeys.textResponses, + response ? response.textResponses : prompts.map(() => ''), ); - useEffect(() => { - // a workaround to update the submission state when the pageData changes - if (pageData?.submission) { - dispatchSubmission({ ...pageData.submission, isDirty: false }); - } - }, [pageData?.submission]); - - const onTextResponseChange = (index) => (textResponse) => { - dispatchSubmission({ - response: { - ...submission.response, - textResponses: [ - ...submission.response.textResponses.slice(0, index), - textResponse, - ...submission.response.textResponses.slice(index + 1), - ], - }, + const saveResponseMutation = useSaveResponse(); + + const saveResponseHandler = useCallback(() => { + setIsDirty(false); + saveResponseMutation.mutate({ textResponses: value }); + }, [setIsDirty, saveResponseMutation, value]); + + const onChange = useCallback((index) => (textResponse) => { + setValue(oldResponses => { + const out = [...oldResponses]; + out[index] = textResponse; + return out; }); + setIsDirty(true); + }, [setValue, setIsDirty]); + + return { + formProps: { + value, + onChange, + draftSaved: saveResponseMutation.status === MutationStatus.success && !isDirty, + }, + saveResponse: { + handler: saveResponseHandler, + status: saveResponseMutation.status, + }, }; +}; - const onFileUploaded = uploadFilesMutation.mutate; +export const useUploadedFiles = () => { + const deleteFileMutation = useDeleteFile(); + const uploadFilesMutation = useUploadFiles(); - const onDeletedFile = deleteFileMutation.mutate; + const { response } = usePageData().submission; - const submitResponseHandler = () => { - dispatchSubmission({ isDirty: false }); - submitResponseMutation.mutate(submission); - }; + const [value, setValue] = useKeyedState( + stateKeys.uploadedFiles, + response ? response.uploadedFiles : [], + ); - const saveResponseHandler = () => { - dispatchSubmission({ isDirty: false }); - saveResponseMutation.mutate(submission); - }; + const onFileUploaded = useCallback(async (data) => { + const { fileData, queryClient } = data; + const uploadResponse = await uploadFilesMutation.mutateAsync(data); + if (uploadResponse) { + setValue((oldFiles) => [...oldFiles, uploadResponse.uploadedFiles[0]]); + } + }, [uploadFilesMutation, setValue]); + + const onDeletedFile = deleteFileMutation.mutateAsync; + + return { value, onFileUploaded, onDeletedFile }; +}; + +const useSubmissionViewData = () => { + const submitResponseMutation = useSubmitResponse(); + const textResponses = useTextResponses(); + const uploadedFiles = useUploadedFiles(); + const { showDuringResponse } = useRubricConfig(); + + const submitResponseHandler = useCallback(() => { + submitResponseMutation.mutate({ + textResponses: textResponses.formProps.value, + uploadedFiles: uploadedFiles.value, + }); + }, [submitResponseMutation, textResponses, uploadedFiles]); return { - submitResponseHandler, - submitResponseStatus: submitResponseMutation.status, - saveResponseHandler, - saveResponseStatus: saveResponseMutation.status, - draftSaved: saveResponseMutation.status === MutationStatus.success && !submission.isDirty, - pageData, - oraConfigData, - submission, - dispatchSubmission, - onTextResponseChange, - onFileUploaded, - onDeletedFile, + actionsProps: { + submitResponse: { + handler: submitResponseHandler, + status: submitResponseMutation.status, + }, + saveResponse: textResponses.saveResponse, + }, + formProps: { + textResponses: textResponses.formProps, + uploadedFiles, + }, + showRubric: showDuringResponse, }; }; -export default useSubmissionViewHooks; +export default useSubmissionViewData; diff --git a/src/views/SubmissionView/index.jsx b/src/views/SubmissionView/index.jsx index 5239589e..52de0395 100644 --- a/src/views/SubmissionView/index.jsx +++ b/src/views/SubmissionView/index.jsx @@ -1,40 +1,37 @@ import React from 'react'; +import PropTypes from 'prop-types'; +import { Col, Row } from '@edx/paragon'; + +import Rubric from 'components/Rubric'; import ProgressBar from 'components/ProgressBar'; -import SubmissionContentLayout from './SubmissionContentLayout'; +import { useIsPageDataLoaded } from 'data/services/lms/hooks/selectors'; + +import SubmissionContent from './SubmissionContent'; import SubmissionActions from './SubmissionActions'; -import useSubmissionViewHooks from './hooks'; +import useSubmissionViewData from './hooks'; + +import './index.scss'; export const SubmissionView = () => { - const { - submission, - oraConfigData, - onFileUploaded, - onTextResponseChange, - submitResponseHandler, - submitResponseStatus, - saveResponseHandler, - saveResponseStatus, - draftSaved, - onDeletedFile, - } = useSubmissionViewHooks(); + const { actionsProps, formProps, showRubric } = useSubmissionViewData(); + if (!useIsPageDataLoaded()) { + return null; + } return ( <> - - +
+
+ + + + + {showRubric && } + +
+
+ ); }; diff --git a/src/views/SubmissionView/index.test.jsx b/src/views/SubmissionView/index.test.jsx index 1d233c9a..8311e215 100644 --- a/src/views/SubmissionView/index.test.jsx +++ b/src/views/SubmissionView/index.test.jsx @@ -10,7 +10,6 @@ jest.mock('./hooks', () => jest.fn().mockReturnValue({ submission: 'submission', oraConfigData: 'oraConfigData', onFileUploaded: jest.fn().mockName('onFileUploaded'), - onDeletedFile: jest.fn().mockName('onDeletedFile'), onTextResponseChange: jest.fn().mockName('onTextResponseChange'), submitResponseHandler: jest.fn().mockName('submitResponseHandler'), submitResponseStatus: 'submitResponseStatus',