From 729cb40c66fcd79c62160138d13320b6fb75c59e Mon Sep 17 00:00:00 2001 From: Jansen Kantor Date: Wed, 5 Nov 2025 15:10:01 -0500 Subject: [PATCH 1/4] feat: add prompt to grading screen --- .../ResponseDisplay/PromptDisplay.jsx | 51 +++++++++++++++++++ .../ResponseDisplay/ResponseDisplay.scss | 12 +++++ src/containers/ResponseDisplay/index.jsx | 26 +++++++--- src/containers/ResponseDisplay/index.test.jsx | 13 +++++ src/containers/ResponseDisplay/messages.js | 5 ++ src/data/redux/app/reducer.js | 2 +- src/data/redux/app/reducer.test.js | 2 +- src/data/redux/app/selectors.js | 6 +-- src/data/redux/app/selectors.test.js | 9 ++-- src/data/services/lms/api.js | 2 +- 10 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 src/containers/ResponseDisplay/PromptDisplay.jsx diff --git a/src/containers/ResponseDisplay/PromptDisplay.jsx b/src/containers/ResponseDisplay/PromptDisplay.jsx new file mode 100644 index 0000000..e8475c5 --- /dev/null +++ b/src/containers/ResponseDisplay/PromptDisplay.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Collapsible, Card } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import PropTypes from 'prop-types'; +import messages from './messages'; + +const PromptDisplay = ({ + prompt, className, styling, headerTitle, +}) => { + const intl = useIntl(); + const msg = intl.formatMessage(messages.promptCollapsibleHeader); + return ( +
+ {msg} : msg} + > + { prompt } + +
+ ); +}; + +PromptDisplay.propTypes = { + prompt: PropTypes.string.isRequired, + className: PropTypes.string.isRequired, + styling: PropTypes.string.isRequired, + headerTitle: PropTypes.bool.isRequired, +}; + +const SinglePromptDisplay = ({ prompt }) => ( + +); + +SinglePromptDisplay.propTypes = { + prompt: PropTypes.string.isRequired, +}; + +const MultiplePromptDisplay = ({ prompt }) => ( + <> + + + +); + +MultiplePromptDisplay.propTypes = { + prompt: PropTypes.string.isRequired, +}; + +export { SinglePromptDisplay, MultiplePromptDisplay }; diff --git a/src/containers/ResponseDisplay/ResponseDisplay.scss b/src/containers/ResponseDisplay/ResponseDisplay.scss index 5d4e0fe..16a8e04 100644 --- a/src/containers/ResponseDisplay/ResponseDisplay.scss +++ b/src/containers/ResponseDisplay/ResponseDisplay.scss @@ -4,6 +4,14 @@ overflow-y: hidden; height: fit-content; + .prompt-display-single { + padding: var(--pgn-spacing-spacer-3) 0; + } + + .prompt-display-multiple > .collapsible-basic .collapsible-trigger{ + text-decoration: none!important; + } + .submission-files { .submission-files-title { padding: var(--pgn-spacing-spacer-3); @@ -42,6 +50,10 @@ padding: var(--pgn-spacing-spacer-3) 0; } + .response-display-card { + margin: var(--pgn-spacing-spacer-3) 0; + } + .response-display-text-content { white-space: pre-line; overflow: hidden; diff --git a/src/containers/ResponseDisplay/index.jsx b/src/containers/ResponseDisplay/index.jsx index 09cdac4..38ef359 100644 --- a/src/containers/ResponseDisplay/index.jsx +++ b/src/containers/ResponseDisplay/index.jsx @@ -14,7 +14,7 @@ import { fileUploadResponseOptions } from 'data/services/lms/constants'; import { getConfig } from '@edx/frontend-platform'; import SubmissionFiles from './SubmissionFiles'; import PreviewDisplay from './PreviewDisplay'; - +import { SinglePromptDisplay, MultiplePromptDisplay } from './PromptDisplay'; import './ResponseDisplay.scss'; /** @@ -26,13 +26,13 @@ export class ResponseDisplay extends React.Component { this.purify = createDOMPurify(window); } + get prompts() { + return this.props.prompts.map((item) => this.formattedHtml(item)); + } + get textContents() { const { text } = this.props.response; - - const formattedText = text - .map((item) => item.replaceAll(/\.\.\/asset/g, `${getConfig().LMS_BASE_URL}/asset`)) - .map((item) => parse(this.purify.sanitize(item))); - + const formattedText = text.map((item) => this.formattedHtml(item)); return formattedText; } @@ -46,15 +46,24 @@ export class ResponseDisplay extends React.Component { ); } + formattedHtml(text) { + const cleanedText = text.replaceAll(/\.\.\/asset/g, `${getConfig().LMS_BASE_URL}/asset`); + return parse(this.purify.sanitize(cleanedText)); + } + render() { + const { prompts } = this; + const multiPrompt = prompts.length > 1; return (
+ {!multiPrompt && } {this.allowFileUpload && } {this.allowFileUpload && } { /* eslint-disable react/no-array-index-key */ this.textContents.map((textContent, index) => ( - + + {multiPrompt && } {textContent} )) @@ -71,6 +80,7 @@ ResponseDisplay.defaultProps = { }, fileUploadResponseConfig: fileUploadResponseOptions.none, }; + ResponseDisplay.propTypes = { response: PropTypes.shape({ text: PropTypes.arrayOf(PropTypes.string), @@ -83,11 +93,13 @@ ResponseDisplay.propTypes = { fileUploadResponseConfig: PropTypes.oneOf( Object.values(fileUploadResponseOptions), ), + prompts: PropTypes.arrayOf(PropTypes.string).isRequired, }; export const mapStateToProps = (state) => ({ response: selectors.grading.selected.response(state), fileUploadResponseConfig: selectors.app.ora.fileUploadResponseConfig(state), + prompts: selectors.app.ora.prompts(state), }); export const mapDispatchToProps = {}; diff --git a/src/containers/ResponseDisplay/index.test.jsx b/src/containers/ResponseDisplay/index.test.jsx index 8925400..030b3b8 100644 --- a/src/containers/ResponseDisplay/index.test.jsx +++ b/src/containers/ResponseDisplay/index.test.jsx @@ -13,11 +13,17 @@ jest.mock('data/redux', () => ({ app: { ora: { fileUploadResponseConfig: jest.fn((state) => state.fileUploadResponseConfig || 'optional'), + prompts: jest.fn((state) => state.prompts || ['prompt']), }, }, }, })); +jest.mock('./PromptDisplay', () => ({ + SinglePromptDisplay: jest.fn(({ prompt }) => (
Prompt: {prompt}
)), + MultiplePromptDisplay: jest.fn(({ prompt }) => (
Prompt: {prompt}
)), +})); + jest.mock('./SubmissionFiles', () => jest.fn(({ files }) => (
Files: {files.length}
))); @@ -50,6 +56,7 @@ describe('ResponseDisplay', () => { ], }, fileUploadResponseConfig: 'optional', + prompts: ['prompt one', 'prompt two'], }; beforeAll(() => { @@ -109,6 +116,7 @@ describe('ResponseDisplay', () => { files: ['file1', 'file2'], }, fileUploadResponseConfig: 'required', + prompts: ['prompt'], }; it('maps response from grading.selected.response selector', () => { @@ -120,5 +128,10 @@ describe('ResponseDisplay', () => { const mapped = mapStateToProps(testState); expect(mapped.fileUploadResponseConfig).toEqual(selectors.app.ora.fileUploadResponseConfig(testState)); }); + + it('maps prompts from app.ora.prompts selector', () => { + const mapped = mapStateToProps(testState); + expect(mapped.prompts).toEqual(selectors.app.ora.prompts(testState)); + }); }); }); diff --git a/src/containers/ResponseDisplay/messages.js b/src/containers/ResponseDisplay/messages.js index e738776..de66c40 100644 --- a/src/containers/ResponseDisplay/messages.js +++ b/src/containers/ResponseDisplay/messages.js @@ -46,6 +46,11 @@ const messages = defineMessages({ defaultMessage: 'Exceeded the allow download size', description: 'Exceed the allow download size error message', }, + promptCollapsibleHeader: { + id: 'ora-grading.ResponseDisplay.Prompt.collapsibleHeader', + defaultMessage: 'Prompt', + description: 'Header for a collapsible that displays the assignment prompt', + }, }); export default messages; diff --git a/src/data/redux/app/reducer.js b/src/data/redux/app/reducer.js index f466e60..aea71aa 100644 --- a/src/data/redux/app/reducer.js +++ b/src/data/redux/app/reducer.js @@ -11,7 +11,7 @@ const initialState = { isEnabled: false, isGrading: false, oraMetadata: { - prompt: '', + prompts: [], name: '', type: '', rubricConfig: null, diff --git a/src/data/redux/app/reducer.test.js b/src/data/redux/app/reducer.test.js index 4302fd2..c138494 100644 --- a/src/data/redux/app/reducer.test.js +++ b/src/data/redux/app/reducer.test.js @@ -17,7 +17,7 @@ describe('app reducer', () => { }); test('populated, but empty ora metadata', () => { const data = initialState.oraMetadata; - expect(data.prompt).toEqual(''); + expect(data.prompts).toEqual([]); expect(data.name).toEqual(''); expect(data.type).toEqual(''); expect(data.rubricConfig).toEqual(null); diff --git a/src/data/redux/app/selectors.js b/src/data/redux/app/selectors.js index 815a73d..4099f7e 100644 --- a/src/data/redux/app/selectors.js +++ b/src/data/redux/app/selectors.js @@ -33,10 +33,10 @@ export const ora = { */ name: oraMetadataSelector(data => data.name), /** - * Returns the ORA Prompt - * @return {string} - ORA prompt + * Returns the ORA Prompts + * @return {array[string]} - ORA prompt */ - prompt: oraMetadataSelector(data => data.prompt), + prompts: oraMetadataSelector(data => (data.prompts ? data.prompts.map((oraPrompt) => oraPrompt.description) : [])), /** * Returns the ORA type * @return {string} - ORA type (team vs individual) diff --git a/src/data/redux/app/selectors.test.js b/src/data/redux/app/selectors.test.js index cb22fb3..36b7f16 100644 --- a/src/data/redux/app/selectors.test.js +++ b/src/data/redux/app/selectors.test.js @@ -18,7 +18,10 @@ const testState = { }, oraMetadata: { name: 'test-ora-name', - prompt: 'test-ora-prompt', + prompts: [ + { description: 'test-ora-prompt' }, + { description: 'test-second-prompt' }, + ], type: 'test-ora-type', fileUploadResponseConfig: 'file-upload-response-config', rubricConfig: { @@ -102,8 +105,8 @@ describe('app selectors unit tests', () => { test('ora.name selector returns name from oraMetadata', () => { testOraSelector(selectors.ora.name, oraMetadata.name); }); - test('ora.prompt selector returns prompt from oraMetadata', () => { - testOraSelector(selectors.ora.prompt, oraMetadata.prompt); + test('ora.prompts selector returns prompts from oraMetadata', () => { + testOraSelector(selectors.ora.prompts, ['test-ora-prompt', 'test-second-prompt']); }); test('ora.type selector returns type from oraMetadata', () => { testOraSelector(selectors.ora.type, oraMetadata.type); diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index bc4c41c..c2ea60e 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -16,7 +16,7 @@ import { /** * get('/api/initialize', { oraLocation }) * @return { - * oraMetadata: { name, prompt, type ('individual' vs 'team'), rubricConfig, fileUploadResponseConfig }, + * oraMetadata: { name, prompts, type ('individual' vs 'team'), rubricConfig, fileUploadResponseConfig }, * courseMetadata: { courseOrg, courseName, courseNumber, courseId }, * submissions: { * [submissionUUID]: { From ae365b6951ff3b5ec3ceb38ee43c1d9e2b4ef31f Mon Sep 17 00:00:00 2001 From: Jansen Kantor Date: Wed, 5 Nov 2025 15:49:26 -0500 Subject: [PATCH 2/4] test: add test coverage --- src/containers/ResponseDisplay/index.test.jsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/containers/ResponseDisplay/index.test.jsx b/src/containers/ResponseDisplay/index.test.jsx index 030b3b8..47fe2f1 100644 --- a/src/containers/ResponseDisplay/index.test.jsx +++ b/src/containers/ResponseDisplay/index.test.jsx @@ -107,6 +107,18 @@ describe('ResponseDisplay', () => { const textContents = container.querySelectorAll('.response-display-text-content'); expect(textContents).toHaveLength(0); }); + + it('displays single prompt when only one prompt', () => { + render(); + expect(screen.queryByTestId('prompt-single')).toBeInTheDocument(); + expect(screen.queryByTestId('prompt-multiple')).not.toBeInTheDocument(); + }); + + it('displays multiple prompts when there are multiple prompts', () => { + render(); + expect(screen.queryByTestId('prompt-single')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('prompt-multiple')).toHaveLength(2); + }); }); describe('mapStateToProps', () => { From f7e6e30d99b5b237003ac96e3df81d47106fb5ae Mon Sep 17 00:00:00 2001 From: Jansen Kantor Date: Thu, 8 Jan 2026 14:57:56 -0500 Subject: [PATCH 3/4] style: formatting --- .../ResponseDisplay/PromptDisplay.jsx | 35 ++++--------------- .../ResponseDisplay/ResponseDisplay.scss | 4 +-- src/containers/ResponseDisplay/index.jsx | 14 ++++---- src/data/redux/app/selectors.js | 2 +- 4 files changed, 18 insertions(+), 37 deletions(-) diff --git a/src/containers/ResponseDisplay/PromptDisplay.jsx b/src/containers/ResponseDisplay/PromptDisplay.jsx index e8475c5..fb7da90 100644 --- a/src/containers/ResponseDisplay/PromptDisplay.jsx +++ b/src/containers/ResponseDisplay/PromptDisplay.jsx @@ -1,20 +1,21 @@ import React from 'react'; -import { Collapsible, Card } from '@openedx/paragon'; +import { Collapsible } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import messages from './messages'; const PromptDisplay = ({ - prompt, className, styling, headerTitle, + prompt, }) => { const intl = useIntl(); const msg = intl.formatMessage(messages.promptCollapsibleHeader); return ( -
+
{msg} : msg} + defaultOpen + styling="card-lg" + title={

{msg}

} > { prompt }
@@ -24,28 +25,6 @@ const PromptDisplay = ({ PromptDisplay.propTypes = { prompt: PropTypes.string.isRequired, - className: PropTypes.string.isRequired, - styling: PropTypes.string.isRequired, - headerTitle: PropTypes.bool.isRequired, }; -const SinglePromptDisplay = ({ prompt }) => ( - -); - -SinglePromptDisplay.propTypes = { - prompt: PropTypes.string.isRequired, -}; - -const MultiplePromptDisplay = ({ prompt }) => ( - <> - - - -); - -MultiplePromptDisplay.propTypes = { - prompt: PropTypes.string.isRequired, -}; - -export { SinglePromptDisplay, MultiplePromptDisplay }; +export default PromptDisplay; diff --git a/src/containers/ResponseDisplay/ResponseDisplay.scss b/src/containers/ResponseDisplay/ResponseDisplay.scss index 16a8e04..45566fe 100644 --- a/src/containers/ResponseDisplay/ResponseDisplay.scss +++ b/src/containers/ResponseDisplay/ResponseDisplay.scss @@ -8,8 +8,8 @@ padding: var(--pgn-spacing-spacer-3) 0; } - .prompt-display-multiple > .collapsible-basic .collapsible-trigger{ - text-decoration: none!important; + .prompt-display-multiple > .collapsible-basic .collapsible-trigger { + text-decoration: none !important; } .submission-files { diff --git a/src/containers/ResponseDisplay/index.jsx b/src/containers/ResponseDisplay/index.jsx index 38ef359..9211bc5 100644 --- a/src/containers/ResponseDisplay/index.jsx +++ b/src/containers/ResponseDisplay/index.jsx @@ -14,7 +14,7 @@ import { fileUploadResponseOptions } from 'data/services/lms/constants'; import { getConfig } from '@edx/frontend-platform'; import SubmissionFiles from './SubmissionFiles'; import PreviewDisplay from './PreviewDisplay'; -import { SinglePromptDisplay, MultiplePromptDisplay } from './PromptDisplay'; +import PromptDisplay from './PromptDisplay'; import './ResponseDisplay.scss'; /** @@ -56,16 +56,18 @@ export class ResponseDisplay extends React.Component { const multiPrompt = prompts.length > 1; return (
- {!multiPrompt && } + {!multiPrompt && } {this.allowFileUpload && } {this.allowFileUpload && } { /* eslint-disable react/no-array-index-key */ this.textContents.map((textContent, index) => ( - - {multiPrompt && } - {textContent} - + <> + { multiPrompt && } + + {textContent} + + )) }
diff --git a/src/data/redux/app/selectors.js b/src/data/redux/app/selectors.js index 4099f7e..31f4391 100644 --- a/src/data/redux/app/selectors.js +++ b/src/data/redux/app/selectors.js @@ -34,7 +34,7 @@ export const ora = { name: oraMetadataSelector(data => data.name), /** * Returns the ORA Prompts - * @return {array[string]} - ORA prompt + * @return {array[]} - List of ORA prompts */ prompts: oraMetadataSelector(data => (data.prompts ? data.prompts.map((oraPrompt) => oraPrompt.description) : [])), /** From 3c03358d4e8bedf29cc71d6f557ead630213566d Mon Sep 17 00:00:00 2001 From: Jansen Kantor Date: Thu, 8 Jan 2026 15:14:48 -0500 Subject: [PATCH 4/4] fix: failing tests --- .../FilePreview/BaseRenderers/textHooks.js | 2 +- src/containers/ResponseDisplay/index.test.jsx | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/components/FilePreview/BaseRenderers/textHooks.js b/src/components/FilePreview/BaseRenderers/textHooks.js index 8887256..09b0128 100644 --- a/src/components/FilePreview/BaseRenderers/textHooks.js +++ b/src/components/FilePreview/BaseRenderers/textHooks.js @@ -18,7 +18,7 @@ export const fetchFile = async ({ onSuccess(); setContent(data); }) - .catch((e) => onError(e.response.status)); + .catch((e) => onError(e.response?.status)); export const rendererHooks = ({ url, onError, onSuccess }) => { const [content, setContent] = module.state.content(''); diff --git a/src/containers/ResponseDisplay/index.test.jsx b/src/containers/ResponseDisplay/index.test.jsx index 47fe2f1..a417ad7 100644 --- a/src/containers/ResponseDisplay/index.test.jsx +++ b/src/containers/ResponseDisplay/index.test.jsx @@ -19,10 +19,9 @@ jest.mock('data/redux', () => ({ }, })); -jest.mock('./PromptDisplay', () => ({ - SinglePromptDisplay: jest.fn(({ prompt }) => (
Prompt: {prompt}
)), - MultiplePromptDisplay: jest.fn(({ prompt }) => (
Prompt: {prompt}
)), -})); +jest.mock('./PromptDisplay', () => jest.fn(({ prompt }) => ( +
Prompt: {prompt}
+))); jest.mock('./SubmissionFiles', () => jest.fn(({ files }) => (
Files: {files.length}
@@ -110,14 +109,12 @@ describe('ResponseDisplay', () => { it('displays single prompt when only one prompt', () => { render(); - expect(screen.queryByTestId('prompt-single')).toBeInTheDocument(); - expect(screen.queryByTestId('prompt-multiple')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('prompt-display')).toHaveLength(1); }); it('displays multiple prompts when there are multiple prompts', () => { render(); - expect(screen.queryByTestId('prompt-single')).not.toBeInTheDocument(); - expect(screen.queryAllByTestId('prompt-multiple')).toHaveLength(2); + expect(screen.queryAllByTestId('prompt-display')).toHaveLength(2); }); });