Compare commits

...

9 Commits

Author SHA1 Message Date
Brian Smith
8a2c6aae3b fix(deps): regenerate package-lock.json (#478)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-02-13 16:56:52 -05:00
Anton Melser
d4c6d93c26 docs: include 'standard' dev instructions 2026-01-28 12:00:10 -03:00
Anton Melser
0ff61e11d5 docs: Generify currently supported node version 2026-01-28 12:00:10 -03:00
Jansen Kantor
d9e5ae0c80 Merge pull request #471 from openedx/jkantor/prompt
feat: add prompt to grading screen
2026-01-08 13:17:54 -07:00
Jansen Kantor
3c03358d4e fix: failing tests 2026-01-08 15:14:48 -05:00
Jansen Kantor
f7e6e30d99 style: formatting 2026-01-08 14:57:56 -05:00
Jansen Kantor
ae365b6951 test: add test coverage 2025-11-05 15:49:26 -05:00
Jansen Kantor
729cb40c66 feat: add prompt to grading screen 2025-11-05 15:10:01 -05:00
Muhammad Anas
bc4abcdeef chore: update frontend-component-header to v8 (#468) 2025-11-03 16:38:29 -05:00
14 changed files with 3555 additions and 1888 deletions

View File

@@ -26,18 +26,14 @@ Getting Started
Prerequisites Prerequisites
============= =============
The `devstack`_ is currently recommended as a development environment for your `Tutor`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you new MFE. Please refer
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it. to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor .. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development .. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
Plugins Plugins
======= =======
@@ -60,9 +56,9 @@ First, clone the repo, install code prerequisites, and start the app.
``git clone git@github.com:openedx/frontend-app-ora-grading.git`` ``git clone git@github.com:openedx/frontend-app-ora-grading.git``
2. Use node v18.x. 2. Use the version of Node specified in the ``.nvmrc`` file.
The current version of the micro-frontend build scripts support node 18. The current version of the micro-frontend build scripts supports the version of Node found in ``.nvmrc``.
Using other major versions of node *may* work, but this is unsupported. For Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_. correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.

5301
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@
"dependencies": { "dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0", "@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0", "@edx/frontend-component-header": "^8.0.0",
"@edx/frontend-platform": "^8.3.1", "@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.6.0", "@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/fontawesome-svg-core": "^1.2.36",

View File

@@ -18,7 +18,7 @@ export const fetchFile = async ({
onSuccess(); onSuccess();
setContent(data); setContent(data);
}) })
.catch((e) => onError(e.response.status)); .catch((e) => onError(e.response?.status));
export const rendererHooks = ({ url, onError, onSuccess }) => { export const rendererHooks = ({ url, onError, onSuccess }) => {
const [content, setContent] = module.state.content(''); const [content, setContent] = module.state.content('');

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Collapsible } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import messages from './messages';
const PromptDisplay = ({
prompt,
}) => {
const intl = useIntl();
const msg = intl.formatMessage(messages.promptCollapsibleHeader);
return (
<div className="prompt-display">
<Collapsible
defaultOpen
styling="card-lg"
title={<h3>{msg}</h3>}
>
{ prompt }
</Collapsible>
</div>
);
};
PromptDisplay.propTypes = {
prompt: PropTypes.string.isRequired,
};
export default PromptDisplay;

View File

@@ -4,6 +4,14 @@
overflow-y: hidden; overflow-y: hidden;
height: fit-content; 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 {
.submission-files-title { .submission-files-title {
padding: var(--pgn-spacing-spacer-3); padding: var(--pgn-spacing-spacer-3);
@@ -42,6 +50,10 @@
padding: var(--pgn-spacing-spacer-3) 0; padding: var(--pgn-spacing-spacer-3) 0;
} }
.response-display-card {
margin: var(--pgn-spacing-spacer-3) 0;
}
.response-display-text-content { .response-display-text-content {
white-space: pre-line; white-space: pre-line;
overflow: hidden; overflow: hidden;

View File

@@ -14,7 +14,7 @@ import { fileUploadResponseOptions } from 'data/services/lms/constants';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import SubmissionFiles from './SubmissionFiles'; import SubmissionFiles from './SubmissionFiles';
import PreviewDisplay from './PreviewDisplay'; import PreviewDisplay from './PreviewDisplay';
import PromptDisplay from './PromptDisplay';
import './ResponseDisplay.scss'; import './ResponseDisplay.scss';
/** /**
@@ -26,13 +26,13 @@ export class ResponseDisplay extends React.Component {
this.purify = createDOMPurify(window); this.purify = createDOMPurify(window);
} }
get prompts() {
return this.props.prompts.map((item) => this.formattedHtml(item));
}
get textContents() { get textContents() {
const { text } = this.props.response; const { text } = this.props.response;
const formattedText = text.map((item) => this.formattedHtml(item));
const formattedText = text
.map((item) => item.replaceAll(/\.\.\/asset/g, `${getConfig().LMS_BASE_URL}/asset`))
.map((item) => parse(this.purify.sanitize(item)));
return formattedText; return formattedText;
} }
@@ -46,17 +46,28 @@ 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() { render() {
const { prompts } = this;
const multiPrompt = prompts.length > 1;
return ( return (
<div className="response-display"> <div className="response-display">
{!multiPrompt && <PromptDisplay prompt={prompts[0]} />}
{this.allowFileUpload && <SubmissionFiles files={this.submittedFiles} data-testid="submission-files" />} {this.allowFileUpload && <SubmissionFiles files={this.submittedFiles} data-testid="submission-files" />}
{this.allowFileUpload && <PreviewDisplay files={this.submittedFiles} data-testid="allow-file-upload" />} {this.allowFileUpload && <PreviewDisplay files={this.submittedFiles} data-testid="allow-file-upload" />}
{ {
/* eslint-disable react/no-array-index-key */ /* eslint-disable react/no-array-index-key */
this.textContents.map((textContent, index) => ( this.textContents.map((textContent, index) => (
<Card key={index}> <>
<Card.Section className="response-display-text-content" data-testid="response-display-text-content">{textContent}</Card.Section> { multiPrompt && <PromptDisplay prompt={prompts[index]} /> }
</Card> <Card className="response-display-card" key={index}>
<Card.Section className="response-display-text-content" data-testid="response-display-text-content">{textContent}</Card.Section>
</Card>
</>
)) ))
} }
</div> </div>
@@ -71,6 +82,7 @@ ResponseDisplay.defaultProps = {
}, },
fileUploadResponseConfig: fileUploadResponseOptions.none, fileUploadResponseConfig: fileUploadResponseOptions.none,
}; };
ResponseDisplay.propTypes = { ResponseDisplay.propTypes = {
response: PropTypes.shape({ response: PropTypes.shape({
text: PropTypes.arrayOf(PropTypes.string), text: PropTypes.arrayOf(PropTypes.string),
@@ -83,11 +95,13 @@ ResponseDisplay.propTypes = {
fileUploadResponseConfig: PropTypes.oneOf( fileUploadResponseConfig: PropTypes.oneOf(
Object.values(fileUploadResponseOptions), Object.values(fileUploadResponseOptions),
), ),
prompts: PropTypes.arrayOf(PropTypes.string).isRequired,
}; };
export const mapStateToProps = (state) => ({ export const mapStateToProps = (state) => ({
response: selectors.grading.selected.response(state), response: selectors.grading.selected.response(state),
fileUploadResponseConfig: selectors.app.ora.fileUploadResponseConfig(state), fileUploadResponseConfig: selectors.app.ora.fileUploadResponseConfig(state),
prompts: selectors.app.ora.prompts(state),
}); });
export const mapDispatchToProps = {}; export const mapDispatchToProps = {};

View File

@@ -13,11 +13,16 @@ jest.mock('data/redux', () => ({
app: { app: {
ora: { ora: {
fileUploadResponseConfig: jest.fn((state) => state.fileUploadResponseConfig || 'optional'), fileUploadResponseConfig: jest.fn((state) => state.fileUploadResponseConfig || 'optional'),
prompts: jest.fn((state) => state.prompts || ['prompt']),
}, },
}, },
}, },
})); }));
jest.mock('./PromptDisplay', () => jest.fn(({ prompt }) => (
<div data-testid="prompt-display">Prompt: {prompt}</div>
)));
jest.mock('./SubmissionFiles', () => jest.fn(({ files }) => ( jest.mock('./SubmissionFiles', () => jest.fn(({ files }) => (
<div data-testid="submission-files">Files: {files.length}</div> <div data-testid="submission-files">Files: {files.length}</div>
))); )));
@@ -50,6 +55,7 @@ describe('ResponseDisplay', () => {
], ],
}, },
fileUploadResponseConfig: 'optional', fileUploadResponseConfig: 'optional',
prompts: ['prompt one', 'prompt two'],
}; };
beforeAll(() => { beforeAll(() => {
@@ -100,6 +106,16 @@ describe('ResponseDisplay', () => {
const textContents = container.querySelectorAll('.response-display-text-content'); const textContents = container.querySelectorAll('.response-display-text-content');
expect(textContents).toHaveLength(0); expect(textContents).toHaveLength(0);
}); });
it('displays single prompt when only one prompt', () => {
render(<ResponseDisplay {...defaultProps} prompts={['only one prompt']} />);
expect(screen.queryAllByTestId('prompt-display')).toHaveLength(1);
});
it('displays multiple prompts when there are multiple prompts', () => {
render(<ResponseDisplay {...defaultProps} />);
expect(screen.queryAllByTestId('prompt-display')).toHaveLength(2);
});
}); });
describe('mapStateToProps', () => { describe('mapStateToProps', () => {
@@ -109,6 +125,7 @@ describe('ResponseDisplay', () => {
files: ['file1', 'file2'], files: ['file1', 'file2'],
}, },
fileUploadResponseConfig: 'required', fileUploadResponseConfig: 'required',
prompts: ['prompt'],
}; };
it('maps response from grading.selected.response selector', () => { it('maps response from grading.selected.response selector', () => {
@@ -120,5 +137,10 @@ describe('ResponseDisplay', () => {
const mapped = mapStateToProps(testState); const mapped = mapStateToProps(testState);
expect(mapped.fileUploadResponseConfig).toEqual(selectors.app.ora.fileUploadResponseConfig(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));
});
}); });
}); });

