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
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
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
`Tutor`_ is currently recommended as a development environment for your
new MFE. Please refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _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
=======
@@ -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``
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
convenience, this repository includes an .nvmrc file to help in setting the
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": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@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/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "^1.2.36",

View File

@@ -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('');

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;
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;

View File

@@ -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 PromptDisplay 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,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() {
const { prompts } = this;
const multiPrompt = prompts.length > 1;
return (
<div className="response-display">
{!multiPrompt && <PromptDisplay prompt={prompts[0]} />}
{this.allowFileUpload && <SubmissionFiles files={this.submittedFiles} data-testid="submission-files" />}
{this.allowFileUpload && <PreviewDisplay files={this.submittedFiles} data-testid="allow-file-upload" />}
{
/* eslint-disable react/no-array-index-key */
this.textContents.map((textContent, index) => (
<Card key={index}>
<Card.Section className="response-display-text-content" data-testid="response-display-text-content">{textContent}</Card.Section>
</Card>
<>
{ multiPrompt && <PromptDisplay prompt={prompts[index]} /> }
<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>
@@ -71,6 +82,7 @@ ResponseDisplay.defaultProps = {
},
fileUploadResponseConfig: fileUploadResponseOptions.none,
};
ResponseDisplay.propTypes = {
response: PropTypes.shape({
text: PropTypes.arrayOf(PropTypes.string),
@@ -83,11 +95,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 = {};

View File

@@ -13,11 +13,16 @@ jest.mock('data/redux', () => ({
app: {
ora: {
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 }) => (
<div data-testid="submission-files">Files: {files.length}</div>
)));
@@ -50,6 +55,7 @@ describe('ResponseDisplay', () => {
],
},
fileUploadResponseConfig: 'optional',
prompts: ['prompt one', 'prompt two'],
};
beforeAll(() => {
@@ -100,6 +106,16 @@ describe('ResponseDisplay', () => {
const textContents = container.querySelectorAll('.response-display-text-content');
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', () => {
@@ -109,6 +125,7 @@ describe('ResponseDisplay', () => {
files: ['file1', 'file2'],
},
fileUploadResponseConfig: 'required',
prompts: ['prompt'],
};
it('maps response from grading.selected.response selector', () => {
@@ -120,5 +137,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));
});
});
});

View File

@@ -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;

View File

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

View File

@@ -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);

View File

@@ -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[]} - List of ORA prompts
*/
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)

View File

@@ -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);

View File

@@ -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]: {