Compare commits

..

1 Commits

Author SHA1 Message Date
Farhaan Bukhsh
b06315373b feat: Add max value warning and disable the submit button
Co-authored-by: lkatsikaris <lkatsikaris@@users.noreply.github.com>

Signed-off-by: Farhaan Bukhsh <farhaan@opencraft.com>
2025-01-12 19:48:42 +05:30
19 changed files with 4055 additions and 5923 deletions

102
README.md
View File

@@ -18,8 +18,8 @@ Jump to:
For existing documentation see:
- Basic Usage: [Review Learner Grades (read-the-docs)](https://docs.openedx.org/en/latest/educators/how-tos/data/view_learner_grades.html)
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#override-learner-subsection-scores-in-bulk)
- Basic Usage: [Review Learner Grades (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#review-learner-grades-on-the-instructor-dashboard)
- Bulk Grade Management: [Override Learner Subsection Scores in Bulk (read-the-docs)](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/student_progress/course_grades.html#override-learner-subsection-scores-in-bulk)
## Should I use Gradebook in my course?
@@ -58,53 +58,56 @@ To install gradebook into your project:
```
npm i --save @edx/frontend-app-gradebook
```
Cloning and Startup
===================
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-gradebook.git``
``git clone https://github.com/openedx/frontend-app-gradebook.git``
2. Use the version of Node specified in ``.nvmrc``
2. Install npm dependencies:
3. Stop the Tutor devstack, if it's running:
``cd frontend-app-gradebook && npm install``
``tutor dev stop``
3. Start the dev server:
4. Next, we need to tell Tutor that we're going to be running this repo in development mode, and it should be excluded from the mfe container that otherwise runs every MFE. Run this:
``tutor mounts add /path/to/frontend-app-gradebook``
5. Start Tutor in development mode. This command will start the LMS and Studio,
and other required MFEs like ``authn`` and ``account``, but will not start the
Gradebook MFE, which we're going to run on the host instead of in a container
managed by Tutor. Run:
``tutor dev start lms cms mfe``
## Startup
1. Install npm dependencies:
``cd frontend-app-gradebook && npm install``
2. Start the dev server:
``npm run dev``
``npm start``
## Running the UI Standalone
To install the project please refer to the [`MFE Development on Tutor`](https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development) instructions.
To install the project please refer to the [`edX Developer Stack`](https://github.com/openedx/devstack) instructions.
When not mounted, gradebook will run in the shared MFE container at http://apps.local.openedx.io/gradebook/course-v1:edX+DemoX+Demo_Course.
The web application runs on port **1994**, so when you go to `http://localhost:1994/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
When mounted in the tutor ``gradebook`` container, or when running a local (host) webpack dev server, the web application runs on port **1994**, so when you go to `http://apps.local.openedx.io:1994/gradebook/course-v1:edX+DemoX+Demo_Course` you should see the UI (assuming you have such a Demo Course in your devstack). Note that you always have to provide a course id to actually see a gradebook.
(Note: This may not work in Tutor; these instructions are for the deprecated Devstack) You can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
If you don't, you can see the log messages for the docker container by executing `make gradebook-logs` in the `devstack` directory.
Note that starting the container executes the `npm run start` script which will hot-reload JavaScript and Sass files changes, so you should (:crossed_fingers:) not need to do anything (other than wait) when making changes.
## Configuring for local use in edx-platform
Assuming you've got the UI running at `http://localhost:1994`, you can configure the LMS in edx-platform
to point to your local gradebook from the instructor dashboard by putting this setting in `lms/env/private.py`:
```
WRITABLE_GRADEBOOK_URL = 'http://localhost:1994'
```
There are also several edx-platform waffle and feature flags you'll have to enable from the Django admin:
1. Grades > Persistent grades enabled flag. Add this flag if it doesn't exist,
check the ``enabled`` and ``enabled for all courses`` boxes.
2. Waffle > Switches. Add the ``grades.assume_zero_grade_if_absent`` switch and make it active.
3. Waffle_utils > Waffle flag course overrides. Activate waffle flags for courses where you want to enable Gradebook functionality:
- Enable Gradebook by adding the ``grades.writable_gradebook`` add checking the ``enabled`` box.
- Enable Bulk Grade Management by adding the ``grades.bulk_management`` flag and checking the ``enabled`` box.
Alternatively, you could add these as regular waffle flags to enable the functionality for all courses.
**NOTE:** IF the above flags are not configured correctly, the gradebook may appear to work, but will return bogus
numbers for grades. If your gradebook isn't accepting your changes, or the changes aren't resulting in sane,
recalculated grade values, verify you've set all flags correctly.
## Plugins
This MFE can be customized using [Frontend Plugin Framework](https://github.com/openedx/frontend-plugin-framework).
@@ -112,13 +115,10 @@ The parts of this MFE that can be customized in that manner are documented [here
## Running tests
Run:
``nvm use``
``npm ci``
``npm test``
1. Assuming that you're operating in the context of the edX devstack,
run `gradebook-shell` from your devstack directory. This will start a bash shell inside your
running gradebook container.
2. Run `make test` (which executes `npm run test`). This will run all of the gradebook tests.
## Directory Structure
@@ -151,7 +151,9 @@ noted.
Contributing
============
Contributions are very welcome. Please read [How To Contribute](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/index.html) for details.
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
@@ -166,23 +168,29 @@ Getting Help
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a [Slack
invitation](https://openedx.org/slack), then join our
[community Slack workspace](https://openedx.slack.com/) Because this is a
frontend repository, the best place to discuss it would be in the
[#wg-frontend channel](https://openedx.slack.com/archives/C04BM6YC7A6).
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-gradebook/issues
For more information about these options, see the [Getting Help](https://openedx.org/community/connect) page.
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
The Open edX Code of Conduct
============================
All community members are expected to follow the [Open edX Code of Conduct](https://openedx.org/code-of-conduct/).
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
Reporting Security Issues
=========================

View File

@@ -7,7 +7,6 @@ metadata:
description: "The frontend (MFE) for Open edX Gradebook"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: user:farhaanbukhsh
type: 'website'

View File

@@ -4,15 +4,15 @@ Instructions for setting up environments and data for testing Gradebook.
## Set up a course with graded content
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://docs.openedx.org/en/latest/educators/quickstarts/build_a_course.html) for notes on how to develop a course from scratch.
A course with graded content is the first prerequisite to testing. Use an existing course (e.g. the DemoX Demonstration Course in Devstack) or see [Building and Running an edX Course > Developing Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/index.html) for notes on how to develop a course from scratch.
Notably, the course needs a grading policy and subsections with scoreable content.
After creating subsections with content, they need to be configured with an "Assignment Type" to be included in grading.
Suggested resources:
- [Establishing a Grading Policy For Your Course](https://docs.openedx.org/en/latest/educators/how-tos/data/manage_learner_grades.html#review-how-grading-is-configured-for-your-course)
- [Adding Exercises and Tools](https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_problems_exercises_tools.html)
- [Set the Assignment Type and Due Date for a Subsection](https://docs.openedx.org/en/latest/educators/how-tos/course_development/set_subsection_problem_date.html#set-the-assignment-type-and-due-date-for-a-subsection)
- [Establishing a Grading Policy For Your Course](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Adding Exercises and Tools](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/grading/index.html)
- [Set the Assignment Type and Due Date for a Subsection](https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/developing_course/course_subsections.html#set-the-assignment-type-and-due-date-for-a-subsection)
## Enable Gradebook for course
@@ -35,7 +35,7 @@ Bulk Management is an added feature to allow modifying grades in bulk via CSV up
## Create a Master's track for testing Master's-only features
[source - note: possibly outdated, edx.org-specific](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
[source](https://openedx.atlassian.net/wiki/spaces/MS/pages/1453818012/Add+a+learner+into+a+master+s+track)
Add a Master's track in your course:
- As an admin user, go to Django Admin (`{lms-url}/admin`) > Course Modes and add a new course mode

9
openedx.yaml Normal file
View File

@@ -0,0 +1,9 @@
# This file describes this Open edX repo, as described in OEP-2:
# http://open-edx-proposals.readthedocs.io/en/latest/oeps/oep-0002.html#specification
tags:
- frontend-app
- masters
oeps:
oep-2: true # Repository metadata
openedx-release: {ref: master}

9728
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@
"prepush": "npm run lint",
"semantic-release": "semantic-release",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/gradebook/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=GMT fedx-scripts jest --coverage --passWithNoTests",
"watch-tests": "jest --watch"
},
@@ -30,18 +29,18 @@
],
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.4.0",
"@edx/frontend-platform": "^8.3.7",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-platform": "8.0.0",
"@edx/openedx-atlas": "^0.6.0",
"@edx/react-unit-test-utils": "^4.0.0",
"@edx/react-unit-test-utils": "^3.0.0",
"@edx/reactifex": "^2.1.1",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/react-fontawesome": "^0.1.5",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/paragon": "^22.16.0",
"@openedx/frontend-plugin-framework": "^1.2.1",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.1.1",
"@redux-beacon/segment": "^1.0.0",
"@reduxjs/toolkit": "^1.5.1",
"classnames": "^2.2.6",
@@ -51,8 +50,8 @@
"history": "4.10.1",
"prop-types": "15.8.1",
"query-string": "6.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "^6.1.0",
"react-redux": "^7.2.9",
"react-router": "6.15.0",
@@ -69,14 +68,15 @@
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/react": "^16.2.0",
"@openedx/frontend-build": "14.0.3",
"@testing-library/react": "12.1.5",
"es-check": "^2.3.0",
"fetch-mock": "^12.2.0",
"husky": "2.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"react-dev-utils": "^12.0.1",
"react-test-renderer": "^18.3.1",
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.3"
}

View File

@@ -3,7 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react';
import { FooterSlot } from '@edx/frontend-component-footer';
import FooterSlot from '@openedx/frontend-slot-footer';
import Header from '@edx/frontend-component-header';
import store from 'data/store';

View File

@@ -16,7 +16,7 @@ jest.mock('react-router-dom', () => ({
jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: () => 'AppProvider',
}));
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'FooterSlot' }));
jest.mock('@edx/frontend-component-footer', () => ({ FooterSlot: 'Footer' }));
jest.mock('data/store', () => 'testStore');
jest.mock('containers/GradebookPage', () => 'GradebookPage');
jest.mock('@edx/frontend-component-header', () => 'Header');

View File

@@ -5,7 +5,7 @@ exports[`AdjustedGradeInput component render snapshot 1`] = `
<Form.Control
name="adjustedGradeValue"
onChange={[MockFunction hook.onChange]}
type="text"
type="number"
value="test-value"
/>
some-hint-text

View File

@@ -15,6 +15,7 @@ const useAdjustedGradeInputData = () => {
value,
onChange,
hintText,
possibleGrade,
};
};

View File

@@ -2,7 +2,9 @@ import React from 'react';
import { Form } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import useAdjustedGradeInputData from './hooks';
import messages from '../messages';
/**
* <AdjustedGradeInput />
@@ -14,16 +16,18 @@ export const AdjustedGradeInput = () => {
value,
onChange,
hintText,
possibleGrade,
} = useAdjustedGradeInputData();
const { formatMessage } = useIntl();
return (
<span>
<Form.Control
type="text"
type="number"
name="adjustedGradeValue"
value={value}
onChange={onChange}
/>
{hintText}
{value > possibleGrade ? <div style={{ color: 'red' }}>{ formatMessage(messages.adjustedGradeError, { possibleGrade })}</div> : hintText}
</span>
);
};

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Reason',
description: 'Edit Modal Override Table Reason column header',
},
adjustedGradeError: {
id: 'gradebook.GradesView.EditModal.Overrides.adjustedGradeError',
defaultMessage: 'The value exceeds the maximum grade: {possibleGrade}',
description: 'Edit Modal Override Adjusted Grade Error',
},
});
export default messages;

View File

@@ -36,6 +36,7 @@ exports[`EditModal component render with error snapshot 1`] = `
Cancel
</ModalDialog.CloseButton>
<Button
disabled={false}
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>
@@ -80,6 +81,7 @@ exports[`EditModal component render without error snapshot 1`] = `
Cancel
</ModalDialog.CloseButton>
<Button
disabled={false}
onClick={[MockFunction hooks.handleAdjustedGradeClick]}
variant="primary"
>

View File

@@ -12,6 +12,7 @@ import OverrideTable from './OverrideTable';
import ModalHeaders from './ModalHeaders';
import useEditModalData from './hooks';
import messages from './messages';
import useAdjustedGradeInputData from './OverrideTable/AdjustedGradeInput/hooks';
/**
* <EditModal />
@@ -30,6 +31,10 @@ export const EditModal = () => {
handleAdjustedGradeClick,
isOpen,
} = useEditModalData();
const {
value,
possibleGrade,
} = useAdjustedGradeInputData();
return (
<ModalDialog
@@ -57,7 +62,7 @@ export const EditModal = () => {
<ModalDialog.CloseButton variant="tertiary">
{formatMessage(messages.closeText)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={handleAdjustedGradeClick}>
<Button variant="primary" onClick={handleAdjustedGradeClick} disabled={value > possibleGrade}>
{formatMessage(messages.saveGrade)}
</Button>
</ActionRow>

View File

@@ -14,10 +14,12 @@ import OverrideTable from './OverrideTable';
import useEditModalData from './hooks';
import EditModal from '.';
import messages from './messages';
import useAdjustedGradeInputData from './OverrideTable/AdjustedGradeInput/hooks';
jest.mock('./hooks', () => jest.fn());
jest.mock('./ModalHeaders', () => 'ModalHeaders');
jest.mock('./OverrideTable', () => 'OverrideTable');
jest.mock('./OverrideTable/AdjustedGradeInput/hooks', () => jest.fn());
const hookProps = {
onClose: jest.fn().mockName('hooks.onClose'),
@@ -27,6 +29,12 @@ const hookProps = {
};
useEditModalData.mockReturnValue(hookProps);
const adjustedGradeProps = {
value: 50,
possibleGrade: 100,
};
useAdjustedGradeInputData.mockReturnValue(adjustedGradeProps);
let el;
describe('EditModal component', () => {
beforeEach(() => {
@@ -39,6 +47,7 @@ describe('EditModal component', () => {
});
it('initializes component hooks', () => {
expect(useEditModalData).toHaveBeenCalled();
expect(useAdjustedGradeInputData).toHaveBeenCalled();
});
});
describe('render', () => {
@@ -88,16 +97,18 @@ describe('EditModal component', () => {
expect(button.children[0].el).toEqual(formatMessage(messages.closeText));
expect(button.type).toEqual('ModalDialog.CloseButton');
});
test('adjusted grade button', () => {
test('adjusted grade button enabled', () => {
const button = footer[1].findByType(ActionRow)[0].children[1];
expect(button.children[0].el).toEqual(formatMessage(messages.saveGrade));
expect(button.type).toEqual('Button');
expect(button.props.onClick).toEqual(hookProps.handleAdjustedGradeClick);
expect(button.props.disabled).toEqual(false);
});
};
describe('without error', () => {
beforeEach(() => {
useEditModalData.mockReturnValueOnce({ ...hookProps, error: undefined });
useAdjustedGradeInputData.mockReturnValueOnce({ value: 50, possibleGrade: 100 });
el = shallow(<EditModal />);
});
test('snapshot', () => {
@@ -124,5 +135,15 @@ describe('EditModal component', () => {
});
testFooter();
});
describe('when the adjusted grade button is disabled', () => {
beforeEach(() => {
useAdjustedGradeInputData.mockReturnValueOnce({ value: 101, possibleGrade: 100 });
el = shallow(<EditModal />);
});
test('adjusted grade button is disabled', () => {
const button = el.instance.findByType(ActionRow)[0].children[1];
expect(button.props.disabled).toEqual(true);
});
});
});
});

View File

@@ -1,8 +1,8 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import React from 'react';
import ReactDOM from 'react-dom';
import {
APP_READY,
@@ -18,14 +18,7 @@ import App from './App';
subscribe(APP_READY, () => {
lightning();
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>,
);
ReactDOM.render(<App />, document.getElementById('root'));
});
initialize({

View File

@@ -1,4 +1,5 @@
import React, { StrictMode } from 'react';
import React from 'react';
import ReactDOM from 'react-dom';
import {
APP_READY,
@@ -11,21 +12,9 @@ import messages from './i18n';
import App from './App';
import '.';
// These need to be var not let so they get hoisted
// and can be used by jest.mock (which is also hoisted)
var mockRender; // eslint-disable-line no-var
var mockCreateRoot; // eslint-disable-line no-var
jest.mock('react-dom/client', () => {
mockRender = jest.fn();
mockCreateRoot = jest.fn(() => ({
render: mockRender,
}));
return ({
createRoot: mockCreateRoot,
});
});
jest.mock('react-dom', () => ({
render: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
APP_READY: 'app-is-ready-key',
initialize: jest.fn(),
@@ -47,11 +36,8 @@ describe('app registry', () => {
test('subscribe is called for APP_READY, linking App to root element', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
callArgs[1]();
expect(mockRender).toHaveBeenCalledWith(
<StrictMode>
<App />
</StrictMode>,
expect(callArgs[1]()).toEqual(
ReactDOM.render(<App />, document.getElementById('root')),
);
});
test('initialize is called with requireAuthenticatedUser, messages, and a config handler', () => {

View File

@@ -1,15 +1,12 @@
# Footer Slot
### Slot ID: `org.openedx.frontend.layout.footer.v1`
### Slot ID Aliases
* `footer_slot`
### Slot ID: `footer_slot`
## Description
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
## Example
@@ -26,7 +23,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
'org.openedx.frontend.layout.footer.v1': {
footer_slot: {
plugins: [
{
// Hide the default footer

View File

@@ -1,3 +1,3 @@
# `frontend-app-gradebook` Plugin Slots
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
* [`footer_slot`](./FooterSlot/)