View File

@@ -46,6 +46,11 @@ const messages = defineMessages({
defaultMessage: 'Exceeded the allow download size', defaultMessage: 'Exceeded the allow download size',
description: 'Exceed the allow download size error message', 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; export default messages;

View File

@@ -11,7 +11,7 @@ const initialState = {
isEnabled: false, isEnabled: false,
isGrading: false, isGrading: false,
oraMetadata: { oraMetadata: {
prompt: '', prompts: [],
name: '', name: '',
type: '', type: '',
rubricConfig: null, rubricConfig: null,

View File

@@ -17,7 +17,7 @@ describe('app reducer', () => {
}); });
test('populated, but empty ora metadata', () => { test('populated, but empty ora metadata', () => {
const data = initialState.oraMetadata; const data = initialState.oraMetadata;
expect(data.prompt).toEqual(''); expect(data.prompts).toEqual([]);
expect(data.name).toEqual(''); expect(data.name).toEqual('');
expect(data.type).toEqual(''); expect(data.type).toEqual('');
expect(data.rubricConfig).toEqual(null); expect(data.rubricConfig).toEqual(null);

View File

@@ -33,10 +33,10 @@ export const ora = {
*/ */
name: oraMetadataSelector(data => data.name), name: oraMetadataSelector(data => data.name),
/** /**
* Returns the ORA Prompt * Returns the ORA Prompts
* @return {string} - ORA prompt * @return {array[]} - List of ORA prompts
*/ */
prompt: oraMetadataSelector(data => data.prompt), prompts: oraMetadataSelector(data => (data.prompts ? data.prompts.map((oraPrompt) => oraPrompt.description) : [])),
/** /**
* Returns the ORA type * Returns the ORA type
* @return {string} - ORA type (team vs individual) * @return {string} - ORA type (team vs individual)

View File

@@ -18,7 +18,10 @@ const testState = {
}, },
oraMetadata: { oraMetadata: {
name: 'test-ora-name', name: 'test-ora-name',
prompt: 'test-ora-prompt', prompts: [
{ description: 'test-ora-prompt' },
{ description: 'test-second-prompt' },
],
type: 'test-ora-type', type: 'test-ora-type',
fileUploadResponseConfig: 'file-upload-response-config', fileUploadResponseConfig: 'file-upload-response-config',
rubricConfig: { rubricConfig: {
@@ -102,8 +105,8 @@ describe('app selectors unit tests', () => {
test('ora.name selector returns name from oraMetadata', () => { test('ora.name selector returns name from oraMetadata', () => {
testOraSelector(selectors.ora.name, oraMetadata.name); testOraSelector(selectors.ora.name, oraMetadata.name);
}); });
test('ora.prompt selector returns prompt from oraMetadata', () => { test('ora.prompts selector returns prompts from oraMetadata', () => {
testOraSelector(selectors.ora.prompt, oraMetadata.prompt); testOraSelector(selectors.ora.prompts, ['test-ora-prompt', 'test-second-prompt']);
}); });
test('ora.type selector returns type from oraMetadata', () => { test('ora.type selector returns type from oraMetadata', () => {
testOraSelector(selectors.ora.type, oraMetadata.type); testOraSelector(selectors.ora.type, oraMetadata.type);

View File

@@ -16,7 +16,7 @@ import {
/** /**
* get('/api/initialize', { oraLocation }) * get('/api/initialize', { oraLocation })
* @return { * @return {
* oraMetadata: { name, prompt, type ('individual' vs 'team'), rubricConfig, fileUploadResponseConfig }, * oraMetadata: { name, prompts, type ('individual' vs 'team'), rubricConfig, fileUploadResponseConfig },
* courseMetadata: { courseOrg, courseName, courseNumber, courseId }, * courseMetadata: { courseOrg, courseName, courseNumber, courseId },
* submissions: { * submissions: {
* [submissionUUID]: { * [submissionUUID]: {