├── .nvmrc
├── src
├── i18n
│ ├── index.js
│ ├── utils.js
│ └── utils.test.jsx
├── plugin-slots
│ ├── README.md
│ └── FooterSlot
│ │ ├── images
│ │ ├── custom_footer.png
│ │ └── default_footer.png
│ │ └── README.md
├── components
│ ├── BulkManagementHistoryView
│ │ ├── BulkManagementHistoryView.scss
│ │ ├── index.jsx
│ │ ├── messages.js
│ │ ├── index.test.jsx
│ │ ├── ResultsSummary.jsx
│ │ ├── ResultsSummary.test.jsx
│ │ ├── BulkManagementAlerts.jsx
│ │ ├── HistoryTable.jsx
│ │ └── BulkManagementAlerts.test.jsx
│ ├── GradebookFilters
│ │ ├── GradebookFilters.scss
│ │ ├── AssignmentTypeFilter
│ │ │ ├── hooks.js
│ │ │ ├── index.test.jsx
│ │ │ └── index.jsx
│ │ ├── hooks.js
│ │ ├── SelectGroup.jsx
│ │ ├── PercentGroup.jsx
│ │ ├── AssignmentFilter
│ │ │ ├── hooks.js
│ │ │ ├── index.jsx
│ │ │ └── index.test.jsx
│ │ ├── CourseGradeFilter
│ │ │ ├── hooks.js
│ │ │ └── index.jsx
│ │ ├── PercentGroup.test.jsx
│ │ ├── index.test.jsx
│ │ ├── SelectGroup.test.jsx
│ │ ├── AssignmentGradeFilter
│ │ │ ├── hooks.js
│ │ │ └── index.jsx
│ │ └── StudentGroupsFilter
│ │ │ ├── index.jsx
│ │ │ └── hooks.js
│ ├── GradesView
│ │ ├── FilteredUsersLabel
│ │ │ ├── messages.js
│ │ │ ├── index.jsx
│ │ │ └── index.test.jsx
│ │ ├── FilterMenuToggle
│ │ │ ├── messages.js
│ │ │ ├── index.jsx
│ │ │ └── index.test.jsx
│ │ ├── BulkManagementControls
│ │ │ ├── messages.js
│ │ │ ├── hooks.js
│ │ │ └── index.jsx
│ │ ├── PageButtons
│ │ │ ├── messages.js
│ │ │ ├── index.jsx
│ │ │ ├── hooks.js
│ │ │ └── index.test.jsx
│ │ ├── SearchControls
│ │ │ ├── messages.js
│ │ │ ├── index.jsx
│ │ │ ├── index.test.jsx
│ │ │ └── hooks.js
│ │ ├── ImportGradesButton
│ │ │ ├── messages.js
│ │ │ ├── index.test.jsx
│ │ │ ├── ref.test.jsx
│ │ │ ├── hooks.js
│ │ │ └── index.jsx
│ │ ├── SpinnerIcon.jsx
│ │ ├── InterventionsReport
│ │ │ ├── hooks.js
│ │ │ ├── messages.js
│ │ │ ├── index.jsx
│ │ │ └── hooks.test.js
│ │ ├── EditModal
│ │ │ ├── OverrideTable
│ │ │ │ ├── ReasonInput
│ │ │ │ │ ├── hooks.js
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ ├── ref.test.jsx
│ │ │ │ │ └── index.test.jsx
│ │ │ │ ├── AdjustedGradeInput
│ │ │ │ │ ├── hooks.js
│ │ │ │ │ ├── index.jsx
│ │ │ │ │ └── index.test.jsx
│ │ │ │ ├── messages.js
│ │ │ │ ├── hooks.js
│ │ │ │ ├── index.jsx
│ │ │ │ └── index.test.jsx
│ │ │ ├── HistoryHeader.jsx
│ │ │ ├── hooks.js
│ │ │ ├── ModalHeaders.test.jsx
│ │ │ ├── ModalHeaders.jsx
│ │ │ └── messages.js
│ │ ├── ImportSuccessToast
│ │ │ ├── messages.js
│ │ │ ├── index.jsx
│ │ │ └── hooks.js
│ │ ├── StatusAlerts
│ │ │ ├── index.jsx
│ │ │ ├── messages.js
│ │ │ ├── hooks.js
│ │ │ └── index.test.jsx
│ │ ├── FilterBadges
│ │ │ ├── index.jsx
│ │ │ ├── test.jsx
│ │ │ └── FilterBadge.jsx
│ │ ├── ScoreViewInput
│ │ │ ├── messages.js
│ │ │ └── index.jsx
│ │ ├── messages.js
│ │ ├── SpinnerIcon.test.jsx
│ │ ├── hooks.js
│ │ ├── GradebookTable
│ │ │ ├── index.jsx
│ │ │ ├── Fields.jsx
│ │ │ ├── Fields.test.jsx
│ │ │ ├── LabelReplacements.test.jsx
│ │ │ ├── messages.js
│ │ │ └── index.test.jsx
│ │ ├── index.test.jsx
│ │ └── index.jsx
│ ├── EdxHeader
│ │ ├── index.jsx
│ │ └── EdxHeader.test.jsx
│ ├── WithSidebar
│ │ └── WithSidebar.scss
│ └── GradebookHeader
│ │ ├── hooks.js
│ │ ├── messages.js
│ │ └── index.jsx
├── data
│ ├── constants
│ │ ├── errors.js
│ │ ├── grades.js
│ │ └── app.js
│ ├── services
│ │ ├── lms
│ │ │ ├── index.js
│ │ │ ├── messages.js
│ │ │ ├── constants.js
│ │ │ └── utils.js
│ │ └── segment
│ │ │ ├── utils.js
│ │ │ └── mapping.js
│ ├── selectors
│ │ ├── roles.js
│ │ ├── assignmentTypes.js
│ │ ├── assignmentTypes.test.js
│ │ ├── roles.test.js
│ │ ├── tracks.js
│ │ └── cohorts.js
│ ├── redux
│ │ ├── hooks
│ │ │ ├── utils.js
│ │ │ ├── index.js
│ │ │ ├── utils.test.js
│ │ │ ├── index.test.js
│ │ │ ├── thunkActions.js
│ │ │ └── actions.js
│ │ ├── transforms.js
│ │ └── transforms.test.js
│ ├── actions
│ │ ├── roles.js
│ │ ├── tracks.js
│ │ ├── config.js
│ │ ├── index.js
│ │ ├── config.test.js
│ │ ├── cohorts.js
│ │ ├── roles.test.js
│ │ ├── assignmentTypes.js
│ │ ├── cohorts.test.js
│ │ ├── tracks.test.js
│ │ ├── assignmentTypes.test.js
│ │ ├── utils.js
│ │ ├── utils.test.js
│ │ └── testUtils.js
│ ├── thunkActions
│ │ ├── index.js
│ │ ├── cohorts.js
│ │ ├── tracks.js
│ │ ├── assignmentTypes.js
│ │ ├── cohorts.test.js
│ │ ├── roles.js
│ │ ├── app.js
│ │ ├── tracks.test.js
│ │ └── testUtils.js
│ ├── reducers
│ │ ├── config.js
│ │ ├── index.js
│ │ ├── roles.js
│ │ ├── config.test.js
│ │ ├── tracks.js
│ │ ├── cohorts.js
│ │ ├── assignmentTypes.js
│ │ ├── roles.test.js
│ │ ├── tracks.test.js
│ │ └── cohorts.test.js
│ ├── utils.js
│ ├── utils.test.js
│ └── store.js
├── utils
│ ├── index.js
│ ├── keyStore.js
│ ├── formatDate.js
│ ├── StrictDict.js
│ ├── hoc.jsx
│ └── hoc.test.jsx
├── head
│ ├── messages.js
│ ├── Head.jsx
│ └── Head.test.jsx
├── App.scss
├── App.jsx
├── setupTest.js
├── index.jsx
└── App.test.jsx
├── .eslintignore
├── babel.config.js
├── .dockerignore
├── .npmignore
├── .github
├── dependabot.yml
├── workflows
│ ├── commitlint.yml
│ ├── lockfileversion-check.yml
│ ├── update-browserslist-db.yml
│ ├── self-assign-issue.yml
│ ├── add-depr-ticket-to-depr-board.yml
│ ├── add-remove-label-on-comment.yml
│ └── ci.yml
├── pull_request_template.md
└── renovate.json
├── webpack.dev.config.js
├── .gitignore
├── webpack.prod.config.js
├── public
└── index.html
├── jest.config.js
├── catalog-info.yaml
├── .eslintrc.js
├── .env
├── .env.development
└── Makefile
/.nvmrc:
--------------------------------------------------------------------------------
1 | 24
2 |
--------------------------------------------------------------------------------
/src/i18n/index.js:
--------------------------------------------------------------------------------
1 | export default [];
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | coverage/*
2 | dist/
3 | node_modules/
4 | src/postcss.config.js
5 | src/segment.js
6 | src/lightning.js
7 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | const { createConfig } = require('@openedx/frontend-build');
2 |
3 | module.exports = createConfig('babel');
4 |
--------------------------------------------------------------------------------
/src/plugin-slots/README.md:
--------------------------------------------------------------------------------
1 | # `frontend-app-gradebook` Plugin Slots
2 |
3 | * [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
4 |
--------------------------------------------------------------------------------
/src/plugin-slots/FooterSlot/images/custom_footer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openedx/frontend-app-gradebook/HEAD/src/plugin-slots/FooterSlot/images/custom_footer.png
--------------------------------------------------------------------------------
/src/plugin-slots/FooterSlot/images/default_footer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openedx/frontend-app-gradebook/HEAD/src/plugin-slots/FooterSlot/images/default_footer.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | README.md
4 | LICENSE
5 | .babelrc
6 | .eslintignore
7 | .eslintrc.json
8 | .gitignore
9 | .npmignore
10 | commitlint.config.js
11 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/BulkManagementHistoryView.scss:
--------------------------------------------------------------------------------
1 | .bulk-management-history-view {
2 | .help-text {
3 | margin-bottom: 40px;
4 | max-width: 70%;
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/data/constants/errors.js:
--------------------------------------------------------------------------------
1 | const GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG = 'Error retrieving grade override history.';
2 |
3 | export default GRADE_OVERRIDE_HISTORY_ERROR_DEFAULT_MSG;
4 |
--------------------------------------------------------------------------------
/src/data/services/lms/index.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import api from './api';
3 | import urls from './urls';
4 |
5 | export default StrictDict({
6 | api,
7 | urls,
8 | });
9 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .eslintignore
2 | .eslintrc.json
3 | .gitignore
4 | docker-compose.yml
5 | Dockerfile
6 | Makefile
7 | npm-debug.log
8 |
9 | config
10 | coverage
11 | node_modules
12 | public
13 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/GradebookFilters.scss:
--------------------------------------------------------------------------------
1 | .filter-sidebar-header {
2 | display: flex;
3 | align-items: flex-start;
4 | justify-content: space-between;
5 | padding: 15px;
6 | }
7 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | # Adding new check for github-actions
4 | - package-ecosystem: "github-actions"
5 | directory: "/"
6 | schedule:
7 | interval: "weekly"
8 |
--------------------------------------------------------------------------------
/src/data/selectors/roles.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | const canUserViewGradebook = ({ roles }) => !!roles.canUserViewGradebook;
4 |
5 | export default StrictDict({
6 | canUserViewGradebook,
7 | });
8 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | export { default as StrictDict } from './StrictDict';
3 | export { default as keyStore } from './keyStore';
4 | export { default as formatDateForDisplay } from './formatDate';
5 |
--------------------------------------------------------------------------------
/src/utils/keyStore.js:
--------------------------------------------------------------------------------
1 | import StrictDict from './StrictDict';
2 |
3 | const keyStore = (collection) => StrictDict(
4 | Object.keys(collection).reduce(
5 | (obj, key) => ({ ...obj, [key]: key }),
6 | {},
7 | ),
8 | );
9 |
10 | export default keyStore;
11 |
--------------------------------------------------------------------------------
/.github/workflows/commitlint.yml:
--------------------------------------------------------------------------------
1 | # Run commitlint on the commit messages in a pull request.
2 |
3 | name: Lint Commit Messages
4 |
5 | on:
6 | - pull_request
7 |
8 | jobs:
9 | commitlint:
10 | uses: openedx/.github/.github/workflows/commitlint.yml@master
11 |
--------------------------------------------------------------------------------
/.github/workflows/lockfileversion-check.yml:
--------------------------------------------------------------------------------
1 | #check package-lock file version
2 |
3 | name: Lockfile Version check
4 |
5 | on:
6 | push:
7 | branches:
8 | - master
9 | pull_request:
10 |
11 | jobs:
12 | version-check:
13 | uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
14 |
--------------------------------------------------------------------------------
/src/data/services/lms/messages.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | export default StrictDict({
4 | errors: {
5 | missingAssignment: (
6 | 'Gradebook LMS API requires assignment to be set to filter by min/max assig. grade'
7 | ),
8 | unhandledResponse: 'unhandled response error',
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/src/head/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | 'gradebook.page.title': {
5 | id: 'gradebook.page.title',
6 | defaultMessage: 'Gradebook | {siteName}',
7 | description: 'Title tag',
8 | },
9 | });
10 |
11 | export default messages;
12 |
--------------------------------------------------------------------------------
/src/data/selectors/assignmentTypes.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | const areGradesFrozen = ({ assignmentTypes }) => assignmentTypes.areGradesFrozen;
4 | const allAssignmentTypes = ({ assignmentTypes }) => assignmentTypes.results;
5 |
6 | export default StrictDict({
7 | areGradesFrozen,
8 | allAssignmentTypes,
9 | });
10 |
--------------------------------------------------------------------------------
/src/data/redux/hooks/utils.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | // useDispatch hook wouldn't work here because it is out of scope of the component
3 | import store from 'data/store';
4 |
5 | export const actionHook = (action) => () => (...args) => store.dispatch(action(...args));
6 |
7 | export default StrictDict({
8 | actionHook,
9 | });
10 |
--------------------------------------------------------------------------------
/.github/workflows/update-browserslist-db.yml:
--------------------------------------------------------------------------------
1 | name: Update Browserslist DB
2 | on:
3 | schedule:
4 | - cron: '0 0 * * 1'
5 | workflow_dispatch:
6 |
7 | jobs:
8 | update-browserslist:
9 | uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
10 |
11 | secrets:
12 | requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
13 |
--------------------------------------------------------------------------------
/src/data/redux/transforms.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import selectors from 'data/selectors';
3 |
4 | export const grades = StrictDict({
5 | subsectionGrade: ({ gradeFormat, subsection }) => () => (
6 | selectors.grades.subsectionGrade[gradeFormat](subsection)
7 | ),
8 | roundGrade: selectors.grades.roundGrade,
9 | });
10 |
11 | export default StrictDict({
12 | grades,
13 | });
14 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilteredUsersLabel/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | visibilityLabel: {
5 | id: 'gradebook.GradesTab.usersVisibilityLabel',
6 | defaultMessage: 'Showing {filteredUsers} of {totalUsers} total learners',
7 | description: 'Users visibility label',
8 | },
9 | });
10 |
11 | export default messages;
12 |
--------------------------------------------------------------------------------
/src/data/actions/roles.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import { createActionFactory } from './utils';
3 |
4 | export const dataKey = 'roles';
5 | const createAction = createActionFactory(dataKey);
6 |
7 | const fetching = {
8 | error: createAction('fetching/error'),
9 | received: createAction('fetching/received'),
10 | };
11 |
12 | export default StrictDict({
13 | fetching: StrictDict(fetching),
14 | });
15 |
--------------------------------------------------------------------------------
/webpack.dev.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const { createConfig } = require('@openedx/frontend-build');
3 |
4 | const config = createConfig('webpack-dev');
5 |
6 | config.resolve.modules = [
7 | path.resolve(__dirname, './src'),
8 | 'node_modules',
9 | ];
10 |
11 | config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
12 |
13 | module.exports = config;
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .eslintcache
3 | node_modules
4 | npm-debug.log
5 | coverage
6 |
7 | dist/
8 | src/i18n/transifex_input.json
9 | temp/babel-plugin-react-intl
10 |
11 | ### pyenv ###
12 | .python-version
13 |
14 | ### Emacs ###
15 | *~
16 | *.swo
17 | *.swp
18 |
19 | ### Development environments ###
20 | .idea
21 | .vscode
22 |
23 | ### transifex ###
24 | src/i18n/transifex_input.json
25 | temp
26 |
27 | src/i18n/messages/
--------------------------------------------------------------------------------
/src/data/thunkActions/index.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import app from './app';
3 | import assignmentTypes from './assignmentTypes';
4 | import cohorts from './cohorts';
5 | import grades from './grades';
6 | import roles from './roles';
7 | import tracks from './tracks';
8 |
9 | export default StrictDict({
10 | app,
11 | assignmentTypes,
12 | cohorts,
13 | grades,
14 | roles,
15 | tracks,
16 | });
17 |
--------------------------------------------------------------------------------
/webpack.prod.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const { createConfig } = require('@openedx/frontend-build');
3 |
4 | const config = createConfig('webpack-prod');
5 |
6 | config.resolve.modules = [
7 | path.resolve(__dirname, './src'),
8 | 'node_modules',
9 | ];
10 |
11 | config.module.rules[0].exclude = /node_modules\/(?!(query-string|split-on-first|strict-uri-encode|@edx))/;
12 |
13 | module.exports = config;
14 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Gradebook | <%= process.env.SITE_NAME %>
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/data/redux/hooks/index.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | import selectorHooks from './selectors';
4 | import actionHooks from './actions';
5 | import thunkActionHooks from './thunkActions';
6 |
7 | export const selectors = selectorHooks;
8 | export const actions = actionHooks;
9 | export const thunkActions = thunkActionHooks;
10 |
11 | export default StrictDict({
12 | selectors,
13 | actions,
14 | thunkActions,
15 | });
16 |
--------------------------------------------------------------------------------
/.github/workflows/self-assign-issue.yml:
--------------------------------------------------------------------------------
1 | # This workflow runs when a comment is made on the ticket
2 | # If the comment starts with "assign me" it assigns the author to the
3 | # ticket (case insensitive)
4 |
5 | name: Assign comment author to ticket if they say "assign me"
6 | on:
7 | issue_comment:
8 | types: [created]
9 |
10 | jobs:
11 | self_assign_by_comment:
12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
13 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilterMenuToggle/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | editFilters: {
5 | id: 'gradebook.GradesView.editFilterLabel',
6 | defaultMessage: 'Edit Filters',
7 | description: 'A labeled button in the Grades tab that opens/closes the Filters tab, allowing the grades to be filtered',
8 | },
9 | });
10 |
11 | export default messages;
12 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const { createConfig } = require('@openedx/frontend-build');
2 |
3 | module.exports = createConfig('jest', {
4 | setupFilesAfterEnv: [
5 | '/src/setupTest.js',
6 | ],
7 | modulePaths: ['/src/'],
8 | coveragePathIgnorePatterns: [
9 | 'src/segment.js',
10 | 'src/postcss.config.js',
11 | 'testUtils', // don't unit test jest mocking tools
12 | 'testUtilsExtra', // don't unit test jest mocking tools
13 | ],
14 | });
15 |
--------------------------------------------------------------------------------
/src/data/actions/tracks.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import { createActionFactory } from './utils';
3 |
4 | export const dataKey = 'tracks';
5 | const createAction = createActionFactory(dataKey);
6 |
7 | const fetching = {
8 | started: createAction('fetching/started'),
9 | error: createAction('fetching/error'),
10 | received: createAction('fetching/received'),
11 | };
12 |
13 | export default StrictDict({
14 | fetching: StrictDict(fetching),
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/GradesView/BulkManagementControls/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | downloadGradesBtn: {
5 | id: 'gradebook.GradesView.BulkManagementControls.bulkManagementLabel',
6 | defaultMessage: 'Download Grades',
7 | description: 'A labeled button that allows an admin user to download course grades all at once (in bulk).',
8 | },
9 | });
10 |
11 | export default messages;
12 |
--------------------------------------------------------------------------------
/src/data/reducers/config.js:
--------------------------------------------------------------------------------
1 | import actions from '../actions/config';
2 |
3 | const initialState = {};
4 |
5 | const reducer = (state = initialState, action = {}) => {
6 | switch (action.type) {
7 | case actions.gotBulkManagementConfig.toString():
8 | return {
9 | ...state,
10 | bulkManagementAvailable: action.payload,
11 | };
12 | default:
13 | return state;
14 | }
15 | };
16 |
17 | export { initialState };
18 | export default reducer;
19 |
--------------------------------------------------------------------------------
/src/data/actions/config.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import { createActionFactory } from './utils';
3 |
4 | export const dataKey = 'config';
5 | const createAction = createActionFactory(dataKey);
6 |
7 | /**
8 | * gotBulkManagemmentConfig(bulkManagementAvailable)
9 | * @param {bool} bulkManagementAvailable - is bulk management available?
10 | */
11 | const gotBulkManagementConfig = createAction('gotBulkManagement');
12 |
13 | export default StrictDict({
14 | gotBulkManagementConfig,
15 | });
16 |
--------------------------------------------------------------------------------
/src/data/actions/index.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | import app from './app';
4 | import assignmentTypes from './assignmentTypes';
5 | import cohorts from './cohorts';
6 | import config from './config';
7 | import filters from './filters';
8 | import grades from './grades';
9 | import roles from './roles';
10 | import tracks from './tracks';
11 |
12 | export default StrictDict({
13 | app,
14 | assignmentTypes,
15 | cohorts,
16 | config,
17 | filters,
18 | grades,
19 | roles,
20 | tracks,
21 | });
22 |
--------------------------------------------------------------------------------
/src/data/actions/config.test.js:
--------------------------------------------------------------------------------
1 | import actions, { dataKey } from './config';
2 | import { testAction, testActionTypes } from './testUtils';
3 |
4 | describe('actions.cohorts', () => {
5 | describe('action types', () => {
6 | const actionTypes = [
7 | actions.gotBulkManagementConfig,
8 | ].map(action => action.toString());
9 | testActionTypes(actionTypes, dataKey);
10 | });
11 | describe('actions provided', () => {
12 | test('gotBulkManagementConfig action', () => testAction(actions.gotBulkManagementConfig));
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/GradesView/PageButtons/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | prevPage: {
5 | id: 'gradebook.GradesView.PageButtons.prevPage',
6 | defaultMessage: 'Previous Page',
7 | description: 'Grades tab Previous Page button text',
8 | },
9 | nextPage: {
10 | id: 'gradebook.GradesView.PageButtons.nextPage',
11 | defaultMessage: 'Next Page',
12 | description: 'Grades tab Next Page button text',
13 | },
14 | });
15 |
16 | export default messages;
17 |
--------------------------------------------------------------------------------
/src/utils/formatDate.js:
--------------------------------------------------------------------------------
1 | export const options = {
2 | year: 'numeric',
3 | month: 'long',
4 | day: 'numeric',
5 | timeZone: 'UTC',
6 | };
7 | export const timeOptions = {
8 | hour: '2-digit',
9 | minute: '2-digit',
10 | timeZone: 'UTC',
11 | timeZoneName: 'short',
12 | };
13 |
14 | const formatDateForDisplay = (inputDate) => {
15 | const date = inputDate.toLocaleDateString('en-US', options);
16 | const time = inputDate.toLocaleTimeString('en-US', timeOptions);
17 | return `${date} at ${time}`;
18 | };
19 |
20 | export default formatDateForDisplay;
21 |
--------------------------------------------------------------------------------
/catalog-info.yaml:
--------------------------------------------------------------------------------
1 | # This file records information about this repo. Its use is described in OEP-55:
2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
3 | apiVersion: backstage.io/v1alpha1
4 | kind: Component
5 | metadata:
6 | name: "frontend-app-gradebook"
7 | description: "The frontend (MFE) for Open edX Gradebook"
8 | annotations:
9 | openedx.org/arch-interest-groups: ""
10 | openedx.org/release: "master"
11 | spec:
12 | owner: user:farhaanbukhsh
13 | type: 'website'
14 | lifecycle: 'experimental'
15 |
--------------------------------------------------------------------------------
/src/data/actions/cohorts.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import { createActionFactory } from './utils';
3 |
4 | export const dataKey = 'cohorts';
5 | const createAction = createActionFactory(dataKey);
6 |
7 | const fetching = {
8 | started: createAction('fetching/started'),
9 | error: createAction('fetching/error'),
10 | /**
11 | * fetching.received(results)
12 | * @param {object[]} results - cohorts fetch results
13 | */
14 | received: createAction('fetching/received'),
15 | };
16 |
17 | export default StrictDict({
18 | fetching: StrictDict(fetching),
19 | });
20 |
--------------------------------------------------------------------------------
/src/data/redux/hooks/utils.test.js:
--------------------------------------------------------------------------------
1 | import store from 'data/store';
2 | import { actionHook } from './utils';
3 |
4 | jest.mock('data/store', () => ({
5 | dispatch: jest.fn(),
6 | }));
7 |
8 | describe('actionHook', () => {
9 | it('returns a function that dispatches the action', () => {
10 | const action = jest.fn();
11 | const useHook = actionHook(action);
12 | const args = [1, 2, 3];
13 | const hook = useHook();
14 | hook(...args);
15 | expect(action).toHaveBeenCalledWith(...args);
16 | expect(store.dispatch).toHaveBeenCalledWith(action(...args));
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/data/services/lms/constants.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | export const pageSize = 25;
4 | export const historyRecordLimit = 5;
5 |
6 | export const paramKeys = StrictDict({
7 | cohortId: 'cohort_id',
8 | pageSize: 'page_size',
9 | userContains: 'user_contains',
10 | enrollmentMode: 'enrollment_mode',
11 | assignment: 'assignment',
12 | assignmentGradeMin: 'assignment_grade_min',
13 | assignmentGradeMax: 'assignment_grade_max',
14 | courseGradeMin: 'course_grade_min',
15 | courseGradeMax: 'course_grade_max',
16 | excludedCourseRoles: 'excluded_course_roles',
17 | });
18 |
--------------------------------------------------------------------------------
/src/data/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import app from './app';
4 | import assignmentTypes from './assignmentTypes';
5 | import cohorts from './cohorts';
6 | import config from './config';
7 | import filters from './filters';
8 | import grades from './grades';
9 | import roles from './roles';
10 | import tracks from './tracks';
11 |
12 | /* istanbul ignore next */
13 | const rootReducer = combineReducers({
14 | app,
15 | assignmentTypes,
16 | cohorts,
17 | config,
18 | filters,
19 | grades,
20 | roles,
21 | tracks,
22 | });
23 |
24 | export default rootReducer;
25 |
--------------------------------------------------------------------------------
/src/data/redux/hooks/index.test.js:
--------------------------------------------------------------------------------
1 | import hooks from '.';
2 |
3 | import selectors from './selectors';
4 | import actions from './actions';
5 | import thunkActions from './thunkActions';
6 |
7 | jest.mock('./selectors', () => jest.fn());
8 | jest.mock('./actions', () => jest.fn());
9 | jest.mock('./thunkActions', () => jest.fn());
10 |
11 | describe('redux hooks', () => {
12 | it('exports selectors, actions, and thunkActions', () => {
13 | expect(hooks.actions).toEqual(actions);
14 | expect(hooks.selectors).toEqual(selectors);
15 | expect(hooks.thunkActions).toEqual(thunkActions);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/components/GradesView/SearchControls/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | label: {
5 | id: 'gradebook.GradesView.search.label',
6 | defaultMessage: 'Search for a learner',
7 | description: 'Text prompting a user to use this functionality to search for a learner',
8 | },
9 | hint: {
10 | id: 'gradebook.GradesView.search.hint',
11 | defaultMessage: 'Search by username, email, or student key',
12 | description: 'A hint explaining the ways a user can search',
13 | },
14 | });
15 |
16 | export default messages;
17 |
--------------------------------------------------------------------------------
/src/data/services/segment/utils.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | import { trackEvent } from '@redux-beacon/segment';
3 | import { courseId, trackingCategory as category } from './constants';
4 |
5 | export const handleEvent = (name, options = {}) => trackEvent(
6 | (event = {}) => {
7 | const { payload } = event;
8 | const { propsFn, extrasFn } = options;
9 | return {
10 | name,
11 | ...(extrasFn && extrasFn(payload)),
12 | properties: {
13 | category,
14 | label: courseId,
15 | ...(propsFn && propsFn(payload)),
16 | },
17 | };
18 | },
19 | );
20 |
--------------------------------------------------------------------------------
/src/data/thunkActions/cohorts.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | import { StrictDict } from 'utils';
3 | import actions from 'data/actions';
4 | import lms from 'data/services/lms';
5 |
6 | export const fetchCohorts = () => (
7 | (dispatch) => {
8 | dispatch(actions.cohorts.fetching.started());
9 | return lms.api.fetch.cohorts()
10 | .then(({ data }) => {
11 | dispatch(actions.cohorts.fetching.received(data));
12 | })
13 | .catch(() => {
14 | dispatch(actions.cohorts.fetching.error());
15 | });
16 | }
17 | );
18 |
19 | export default StrictDict({ fetchCohorts });
20 |
--------------------------------------------------------------------------------
/src/data/reducers/roles.js:
--------------------------------------------------------------------------------
1 | import actions from '../actions/roles';
2 |
3 | const initialState = {
4 | canUserViewGradebook: true,
5 | };
6 |
7 | const roles = (state = initialState, action = {}) => {
8 | switch (action.type) {
9 | case actions.fetching.received.toString():
10 | return {
11 | ...state,
12 | canUserViewGradebook: action.payload,
13 | };
14 | case actions.fetching.error.toString():
15 | return {
16 | ...state,
17 | canUserViewGradebook: false,
18 | };
19 | default:
20 | return state;
21 | }
22 | };
23 |
24 | export { initialState };
25 | export default roles;
26 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportGradesButton/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | csvUploadLabel: {
5 | id: 'gradebook.BulkManagementHistoryView.csvUploadLabel',
6 | defaultMessage: 'Upload Grade CSV',
7 | description: 'A labeled button to upload a CSV containing course grades.',
8 | },
9 | importGradesBtnText: {
10 | id: 'gradebook.GradesView.importGradesBtnText',
11 | defaultMessage: 'Import Grades',
12 | description: 'A labeled button to import grades in the BulkManagement Tab File Upload Form',
13 | },
14 | });
15 |
16 | export default messages;
17 |
--------------------------------------------------------------------------------
/src/data/thunkActions/tracks.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | import { StrictDict } from 'utils';
3 |
4 | import lms from 'data/services/lms';
5 | import actions from 'data/actions';
6 |
7 | export const fetchTracks = () => (
8 | (dispatch) => {
9 | dispatch(actions.tracks.fetching.started());
10 | return lms.api.fetch.tracks()
11 | .then(({ data }) => {
12 | dispatch(actions.tracks.fetching.received(data.course_modes));
13 | })
14 | .catch(() => {
15 | dispatch(actions.tracks.fetching.error());
16 | });
17 | }
18 | );
19 |
20 | export default StrictDict({
21 | fetchTracks,
22 | });
23 |
--------------------------------------------------------------------------------
/src/utils/StrictDict.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const strictGet = (target, name) => {
3 | if (name === Symbol.toStringTag) {
4 | return target;
5 | }
6 |
7 | if (name === '$$typeof') {
8 | return typeof target;
9 | }
10 |
11 | if (name in target || name === '_reactFragment') {
12 | return target[name];
13 | }
14 |
15 | console.log(name.toString());
16 | console.error({ target, name });
17 | const e = Error(`invalid property "${name.toString()}"`);
18 | console.error(e.stack);
19 | return undefined;
20 | };
21 |
22 | const StrictDict = (dict) => new Proxy(dict, { get: strictGet });
23 |
24 | export default StrictDict;
25 |
--------------------------------------------------------------------------------
/src/components/GradesView/SpinnerIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Icon } from '@openedx/paragon';
4 |
5 | import { selectors } from 'data/redux/hooks';
6 |
7 | /**
8 | *
9 | * Simmple redux-connected icon component that shows a spinner overlay only if
10 | * redux state says it should.
11 | */
12 | export const SpinnerIcon = () => {
13 | const show = selectors.root.useShouldShowSpinner();
14 | return show && (
15 |
16 |
17 |
18 | );
19 | };
20 | SpinnerIcon.propTypes = {};
21 |
22 | export default SpinnerIcon;
23 |
--------------------------------------------------------------------------------
/src/data/utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple selector factory.
3 | * Takes a list of string keys, and returns a simple slector for each.
4 | *
5 | * @function
6 | * @param {Object|string[]} keys - If passed as object, Object.keys(keys) is used.
7 | * @return {Object} - object of `{[key]: ({key}) => key}`
8 | */
9 | const simpleSelectorFactory = (transformer, keys) => {
10 | const selKeys = Array.isArray(keys) ? keys : Object.keys(keys);
11 | return selKeys.reduce(
12 | (obj, key) => ({
13 | ...obj, [key]: (state) => transformer(state)[key],
14 | }),
15 | { root: (state) => transformer(state) },
16 | );
17 | };
18 |
19 | export default simpleSelectorFactory;
20 |
--------------------------------------------------------------------------------
/src/head/Head.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Helmet } from 'react-helmet';
3 | import { useIntl } from '@edx/frontend-platform/i18n';
4 | import { getConfig } from '@edx/frontend-platform';
5 |
6 | import messages from './messages';
7 |
8 | const Head = () => {
9 | const { formatMessage } = useIntl();
10 | return (
11 |
12 |
13 | {formatMessage(messages['gradebook.page.title'], { siteName: getConfig().SITE_NAME })}
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | Head.propTypes = {
21 | };
22 |
23 | export default Head;
24 |
--------------------------------------------------------------------------------
/src/components/GradesView/InterventionsReport/hooks.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors } from 'data/redux/hooks';
2 |
3 | const useInterventionsReportData = () => {
4 | const interventionExportUrl = selectors.root.useInterventionExportUrl();
5 | const showBulkManagement = selectors.root.useShowBulkManagement();
6 | const downloadInterventionReport = actions.grades.useDownloadInterventionReport();
7 |
8 | const handleClick = () => {
9 | downloadInterventionReport();
10 | window.location.assign(interventionExportUrl);
11 | };
12 |
13 | return {
14 | show: showBulkManagement,
15 | handleClick,
16 | };
17 | };
18 |
19 | export default useInterventionsReportData;
20 |
--------------------------------------------------------------------------------
/src/components/GradesView/BulkManagementControls/hooks.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors } from 'data/redux/hooks';
2 |
3 | export const useBulkManagementControlsData = () => {
4 | const gradeExportUrl = selectors.root.useGradeExportUrl();
5 | const showBulkManagement = selectors.root.useShowBulkManagement();
6 | const downloadBulkGradesReport = actions.grades.useDownloadBulkGradesReport();
7 |
8 | const handleClickExportGrades = () => {
9 | downloadBulkGradesReport();
10 | window.location.assign(gradeExportUrl);
11 | };
12 |
13 | return {
14 | show: showBulkManagement,
15 | handleClickExportGrades,
16 | };
17 | };
18 | export default useBulkManagementControlsData;
19 |
--------------------------------------------------------------------------------
/src/data/actions/roles.test.js:
--------------------------------------------------------------------------------
1 | import actions, { dataKey } from './roles';
2 | import { testAction, testActionTypes } from './testUtils';
3 |
4 | describe('actions.roles', () => {
5 | describe('action types', () => {
6 | const actionTypes = [
7 | actions.fetching.error,
8 | actions.fetching.received,
9 | ].map(action => action.toString());
10 | testActionTypes(actionTypes, dataKey);
11 | });
12 | describe('actions provided', () => {
13 | describe('fecthing actions', () => {
14 | test('error action', () => testAction(actions.fetching.error));
15 | test('received action', () => testAction(actions.fetching.received));
16 | });
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/ReasonInput/hooks.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { actions, selectors } from 'data/redux/hooks';
4 |
5 | const useReasonInputData = () => {
6 | const ref = React.useRef();
7 | const { reasonForChange } = selectors.app.useModalData();
8 | const setModalState = actions.app.useSetModalState();
9 |
10 | React.useEffect(() => {
11 | ref.current.focus();
12 | }, [ref]);
13 |
14 | const onChange = (event) => {
15 | setModalState({ reasonForChange: event.target.value });
16 | };
17 |
18 | return {
19 | value: reasonForChange,
20 | onChange,
21 | ref,
22 | };
23 | };
24 |
25 | export default useReasonInputData;
26 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportSuccessToast/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | description: {
5 | id: 'gradebook.GradesView.ImportSuccessToast.description',
6 | defaultMessage: 'Import Successful! Grades will be updated momentarily.',
7 | description: 'A message congratulating a successful Import of grades',
8 | },
9 | showHistoryViewBtn: {
10 | id: 'gradebook.GradesView.ImportSuccessToast.showHistoryViewBtn',
11 | defaultMessage: 'View Activity Log',
12 | description: 'The text on a button that loads a view of the Bulk Management Activity Log',
13 | },
14 | });
15 |
16 | export default messages;
17 |
--------------------------------------------------------------------------------
/src/data/constants/grades.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | const EMAIL_HEADING = 'Email';
4 | const TOTAL_COURSE_GRADE_HEADING = 'Total Grade (%)';
5 | const USERNAME_HEADING = 'Username';
6 | const FULL_NAME_HEADING = 'Full Name';
7 |
8 | const GradeFormats = StrictDict({
9 | absolute: 'absolute',
10 | percent: 'percent',
11 | });
12 |
13 | const Headings = StrictDict({
14 | email: EMAIL_HEADING,
15 | totalGrade: TOTAL_COURSE_GRADE_HEADING,
16 | username: USERNAME_HEADING,
17 | fullName: FULL_NAME_HEADING,
18 | });
19 |
20 | export {
21 | EMAIL_HEADING,
22 | TOTAL_COURSE_GRADE_HEADING,
23 | USERNAME_HEADING,
24 | FULL_NAME_HEADING,
25 | GradeFormats,
26 | Headings,
27 | };
28 |
--------------------------------------------------------------------------------
/src/head/Head.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mockConfigs } from 'setupTest';
3 | import { initializeMocks, render, waitFor } from 'testUtilsExtra';
4 |
5 | import Head from './Head';
6 |
7 | describe('Head', () => {
8 | it('should match render title tag and favicon with the site configuration values', async () => {
9 | initializeMocks();
10 | render();
11 |
12 | await waitFor(() => {
13 | expect(document.title).toBe(`Gradebook | ${mockConfigs.SITE_NAME}`);
14 | });
15 |
16 | const favicon = document.querySelector('link[rel="shortcut icon"]');
17 | expect(favicon).toBeInTheDocument();
18 | expect(favicon.href).toBe(mockConfigs.FAVICON_URL);
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/EdxHeader/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Hyperlink } from '@openedx/paragon';
3 | import { getConfig } from '@edx/frontend-platform';
4 |
5 | /**
6 | *
7 | * Gradebook MFE app header.
8 | * Displays edx logo, linked to lms dashboard
9 | */
10 | const EdxHeader = () => (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 |
21 | export default EdxHeader;
22 |
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | // frontend-app-*/src/index.scss
2 | @use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints;
3 |
4 | $fa-font-path: "~font-awesome/fonts";
5 | @import "~font-awesome/scss/font-awesome";
6 |
7 | $input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get upgrade to paragon 4.0.0 to work
8 |
9 | @import "~@edx/frontend-component-header/dist/index";
10 | @import "~@edx/frontend-component-footer/dist/_footer";
11 |
12 | @import "./components/GradesView/GradesView";
13 | @import "./components/BulkManagementHistoryView/BulkManagementHistoryView";
14 | @import "./components/WithSidebar/WithSidebar";
15 | @import "./components/GradebookFilters/GradebookFilters";
16 |
--------------------------------------------------------------------------------
/.github/workflows/add-depr-ticket-to-depr-board.yml:
--------------------------------------------------------------------------------
1 | # Run the workflow that adds new tickets that are either:
2 | # - labelled "DEPR"
3 | # - title starts with "[DEPR]"
4 | # - body starts with "Proposal Date" (this is the first template field)
5 | # to the org-wide DEPR project board
6 |
7 | name: Add newly created DEPR issues to the DEPR project board
8 |
9 | on:
10 | issues:
11 | types: [opened]
12 |
13 | jobs:
14 | routeissue:
15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
16 | secrets:
17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
20 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportSuccessToast/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Toast } from '@openedx/paragon';
4 |
5 | import useImportSuccessToastData from './hooks';
6 |
7 | /**
8 | *
9 | * Toast component triggered by successful grade upload.
10 | * Provides a link to view the Bulk Management History tab.
11 | */
12 | export const ImportSuccessToast = () => {
13 | const {
14 | action,
15 | onClose,
16 | show,
17 | description,
18 | } = useImportSuccessToastData();
19 | return (
20 |
21 | {description}
22 |
23 | );
24 | };
25 |
26 | ImportSuccessToast.propTypes = {};
27 |
28 | export default ImportSuccessToast;
29 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/ReasonInput/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Form } from '@openedx/paragon';
4 |
5 | import useReasonInputData from './hooks';
6 |
7 | export const controlTestId = 'reason-input-control';
8 |
9 | /**
10 | *
11 | * Input control for the "reason for change" field in the Edit modal.
12 | */
13 | export const ReasonInput = () => {
14 | const { ref, value, onChange } = useReasonInputData();
15 | return (
16 |
22 | );
23 | };
24 |
25 | ReasonInput.propTypes = {};
26 |
27 | export default ReasonInput;
28 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportGradesButton/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {
4 | render, screen, initializeMocks,
5 | } from 'testUtilsExtra';
6 |
7 | import ImportGradesButton from '.';
8 |
9 | initializeMocks();
10 |
11 | describe('ImportGradesButton component', () => {
12 | beforeEach(() => {
13 | jest.clearAllMocks();
14 | render();
15 | });
16 |
17 | describe('render', () => {
18 | test('Form', async () => {
19 | const uploader = screen.getByTestId('file-control');
20 | expect(uploader).toBeInTheDocument();
21 | });
22 | test('import button', () => {
23 | expect(screen.getByRole('button', { name: 'Import Grades' })).toBeInTheDocument();
24 | });
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/AdjustedGradeInput/hooks.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors } from 'data/redux/hooks';
2 | import { getLocalizedSlash } from 'i18n/utils';
3 |
4 | const useAdjustedGradeInputData = () => {
5 | const possibleGrade = selectors.root.useEditModalPossibleGrade();
6 | const value = selectors.app.useModalData().adjustedGradeValue;
7 | const setModalState = actions.app.useSetModalState();
8 | const hintText = possibleGrade && ` ${getLocalizedSlash()} ${possibleGrade}`;
9 |
10 | const onChange = ({ target }) => {
11 | setModalState({ adjustedGradeValue: target.value });
12 | };
13 |
14 | return {
15 | value,
16 | onChange,
17 | hintText,
18 | };
19 | };
20 |
21 | export default useAdjustedGradeInputData;
22 |
--------------------------------------------------------------------------------
/src/data/reducers/config.test.js:
--------------------------------------------------------------------------------
1 | import config, { initialState } from './config';
2 | import actions from '../actions/config';
3 |
4 | const testingState = {
5 | abitraryField: 'abitrary',
6 | };
7 |
8 | describe('config reducer', () => {
9 | it('has initial state', () => {
10 | expect(
11 | config(undefined, {}),
12 | ).toEqual(initialState);
13 | });
14 |
15 | it('loads bulkManagementAvailable from payload', () => {
16 | const expectedBulkManagementAvailable = true;
17 | const expected = {
18 | ...testingState,
19 | bulkManagementAvailable: expectedBulkManagementAvailable,
20 | };
21 | expect(
22 | config(testingState, actions.gotBulkManagementConfig(expectedBulkManagementAvailable)),
23 | ).toEqual(expected);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/data/selectors/assignmentTypes.test.js:
--------------------------------------------------------------------------------
1 | import selectors from './assignmentTypes';
2 |
3 | describe('assignmentType selectors', () => {
4 | describe('areGradesFrozen', () => {
5 | it('selects areGradesFrozen from state', () => {
6 | const testValue = 'THX 1138';
7 | expect(
8 | selectors.areGradesFrozen({ assignmentTypes: { areGradesFrozen: testValue } }),
9 | ).toEqual(testValue);
10 | });
11 | });
12 |
13 | describe('allAssignmentTypes', () => {
14 | it('returns assignment types', () => {
15 | const testAssignmentTypes = ['assignment', 'labs'];
16 | expect(
17 | selectors.allAssignmentTypes({ assignmentTypes: { results: testAssignmentTypes } }),
18 | ).toEqual(testAssignmentTypes);
19 | });
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/src/data/actions/assignmentTypes.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import { createActionFactory } from './utils';
3 |
4 | export const dataKey = 'assignmentTypes';
5 | const createAction = createActionFactory(dataKey);
6 |
7 | const fetching = {
8 | error: createAction('fetching/error'),
9 | started: createAction('fetching/started'),
10 | /**
11 | * fetching.received(results)
12 | * @param {object[]} results - assignmentType fetch results
13 | */
14 | received: createAction('fetching/received'),
15 | };
16 | /**
17 | * gotGradesFrozen(areGradesFrozen)
18 | * @param {bool} are grades frozen?
19 | */
20 | const gotGradesFrozen = createAction('gotGradesFrozen');
21 |
22 | export default StrictDict({
23 | fetching: StrictDict(fetching),
24 | gotGradesFrozen,
25 | });
26 |
--------------------------------------------------------------------------------
/src/data/actions/cohorts.test.js:
--------------------------------------------------------------------------------
1 | import actions, { dataKey } from './cohorts';
2 | import { testAction, testActionTypes } from './testUtils';
3 |
4 | describe('actions.cohorts', () => {
5 | describe('action types', () => {
6 | const actionTypes = [
7 | actions.fetching.error,
8 | actions.fetching.started,
9 | actions.fetching.received,
10 | ].map(action => action.toString());
11 | testActionTypes(actionTypes, dataKey);
12 | });
13 | describe('actions provided', () => {
14 | describe('fecthing actions', () => {
15 | test('error action', () => testAction(actions.fetching.error));
16 | test('started action', () => testAction(actions.fetching.started));
17 | test('received action', () => testAction(actions.fetching.received));
18 | });
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/data/actions/tracks.test.js:
--------------------------------------------------------------------------------
1 | import actions, { dataKey } from './tracks';
2 | import { testAction, testActionTypes } from './testUtils';
3 |
4 | describe('actions.tracks', () => {
5 | describe('action types', () => {
6 | const actionTypes = [
7 | actions.fetching.error,
8 | actions.fetching.started,
9 | actions.fetching.received,
10 | ].map(action => action.toString());
11 | testActionTypes(actionTypes, dataKey);
12 | });
13 | describe('actions provided', () => {
14 | describe('fecthing actions', () => {
15 | test('error action', () => testAction(actions.fetching.error));
16 | test('started action', () => testAction(actions.fetching.started));
17 | test('received action', () => testAction(actions.fetching.received));
18 | });
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/ReasonInput/ref.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import useReasonInputData from './hooks';
5 | import ReasonInput, { controlTestId } from '.';
6 |
7 | jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
8 |
9 | const focus = jest.fn();
10 | const props = {
11 | value: 'test-value',
12 | onChange: jest.fn(),
13 | ref: { current: { focus }, useRef: jest.fn() },
14 | };
15 | useReasonInputData.mockReturnValue(props);
16 |
17 | let el;
18 | describe('ReasonInput ref', () => {
19 | it('loads ref from hook', () => {
20 | el = render();
21 | const control = el.getByTestId(controlTestId);
22 | expect(control).toEqual(props.ref.current);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/.github/workflows/add-remove-label-on-comment.yml:
--------------------------------------------------------------------------------
1 | # This workflow runs when a comment is made on the ticket
2 | # If the comment starts with "label: " it tries to apply
3 | # the label indicated in rest of comment.
4 | # If the comment starts with "remove label: ", it tries
5 | # to remove the indicated label.
6 | # Note: Labels are allowed to have spaces and this script does
7 | # not parse spaces (as often a space is legitimate), so the command
8 | # "label: really long lots of words label" will apply the
9 | # label "really long lots of words label"
10 |
11 | name: Allows for the adding and removing of labels via comment
12 |
13 | on:
14 | issue_comment:
15 | types: [created]
16 |
17 | jobs:
18 | add_remove_labels:
19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
20 |
21 |
--------------------------------------------------------------------------------
/src/components/GradesView/StatusAlerts/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Alert } from '@openedx/paragon';
4 |
5 | import useStatusAlertsData from './hooks';
6 |
7 | export const StatusAlerts = () => {
8 | const {
9 | successBanner,
10 | gradeFilter,
11 | } = useStatusAlertsData();
12 |
13 | return (
14 | <>
15 |
20 | {successBanner.text}
21 |
22 |
27 | {gradeFilter.text}
28 |
29 | >
30 | );
31 | };
32 |
33 | StatusAlerts.propTypes = {};
34 |
35 | export default StatusAlerts;
36 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilterBadges/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-named-as-default */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { badgeOrder } from 'data/constants/filters';
6 |
7 | import FilterBadge from './FilterBadge';
8 |
9 | /**
10 | * FilterBadges
11 | * Displays a FilterBadge for each filter type in the data model with their current values.
12 | * @param {func} handleClose - event taking a list of filternames to reset
13 | */
14 | export const FilterBadges = ({ handleClose }) => (
15 |
16 | {badgeOrder.map(filterName => (
17 |
18 | ))}
19 |
20 | );
21 | FilterBadges.propTypes = {
22 | handleClose: PropTypes.func.isRequired,
23 | };
24 |
25 | export default FilterBadges;
26 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/HistoryHeader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | /**
5 | * HistoryHeader
6 | * simple display container for an individual history table header
7 | * @param {string} id - header id
8 | * @param {string} label - header label
9 | * @param {string} value - header value
10 | */
11 | const HistoryHeader = ({ id, label, value }) => (
12 |
13 |
{label}:
14 |
{value}
15 |
16 | );
17 | HistoryHeader.defaultProps = {
18 | value: null,
19 | };
20 | HistoryHeader.propTypes = {
21 | id: PropTypes.string.isRequired,
22 | label: PropTypes.node.isRequired,
23 | value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
24 | };
25 |
26 | export default HistoryHeader;
27 |
--------------------------------------------------------------------------------
/src/utils/hoc.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useLocation, useNavigate, useParams } from 'react-router-dom';
4 |
5 | export const withParams = WrappedComponent => {
6 | const WithParamsComponent = props => ;
7 | return WithParamsComponent;
8 | };
9 |
10 | export const withNavigate = WrappedComponent => {
11 | const WithNavigateComponent = props => {
12 | const navigate = useNavigate();
13 | return ;
14 | };
15 | return WithNavigateComponent;
16 | };
17 |
18 | export const withLocation = WrappedComponent => {
19 | const WithLocationComponent = props => {
20 | const location = useLocation();
21 | return ;
22 | };
23 | return WithLocationComponent;
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/GradesView/ScoreViewInput/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | scoreView: {
5 | id: 'gradebook.GradesView.scoreViewLabel',
6 | defaultMessage: 'Score View',
7 | description: 'The label for the dropdown list that allows a user to select the Score format',
8 | },
9 | absolute: {
10 | id: 'gradebook.GradesView.absoluteOption',
11 | defaultMessage: 'Absolute',
12 | description: 'A label within the Score Format dropdown list for the Absolute Grade Score option',
13 | },
14 | percent: {
15 | id: 'gradebook.GradesView.percentOption',
16 | defaultMessage: 'Percent',
17 | description: 'A label within the Score Format dropdown list for the Percent Grade Score option',
18 | },
19 | });
20 |
21 | export default messages;
22 |
--------------------------------------------------------------------------------
/src/components/GradesView/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | filterStepHeading: {
5 | id: 'gradebook.GradesView.filterHeading',
6 | defaultMessage: 'Step 1: Filter the Grade Report',
7 | description: 'Filter controls container heading string',
8 | },
9 | gradebookStepHeading: {
10 | id: 'gradebook.GradesView.gradebookStepHeading',
11 | defaultMessage: 'Step 2: View or Modify Individual Grades',
12 | description: 'Alert text for invalid minimum course grade',
13 | },
14 | mastersHint: {
15 | id: 'gradebook.GradesView.mastersHint',
16 | defaultMessage: "available for learners in the Master's track only",
17 | description: 'Masters feature availability hint on Grades Tab',
18 | },
19 | });
20 |
21 | export default messages;
22 |
--------------------------------------------------------------------------------
/src/i18n/utils.js:
--------------------------------------------------------------------------------
1 | import { getLocale, isRtl } from '@edx/frontend-platform/i18n';
2 |
3 | export const getLocalizedSlash = () => {
4 | // For fractional grades
5 | // if we are in a LTR language, we want to use a forward slash.
6 | // If we are in a RTL language, we want to use a backslash instead
7 | if (isRtl(getLocale())) {
8 | return '\\';
9 | }
10 | return '/';
11 | };
12 |
13 | export const getLocalizedPercentSign = () => {
14 | // LTR languages put the percent to the right of a number.
15 | // RTL languages put the percent sign to the left of the number.
16 | // We can place a non-printing unicode right-to-left marker next to the percent
17 | // sign to make it print to the left of the number if we are currently in a LTR language
18 | if (isRtl(getLocale())) {
19 | return '\u200f%';
20 | }
21 | return '%';
22 | };
23 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Routes } from 'react-router-dom';
3 |
4 | import { AppProvider } from '@edx/frontend-platform/react';
5 |
6 | import { FooterSlot } from '@edx/frontend-component-footer';
7 | import Header from '@edx/frontend-component-header';
8 |
9 | import store from 'data/store';
10 | import GradebookPage from 'containers/GradebookPage';
11 | import './App.scss';
12 | import Head from './head/Head';
13 |
14 | const App = () => (
15 |
16 |
17 |
18 |
19 |
20 |
21 | }
24 | />
25 |
26 |
27 |
28 |
29 |
30 | );
31 |
32 | export default App;
33 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | **TL;DR -** [ A short summary of what this PR does and why ]
2 |
3 | JIRA: [JIRA-XXXX](https://openedx.atlassian.net/browse/JIRA-XXXX)
4 |
5 | **What changed?**
6 |
7 | - [ More in depth breakdown of changes ]
8 | - [ Peripheral things that got changed ]
9 | - [ etc... ]
10 |
11 | **Developer Checklist**
12 | - [ ] Test suites passing
13 | - [ ] Documentation and test plan updated, if applicable
14 | - [ ] Received code-owner approving review
15 | - [ ] Bumped version number [package.json](../package.json)
16 |
17 | **Testing Instructions**
18 |
19 | [ How should a reviewer test this PR? ]
20 |
21 | **Reviewer Checklist**
22 |
23 | Collectively, these should be completed by reviewers of this PR:
24 |
25 | - [ ] I've done a visual code review
26 | - [ ] I've tested the new functionality
27 |
28 |
29 | FYI: @openedx/content-aurora
30 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base",
4 | "schedule:weekly",
5 | ":automergeLinters",
6 | ":automergeMinor",
7 | ":automergeTesters",
8 | ":enableVulnerabilityAlerts",
9 | ":rebaseStalePrs",
10 | ":semanticCommits",
11 | ":updateNotScheduled"
12 | ],
13 | "packageRules": [
14 | {
15 | "matchDepTypes": [
16 | "devDependencies"
17 | ],
18 | "matchUpdateTypes": [
19 | "lockFileMaintenance",
20 | "minor",
21 | "patch",
22 | "pin"
23 | ],
24 | "automerge": true
25 | },
26 | {
27 | "matchPackagePatterns": ["@edx", "@openedx"],
28 | "matchUpdateTypes": ["minor", "patch"],
29 | "automerge": true
30 | }
31 | ],
32 | "timezone": "America/New_York"
33 | }
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/AdjustedGradeInput/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Form } from '@openedx/paragon';
4 |
5 | import useAdjustedGradeInputData from './hooks';
6 |
7 | /**
8 | *
9 | * Input control for adjusting the grade of a unit
10 | * displays an "/ ${possibleGrade} if there is one in the data model.
11 | */
12 | export const AdjustedGradeInput = () => {
13 | const {
14 | value,
15 | onChange,
16 | hintText,
17 | } = useAdjustedGradeInputData();
18 | return (
19 |
20 |
26 | {hintText}
27 |
28 | );
29 | };
30 |
31 | AdjustedGradeInput.propTypes = {};
32 |
33 | export default AdjustedGradeInput;
34 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/hooks.js:
--------------------------------------------------------------------------------
1 | import { selectors, actions, thunkActions } from 'data/redux/hooks';
2 |
3 | export const useEditModalData = () => {
4 | const error = selectors.grades.useGradeData().gradeOverrideHistoryError;
5 | const isOpen = selectors.app.useModalData().open;
6 | const closeModal = actions.app.useCloseModal();
7 | const doneViewingAssignment = actions.grades.useDoneViewingAssignment();
8 | const updateGrades = thunkActions.grades.useUpdateGrades();
9 |
10 | const onClose = () => {
11 | doneViewingAssignment();
12 | closeModal();
13 | };
14 |
15 | const handleAdjustedGradeClick = () => {
16 | updateGrades();
17 | doneViewingAssignment();
18 | closeModal();
19 | };
20 |
21 | return {
22 | onClose,
23 | error,
24 | handleAdjustedGradeClick,
25 | isOpen,
26 | };
27 | };
28 |
29 | export default useEditModalData;
30 |
--------------------------------------------------------------------------------
/src/data/selectors/roles.test.js:
--------------------------------------------------------------------------------
1 | import selectors from './roles';
2 |
3 | describe('roles selectors', () => {
4 | describe('canUserViewGradebook', () => {
5 | it('returns true if the user has the canUserViewGradebook role', () => {
6 | const canUserViewGradebook = selectors.canUserViewGradebook({
7 | roles: {
8 | canUserViewGradebook: true,
9 | canUserDoTheMonsterMash: false,
10 | },
11 | });
12 | expect(canUserViewGradebook).toBeTruthy();
13 | });
14 |
15 | it('returns false if the user does not have the canUserViewGradebook role', () => {
16 | const canUserViewGradebook = selectors.canUserViewGradebook({
17 | roles: {
18 | canUserViewGradebook: false,
19 | canUserDoTheMonsterMash: true,
20 | },
21 | });
22 | expect(canUserViewGradebook).toBeFalsy();
23 | });
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/button-has-type, import/no-named-as-default */
2 | import React from 'react';
3 | import { FormattedMessage } from '@edx/frontend-platform/i18n';
4 |
5 | import messages from './messages';
6 | import BulkManagementAlerts from './BulkManagementAlerts';
7 | import HistoryTable from './HistoryTable';
8 |
9 | /**
10 | *
11 | * top-level view for managing uploads of bulk management override csvs.
12 | */
13 | export const BulkManagementHistoryView = () => (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 |
24 | export default BulkManagementHistoryView;
25 |
--------------------------------------------------------------------------------
/src/i18n/utils.test.jsx:
--------------------------------------------------------------------------------
1 | import { isRtl } from '@edx/frontend-platform/i18n';
2 | import { getLocalizedSlash, getLocalizedPercentSign } from './utils';
3 |
4 | jest.mock('@edx/frontend-platform/i18n', () => ({
5 | isRtl: jest.fn(),
6 | getLocale: jest.fn(),
7 | }));
8 |
9 | describe('getLocalizedSlash', () => {
10 | it('ltr', () => {
11 | isRtl.mockReturnValueOnce(false);
12 | expect(getLocalizedSlash()).toEqual('/');
13 | });
14 | it('rtl', () => {
15 | isRtl.mockReturnValueOnce(true);
16 | expect(getLocalizedSlash()).toEqual('\\');
17 | });
18 | });
19 |
20 | describe('getLocalizedPercentSign', () => {
21 | it('ltr', () => {
22 | isRtl.mockReturnValueOnce(false);
23 | expect(getLocalizedPercentSign()).toEqual('%');
24 | });
25 | it('rtl', () => {
26 | isRtl.mockReturnValueOnce(true);
27 | expect(getLocalizedPercentSign()).toEqual('\u200f%');
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/components/WithSidebar/WithSidebar.scss:
--------------------------------------------------------------------------------
1 | $sidebar-width: 350px;
2 |
3 | .sidebar-contents {
4 | overflow-x: auto;
5 | transition: margin 300ms cubic-bezier(0.4,0,0.2,1);
6 | margin-left: 0;
7 | .sidebar.open + & {
8 | margin-left: $sidebar-width;
9 | }
10 | &.opening {
11 | width: calc(100vw - #{$sidebar-width});
12 | }
13 | }
14 |
15 | .sidebar-header {
16 | display: flex;
17 | align-items: flex-start;
18 | justify-content: space-between;
19 | padding: 15px;
20 | }
21 |
22 | .sidebar-container .collapsible {
23 | margin-bottom: 1em;
24 | }
25 |
26 | .sidebar {
27 | height: 100%;
28 | width: $sidebar-width;
29 | position: absolute;
30 | transform: translateX(-$sidebar-width);
31 | flex-direction: column;
32 | transition: transform 300ms cubic-bezier(0.4,0,0.2,1);
33 | &.open {
34 | transform: translateX(0%);
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/data/reducers/tracks.js:
--------------------------------------------------------------------------------
1 | import actions from '../actions/tracks';
2 |
3 | const initialState = {
4 | results: [],
5 | startedFetching: false,
6 | errorFetching: false,
7 | };
8 |
9 | const tracks = (state = initialState, action = {}) => {
10 | switch (action.type) {
11 | case actions.fetching.started.toString():
12 | return {
13 | ...state,
14 | startedFetching: true,
15 | };
16 | case actions.fetching.received.toString():
17 | return {
18 | ...state,
19 | results: action.payload,
20 | errorFetching: false,
21 | finishedFetching: true,
22 | };
23 | case actions.fetching.error.toString():
24 | return {
25 | ...state,
26 | finishedFetching: true,
27 | errorFetching: true,
28 | };
29 | default:
30 | return state;
31 | }
32 | };
33 |
34 | export { initialState };
35 | export default tracks;
36 |
--------------------------------------------------------------------------------
/src/data/reducers/cohorts.js:
--------------------------------------------------------------------------------
1 | import actions from '../actions/cohorts';
2 |
3 | const initialState = {
4 | results: [],
5 | startedFetching: false,
6 | errorFetching: false,
7 | };
8 |
9 | const cohorts = (state = initialState, action = {}) => {
10 | switch (action.type) {
11 | case actions.fetching.started.toString():
12 | return {
13 | ...state,
14 | startedFetching: true,
15 | };
16 | case actions.fetching.received.toString():
17 | return {
18 | ...state,
19 | results: action.payload,
20 | finishedFetching: true,
21 | errorFetching: false,
22 | };
23 | case actions.fetching.error.toString():
24 | return {
25 | ...state,
26 | finishedFetching: true,
27 | errorFetching: true,
28 | };
29 | default:
30 | return state;
31 | }
32 | };
33 |
34 | export { initialState };
35 | export default cohorts;
36 |
--------------------------------------------------------------------------------
/src/data/actions/assignmentTypes.test.js:
--------------------------------------------------------------------------------
1 | import actions, { dataKey } from './assignmentTypes';
2 | import { testAction, testActionTypes } from './testUtils';
3 |
4 | describe('actions', () => {
5 | describe('action types', () => {
6 | const actionTypes = [
7 | actions.fetching.error,
8 | actions.fetching.started,
9 | actions.fetching.received,
10 | actions.gotGradesFrozen,
11 | ].map(action => action.toString());
12 | testActionTypes(actionTypes, dataKey);
13 | });
14 | describe('actions provided', () => {
15 | describe('fetching actions', () => {
16 | test('error action', () => testAction(actions.fetching.error));
17 | test('started action', () => testAction(actions.fetching.started));
18 | test('received action', () => testAction(actions.fetching.received));
19 | });
20 | test('gotGradesFrozen action', () => testAction(actions.gotGradesFrozen));
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentTypeFilter/hooks.js:
--------------------------------------------------------------------------------
1 | import { selectors, actions } from 'data/redux/hooks';
2 |
3 | export const useAssignmentTypeFilterData = ({ updateQueryParams }) => {
4 | const assignmentTypes = selectors.assignmentTypes.useAllAssignmentTypes() || {};
5 | const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
6 | const selectedAssignmentType = selectors.filters.useAssignmentType() || '';
7 | const filterAssignmentType = actions.filters.useUpdateAssignmentType();
8 |
9 | const handleChange = (event) => {
10 | const assignmentType = event.target.value;
11 | filterAssignmentType(assignmentType);
12 | updateQueryParams({ assignmentType });
13 | };
14 |
15 | return {
16 | assignmentTypes,
17 | handleChange,
18 | isDisabled: assignmentFilterOptions.length === 0,
19 | selectedAssignmentType,
20 | };
21 | };
22 | export default useAssignmentTypeFilterData;
23 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | const { createConfig } = require('@openedx/frontend-build');
3 |
4 | const config = createConfig('eslint', {
5 | rules: {
6 | 'import/no-named-as-default': 'off',
7 | 'import/no-named-as-default-member': 'off',
8 | 'import/no-self-import': 'off',
9 | 'spaced-comment': ['error', 'always', { 'block': { 'exceptions': ['*'] } }],
10 |
11 | // TOD: Remove this rule once we have a better way to handle this.
12 | 'import/no-import-module-exports': 'off',
13 | 'no-import-assign': 'off',
14 | 'default-param-last': 'off',
15 | },
16 | overrides: [{
17 | files: ['*.test.js'], rules: { 'no-import-assign': 'off' },
18 | }],
19 | });
20 |
21 | config.settings = {
22 | 'import/resolver': {
23 | node: {
24 | paths: ['src', 'node_modules'],
25 | extensions: ['.js', '.jsx'],
26 | },
27 | },
28 | };
29 |
30 | module.exports = config;
31 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportGradesButton/ref.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import useImportGradesButtonData from './hooks';
3 | import ImportGradesButton from '.';
4 | import { renderWithIntl, screen } from '../../../testUtilsExtra';
5 |
6 | jest.mock('components/NetworkButton', () => 'network-button');
7 | jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
8 |
9 | const props = {
10 | fileInputRef: { current: { click: jest.fn() }, useRef: jest.fn() },
11 | gradeExportUrl: 'test-grade-export-utl',
12 | handleClickImportGrades: jest.fn(),
13 | handleFileInputChange: jest.fn(),
14 | };
15 | useImportGradesButtonData.mockReturnValue(props);
16 |
17 | describe('ImportGradesButton ref test', () => {
18 | it('loads ref from hook', () => {
19 | renderWithIntl();
20 | const input = screen.getByTestId('file-control');
21 | expect(input).toEqual(props.fileInputRef.current);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/hooks.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors, thunkActions } from 'data/redux/hooks';
2 |
3 | export const useGradebookFiltersData = ({ updateQueryParams }) => {
4 | const includeCourseRoleMembers = selectors.filters.useIncludeCourseRoleMembers();
5 | const updateIncludeCourseRoleMembers = actions.filters.useUpdateIncludeCourseRoleMembers();
6 | const closeMenu = thunkActions.app.filterMenu.useCloseMenu();
7 | const fetchGrades = thunkActions.grades.useFetchGrades();
8 |
9 | const handleIncludeTeamMembersChange = ({ target: { checked } }) => {
10 | updateIncludeCourseRoleMembers(checked);
11 | fetchGrades();
12 | updateQueryParams({ includeCourseRoleMembers: checked });
13 | };
14 | return {
15 | closeMenu,
16 | includeCourseTeamMembers: {
17 | handleChange: handleIncludeTeamMembersChange,
18 | value: includeCourseRoleMembers,
19 | },
20 | };
21 | };
22 |
23 | export default useGradebookFiltersData;
24 |
--------------------------------------------------------------------------------
/src/components/GradesView/PageButtons/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Button } from '@openedx/paragon';
4 |
5 | import usePageButtonsData from './hooks';
6 |
7 | export const PageButtons = () => {
8 | const { prev, next } = usePageButtonsData();
9 |
10 | return (
11 |
15 |
23 |
31 |
32 | );
33 | };
34 |
35 | PageButtons.propTypes = {};
36 |
37 | export default PageButtons;
38 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | NODE_ENV='production'
2 | NODE_PATH=./src
3 | BASE_URL=''
4 | LMS_BASE_URL=''
5 | LOGIN_URL=''
6 | LOGOUT_URL=''
7 | CSRF_TOKEN_API_PATH=''
8 | REFRESH_ACCESS_TOKEN_ENDPOINT=''
9 | DATA_API_BASE_URL=''
10 | SEGMENT_KEY=''
11 | FEATURE_FLAGS={}
12 | ACCESS_TOKEN_COOKIE_NAME=''
13 | LANGUAGE_PREFERENCE_COOKIE_NAME=''
14 | NEW_RELIC_APP_ID=''
15 | NEW_RELIC_LICENSE_KEY=''
16 | SITE_NAME=''
17 | MARKETING_SITE_BASE_URL=''
18 | SUPPORT_URL=''
19 | CONTACT_URL=''
20 | OPEN_SOURCE_URL=''
21 | TERMS_OF_SERVICE_URL=''
22 | PRIVACY_POLICY_URL=''
23 | FACEBOOK_URL=''
24 | TWITTER_URL=''
25 | YOU_TUBE_URL=''
26 | LINKED_IN_URL=''
27 | REDDIT_URL=''
28 | APPLE_APP_STORE_URL=''
29 | GOOGLE_PLAY_URL=''
30 | ENTERPRISE_MARKETING_URL=''
31 | ENTERPRISE_MARKETING_UTM_SOURCE=''
32 | ENTERPRISE_MARKETING_UTM_CAMPAIGN=''
33 | ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=''
34 | APP_ID=''
35 | MFE_CONFIG_API_URL=''
36 | DISPLAY_FEEDBACK_WIDGET='true'
37 | # Fallback in local style files
38 | PARAGON_THEME_URLS={}
39 |
--------------------------------------------------------------------------------
/src/setupTest.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
3 | export const mockConfigs = {
4 | SITE_NAME: 'test-site-name',
5 | FAVICON_URL: 'http://localhost:18000/favicon.ico',
6 | LMS_BASE_URL: 'http://localhost:18000',
7 | };
8 | // These configuration values are usually set in webpack's EnvironmentPlugin however
9 | // Jest does not use webpack so we need to set these so for testing
10 | // many are here to prevent warnings on the tests
11 | process.env.LMS_BASE_URL = mockConfigs.LMS_BASE_URL;
12 | process.env.SITE_NAME = mockConfigs.SITE_NAME;
13 | process.env.FAVICON_URL = mockConfigs.FAVICON_URL;
14 | process.env.BASE_URL = mockConfigs.LMS_BASE_URL;
15 | process.env.LOGIN_URL = `${mockConfigs.LMS_BASE_URL}/login`;
16 | process.env.LOGOUT_URL = `${mockConfigs.LMS_BASE_URL}/logout`;
17 | process.env.REFRESH_ACCESS_TOKEN_ENDPOINT = `${mockConfigs.LMS_BASE_URL}/refresh_access_token`;
18 | process.env.ACCESS_TOKEN_COOKIE_NAME = 'edx';
19 | process.env.CSRF_TOKEN_API_PATH = 'TOKEN_PATH';
20 |
--------------------------------------------------------------------------------
/src/components/GradesView/PageButtons/hooks.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from '@edx/frontend-platform/i18n';
2 |
3 | import { selectors, thunkActions } from 'data/redux/hooks';
4 | import messages from './messages';
5 |
6 | export const usePageButtonsData = () => {
7 | const { formatMessage } = useIntl();
8 |
9 | const { nextPage, prevPage } = selectors.grades.useGradeData();
10 | const getPrevNextGrades = thunkActions.grades.useFetchPrevNextGrades();
11 |
12 | const getPrevGrades = () => {
13 | getPrevNextGrades(prevPage);
14 | };
15 |
16 | const getNextGrades = () => {
17 | getPrevNextGrades(nextPage);
18 | };
19 |
20 | return {
21 | prev: {
22 | disabled: !prevPage,
23 | onClick: getPrevGrades,
24 | text: formatMessage(messages.prevPage),
25 | },
26 | next: {
27 | disabled: !nextPage,
28 | onClick: getNextGrades,
29 | text: formatMessage(messages.nextPage),
30 | },
31 | };
32 | };
33 |
34 | export default usePageButtonsData;
35 |
--------------------------------------------------------------------------------
/src/components/GradesView/StatusAlerts/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | editSuccessAlert: {
5 | id: 'gradebook.GradesView.editSuccessAlert',
6 | defaultMessage: 'The grade has been successfully edited. You may see a slight delay before updates appear in the Gradebook.',
7 | description: 'An alert text for successfully editing a grade',
8 | },
9 | maxGradeInvalid: {
10 | id: 'gradebook.GradesView.maxCourseGradeInvalid',
11 | defaultMessage: 'Maximum course grade must be between 0 and 100',
12 | description: 'An alert text for selecting a maximum course grade greater than 100',
13 | },
14 | minGradeInvalid: {
15 | id: 'gradebook.GradesView.minCourseGradeInvalid',
16 | defaultMessage: 'Minimum course grade must be between 0 and 100',
17 | description: 'An alert text for selecting a minimum course grade less than 0',
18 | },
19 | });
20 |
21 | export default messages;
22 |
--------------------------------------------------------------------------------
/src/components/GradesView/InterventionsReport/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | title: {
5 | id: 'gradebook.GradesView.InterventionsReport.title',
6 | defaultMessage: 'Interventions Report',
7 | description: 'The title for the Intervention report subsection',
8 | },
9 | description: {
10 | id: 'gradebook.GradesView.InterventionsReport.description',
11 | defaultMessage: 'Need to find students who may be falling behind? Download the interventions report to obtain engagement metrics such as section attempts and visits.',
12 | description: 'The description for the Intervention report subsection',
13 | },
14 | downloadBtn: {
15 | id: 'gradebook.GradesView.InterventionsReport.downloadBtn',
16 | defaultMessage: 'Download Interventions',
17 | description: 'The labeled button to download the Intervention report from the Grades View',
18 | },
19 | });
20 |
21 | export default messages;
22 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/ReasonInput/index.test.jsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 |
3 | import useReasonInputData from './hooks';
4 | import ReasonInput from '.';
5 |
6 | jest.mock('./hooks', () => jest.fn());
7 |
8 | const hookProps = {
9 | ref: jest.fn().mockName('hook.ref'),
10 | onChange: jest.fn().mockName('hook.onChange'),
11 | value: 'test-value',
12 | };
13 | useReasonInputData.mockReturnValue(hookProps);
14 |
15 | describe('ReasonInput component', () => {
16 | beforeEach(() => {
17 | jest.clearAllMocks();
18 | render();
19 | });
20 | describe('behavior', () => {
21 | it('initializes hook data', () => {
22 | expect(useReasonInputData).toHaveBeenCalled();
23 | });
24 | });
25 | describe('renders', () => {
26 | it('input correctly', () => {
27 | expect(screen.getByRole('textbox')).toBeInTheDocument();
28 | expect(screen.getByRole('textbox')).toHaveValue(hookProps.value);
29 | });
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/src/components/GradesView/SpinnerIcon.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import { selectors } from 'data/redux/hooks';
5 | import SpinnerIcon from './SpinnerIcon';
6 |
7 | jest.mock('data/redux/hooks', () => ({
8 | selectors: {
9 | root: { useShouldShowSpinner: jest.fn() },
10 | },
11 | }));
12 |
13 | describe('SpinnerIcon', () => {
14 | beforeEach(() => {
15 | jest.clearAllMocks();
16 | });
17 | it('does not render if show: false', () => {
18 | selectors.root.useShouldShowSpinner.mockReturnValueOnce(false);
19 | const { container } = render();
20 | expect(container.querySelector('.fa.fa-spinner')).not.toBeInTheDocument();
21 | });
22 |
23 | test('displays spinner overlay with spinner icon', () => {
24 | selectors.root.useShouldShowSpinner.mockReturnValueOnce(true);
25 | const { container } = render();
26 | expect(container.querySelector('.fa.fa-spinner')).toBeInTheDocument();
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/SelectGroup.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { Form } from '@openedx/paragon';
6 |
7 | const SelectGroup = ({
8 | id,
9 | label,
10 | value,
11 | onChange,
12 | disabled,
13 | options,
14 | }) => (
15 |
16 |
17 | {label}
18 |
19 | {options}
20 |
21 |
22 |
23 | );
24 | SelectGroup.propTypes = {
25 | id: PropTypes.string.isRequired,
26 | label: PropTypes.node.isRequired,
27 | value: PropTypes.string.isRequired,
28 | onChange: PropTypes.func.isRequired,
29 | disabled: PropTypes.bool,
30 | options: PropTypes.arrayOf(PropTypes.node).isRequired,
31 | };
32 | SelectGroup.defaultProps = {
33 | disabled: false,
34 | };
35 |
36 | export default SelectGroup;
37 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/AdjustedGradeInput/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 |
4 | import useAdjustedGradeInputData from './hooks';
5 | import AdjustedGradeInput from '.';
6 |
7 | jest.mock('./hooks', () => jest.fn());
8 |
9 | const hookProps = {
10 | hintText: 'some-hint-text',
11 | onChange: jest.fn().mockName('hook.onChange'),
12 | value: 'test-value',
13 | };
14 | useAdjustedGradeInputData.mockReturnValue(hookProps);
15 |
16 | describe('AdjustedGradeInput component', () => {
17 | beforeEach(() => {
18 | jest.clearAllMocks();
19 | render();
20 | });
21 | describe('render', () => {
22 | test('renders input with correct props', () => {
23 | const input = screen.getByRole('textbox');
24 | expect(input).toBeInTheDocument();
25 | expect(input).toHaveValue(hookProps.value);
26 | expect(screen.getByText(hookProps.hintText)).toBeInTheDocument();
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/components/GradesView/hooks.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from '@edx/frontend-platform/i18n';
2 |
3 | import { actions, thunkActions } from 'data/redux/hooks';
4 | import messages from './messages';
5 |
6 | export const useGradesViewData = ({ updateQueryParams }) => {
7 | const { formatMessage } = useIntl();
8 | const fetchGrades = thunkActions.grades.useFetchGrades();
9 | const resetFilters = actions.filters.useResetFilters();
10 |
11 | const handleFilterBadgeClose = (filterNames) => () => {
12 | resetFilters(filterNames);
13 | updateQueryParams(filterNames.reduce(
14 | (obj, filterName) => ({ ...obj, [filterName]: false }),
15 | {},
16 | ));
17 | fetchGrades();
18 | };
19 |
20 | return {
21 | stepHeadings: {
22 | filter: formatMessage(messages.filterStepHeading),
23 | gradebook: formatMessage(messages.gradebookStepHeading),
24 | },
25 | handleFilterBadgeClose,
26 | mastersHint: formatMessage(messages.mastersHint),
27 | };
28 | };
29 |
30 | export default useGradesViewData;
31 |
--------------------------------------------------------------------------------
/src/components/GradesView/BulkManagementControls/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import React from 'react';
3 |
4 | import NetworkButton from 'components/NetworkButton';
5 | import ImportGradesButton from '../ImportGradesButton';
6 |
7 | import useBulkManagementControlsData from './hooks';
8 | import messages from './messages';
9 |
10 | /**
11 | *
12 | * Provides download buttons for Bulk Management and Intervention reports, only if
13 | * showBulkManagement is set in redus.
14 | */
15 | export const BulkManagementControls = () => {
16 | const {
17 | show,
18 | handleClickExportGrades,
19 | } = useBulkManagementControlsData();
20 |
21 | if (!show) { return null; }
22 | return (
23 |
24 |
28 |
29 |
30 | );
31 | };
32 |
33 | export default BulkManagementControls;
34 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | adjustedGradeHeader: {
5 | id: 'gradebook.GradesView.EditModal.Overrides.adjustedGradeHeader',
6 | defaultMessage: 'Adjusted grade',
7 | description: 'Edit Modal Override Table Adjusted grade column header',
8 | },
9 | dateHeader: {
10 | id: 'gradebook.GradesView.EditModal.Overrides.dateHeader',
11 | defaultMessage: 'Date',
12 | description: 'Edit Modal Override Table Date column header',
13 | },
14 | graderHeader: {
15 | id: 'gradebook.GradesView.EditModal.Overrides.graderHeader',
16 | defaultMessage: 'Grader',
17 | description: 'Edit Modal Override Table Grader column header',
18 | },
19 | reasonHeader: {
20 | id: 'gradebook.GradesView.EditModal.Overrides.reasonHeader',
21 | defaultMessage: 'Reason',
22 | description: 'Edit Modal Override Table Reason column header',
23 | },
24 | });
25 |
26 | export default messages;
27 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/PercentGroup.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { Form } from '@openedx/paragon';
6 |
7 | const PercentGroup = ({
8 | id,
9 | label,
10 | value,
11 | disabled,
12 | onChange,
13 | }) => (
14 |
15 |
16 | {label}
17 |
24 |
25 | %
26 |
27 | );
28 | PercentGroup.defaultProps = {
29 | disabled: false,
30 | };
31 | PercentGroup.propTypes = {
32 | id: PropTypes.string.isRequired,
33 | label: PropTypes.node.isRequired,
34 | value: PropTypes.string.isRequired,
35 | onChange: PropTypes.func.isRequired,
36 | disabled: PropTypes.bool,
37 | };
38 |
39 | export default PercentGroup;
40 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportGradesButton/hooks.js:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { selectors, thunkActions } from 'data/redux/hooks';
3 |
4 | export const useImportButtonData = () => {
5 | const gradeExportUrl = selectors.root.useGradeExportUrl();
6 | const submitImportGradesButtonData = thunkActions.grades.useSubmitImportGradesButtonData();
7 |
8 | const fileInputRef = useRef();
9 |
10 | const handleClickImportGrades = () => fileInputRef.current?.click();
11 | const handleFileInputChange = () => {
12 | if (fileInputRef.current?.files[0]) {
13 | const clearInput = () => {
14 | fileInputRef.current.value = null;
15 | };
16 | const formData = new FormData();
17 | formData.append('csv', fileInputRef.current.files[0]);
18 | submitImportGradesButtonData(formData).then(clearInput);
19 | }
20 | };
21 |
22 | return {
23 | fileInputRef,
24 | gradeExportUrl,
25 | handleClickImportGrades,
26 | handleFileInputChange,
27 | };
28 | };
29 |
30 | export default useImportButtonData;
31 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | heading: {
5 | id: 'gradebook.BulkManagementHistoryView.heading',
6 | defaultMessage: 'Bulk Management History',
7 | description: 'Heading text for BulkManagement History Tab',
8 | },
9 | helpText: {
10 | id: 'gradebook.BulkManagementHistoryView',
11 | defaultMessage: 'Below is a log of previous grade imports. To download a CSV of your gradebook and import grades for override, return to the Gradebook. Please note, after importing grades, it may take a few seconds to process the override.',
12 | description: 'Bulk Management History View help text',
13 | },
14 | successDialog: {
15 | id: 'gradebook.BulkManagementHistoryView.successDialog',
16 | defaultMessage: 'CSV processing. File uploads may take several minutes to complete.',
17 | description: 'Success Dialog message in BulkManagement Tab File Upload Form',
18 | },
19 | });
20 |
21 | export default messages;
22 |
--------------------------------------------------------------------------------
/src/components/GradesView/SearchControls/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { SearchField } from '@openedx/paragon';
4 | import useSearchControlsData from './hooks';
5 |
6 | /**
7 | * Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
8 | * as well as the search box for searching by username/email.
9 | */
10 | export const SearchControls = () => {
11 | const {
12 | onSubmit,
13 | onBlur,
14 | onClear,
15 | searchValue,
16 | inputLabel,
17 | hintText,
18 | } = useSearchControlsData();
19 |
20 | return (
21 |
22 |
29 |
30 | {hintText}
31 |
32 |
33 | );
34 | };
35 |
36 | SearchControls.propTypes = {};
37 |
38 | export default SearchControls;
39 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/index.test.jsx:
--------------------------------------------------------------------------------
1 | import { render, initializeMocks, screen } from 'testUtilsExtra';
2 |
3 | import { BulkManagementHistoryView } from '.';
4 | import messages from './messages';
5 |
6 | jest.mock('./BulkManagementAlerts', () => jest.fn(() => BulkManagementAlerts
));
7 | jest.mock('./HistoryTable', () => jest.fn(() => HistoryTable
));
8 |
9 | initializeMocks();
10 |
11 | describe('BulkManagementHistoryView', () => {
12 | describe('component', () => {
13 | beforeEach(() => {
14 | render();
15 | });
16 | describe('render alerts and heading', () => {
17 | it('heading - h4 loaded from messages', () => {
18 | expect(screen.getByText(messages.heading.defaultMessage)).toBeInTheDocument();
19 | expect(screen.getByText(messages.helpText.defaultMessage)).toBeInTheDocument();
20 | expect(screen.getByText('BulkManagementAlerts')).toBeInTheDocument();
21 | expect(screen.getByText('HistoryTable')).toBeInTheDocument();
22 | });
23 | });
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilterMenuToggle/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Button, Icon } from '@openedx/paragon';
4 | import { useIntl } from '@edx/frontend-platform/i18n';
5 | import { FilterAlt } from '@openedx/paragon/icons';
6 |
7 | import { thunkActions } from 'data/redux/hooks';
8 |
9 | import messages from './messages';
10 |
11 | /**
12 | * Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
13 | * as well as the search box for searching by username/email.
14 | */
15 | export const FilterMenuToggle = () => {
16 | const toggleFilterMenu = thunkActions.app.filterMenu.useToggleMenu();
17 | const { formatMessage } = useIntl();
18 | return (
19 |
26 | );
27 | };
28 |
29 | FilterMenuToggle.propTypes = {};
30 |
31 | export default FilterMenuToggle;
32 |
--------------------------------------------------------------------------------
/src/data/thunkActions/assignmentTypes.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | import { StrictDict } from 'utils';
3 |
4 | import lms from 'data/services/lms';
5 | import actions from 'data/actions';
6 |
7 | import { fetchBulkUpgradeHistory } from './grades';
8 |
9 | const {
10 | assignmentTypes: { fetching, gotGradesFrozen },
11 | config: { gotBulkManagementConfig },
12 | } = actions;
13 |
14 | export const fetchAssignmentTypes = () => (
15 | (dispatch) => {
16 | dispatch(fetching.started());
17 | return lms.api.fetch.assignmentTypes()
18 | .then(({ data }) => {
19 | dispatch(fetching.received(Object.keys(data.assignment_types)));
20 | dispatch(gotGradesFrozen(data.grades_frozen));
21 | dispatch(gotBulkManagementConfig(data.can_see_bulk_management));
22 | if (data.can_see_bulk_management) {
23 | dispatch(fetchBulkUpgradeHistory());
24 | }
25 | })
26 | .catch(() => {
27 | dispatch(fetching.error());
28 | });
29 | }
30 | );
31 |
32 | export default StrictDict({ fetchAssignmentTypes });
33 |
--------------------------------------------------------------------------------
/src/data/actions/utils.js:
--------------------------------------------------------------------------------
1 | import { createAction } from '@reduxjs/toolkit';
2 |
3 | export const options = {
4 | year: 'numeric',
5 | month: 'long',
6 | day: 'numeric',
7 | timeZone: 'UTC',
8 | };
9 | export const timeOptions = {
10 | hour: '2-digit',
11 | minute: '2-digit',
12 | timeZone: 'UTC',
13 | timeZoneName: 'short',
14 | };
15 |
16 | const formatDateForDisplay = (inputDate) => {
17 | const date = inputDate.toLocaleDateString('en-US', options);
18 | const time = inputDate.toLocaleTimeString('en-US', timeOptions);
19 | return `${date} at ${time}`;
20 | };
21 |
22 | const sortAlphaAsc = (gradeRowA, gradeRowB) => {
23 | const a = gradeRowA.username.toUpperCase();
24 | const b = gradeRowB.username.toUpperCase();
25 | if (a < b) {
26 | return -1;
27 | }
28 | if (a > b) {
29 | return 1;
30 | }
31 | return 0;
32 | };
33 |
34 | const createActionFactory = (dataKey) => (actionKey, ...args) => (
35 | createAction(`${dataKey}/${actionKey}`, ...args)
36 | );
37 |
38 | export {
39 | createActionFactory,
40 | sortAlphaAsc,
41 | formatDateForDisplay,
42 | };
43 |
--------------------------------------------------------------------------------
/src/components/EdxHeader/EdxHeader.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { getConfig } from '@edx/frontend-platform';
3 | import { IntlProvider } from '@edx/frontend-platform/i18n';
4 | import { render, screen } from '@testing-library/react';
5 |
6 | import Header from '.';
7 |
8 | jest.mock('@edx/frontend-platform', () => ({
9 | ...jest.requireActual('@edx/frontend-platform'),
10 | getConfig: jest.fn(),
11 | }));
12 |
13 | describe('Header', () => {
14 | beforeEach(() => {
15 | jest.clearAllMocks();
16 | });
17 |
18 | test('has edx link with logo url', () => {
19 | const url = 'www.ourLogo.url';
20 | const baseUrl = 'www.lms.url';
21 | getConfig.mockReturnValue({ LOGO_URL: url, LMS_BASE_URL: baseUrl });
22 |
23 | render(
24 |
25 |
26 | ,
27 | );
28 |
29 | const link = screen.getByRole('link');
30 | const logo = screen.getByAltText('edX logo');
31 |
32 | expect(link).toHaveAttribute('href', `${baseUrl}/dashboard`);
33 | expect(logo).toHaveAttribute('src', url);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/hooks.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from '@edx/frontend-platform/i18n';
2 |
3 | import { gradeOverrideHistoryColumns as columns } from 'data/constants/app';
4 | import { selectors } from 'data/redux/hooks';
5 |
6 | import messages from './messages';
7 |
8 | const useOverrideTableData = () => {
9 | const { formatMessage } = useIntl();
10 |
11 | const hide = selectors.grades.useHasOverrideErrors();
12 | const gradeOverrides = selectors.grades.useGradeData().gradeOverrideHistoryResults || [];
13 | const tableProps = {};
14 | if (!hide) {
15 | tableProps.columns = [
16 | { Header: formatMessage(messages.dateHeader), accessor: columns.date },
17 | { Header: formatMessage(messages.graderHeader), accessor: columns.grader },
18 | { Header: formatMessage(messages.reasonHeader), accessor: columns.reason },
19 | { Header: formatMessage(messages.adjustedGradeHeader), accessor: columns.adjustedGrade },
20 | ];
21 | tableProps.data = gradeOverrides;
22 | }
23 | return { hide, ...tableProps };
24 | };
25 |
26 | export default useOverrideTableData;
27 |
--------------------------------------------------------------------------------
/src/data/redux/hooks/thunkActions.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import thunkActions from 'data/thunkActions';
3 | import { actionHook } from './utils';
4 |
5 | const app = StrictDict({
6 | filterMenu: {
7 | useCloseMenu: actionHook(thunkActions.app.filterMenu.close),
8 | useHandleTransitionEnd: actionHook(thunkActions.app.filterMenu.handleTransitionEnd),
9 | useToggleMenu: actionHook(thunkActions.app.filterMenu.toggle),
10 | },
11 | useSetModalStateFromTable: actionHook(thunkActions.app.setModalStateFromTable),
12 | });
13 |
14 | const grades = StrictDict({
15 | useFetchGradesIfAssignmentGradeFiltersSet: actionHook(
16 | thunkActions.grades.fetchGradesIfAssignmentGradeFiltersSet,
17 | ),
18 | useFetchPrevNextGrades: actionHook(thunkActions.grades.fetchPrevNextGrades),
19 | useFetchGrades: actionHook(thunkActions.grades.fetchGrades),
20 | useSubmitImportGradesButtonData: actionHook(thunkActions.grades.submitImportGradesButtonData),
21 | useUpdateGrades: actionHook(thunkActions.grades.updateGrades),
22 | });
23 |
24 | export default StrictDict({
25 | app,
26 | grades,
27 | });
28 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/ResultsSummary.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { Hyperlink, Icon } from '@openedx/paragon';
6 | import { Download } from '@openedx/paragon/icons';
7 |
8 | import lms from 'data/services/lms';
9 |
10 | /**
11 | *
12 | * displays a result summary cell for a single bulk management upgrade history entry.
13 | * @param {string} courseId - course identifier
14 | * @param {number} rowId - row/error identifier
15 | * @param {string} text - summary string
16 | */
17 | const ResultsSummary = ({
18 | rowId,
19 | text,
20 | }) => (
21 |
26 |
27 | {text}
28 |
29 | );
30 |
31 | ResultsSummary.propTypes = {
32 | rowId: PropTypes.number.isRequired,
33 | text: PropTypes.string.isRequired,
34 | };
35 |
36 | export default ResultsSummary;
37 |
--------------------------------------------------------------------------------
/src/data/reducers/assignmentTypes.js:
--------------------------------------------------------------------------------
1 | import actions from '../actions/assignmentTypes';
2 |
3 | const initialState = {
4 | results: [],
5 | startedFetching: false,
6 | errorFetching: false,
7 | };
8 |
9 | const assignmentTypes = (state = initialState, { type, payload } = {}) => {
10 | switch (type) {
11 | case actions.fetching.started.toString():
12 | return {
13 | ...state,
14 | startedFetching: true,
15 | };
16 | case actions.fetching.received.toString():
17 | return {
18 | ...state,
19 | results: payload,
20 | errorFetching: false,
21 | finishedFetching: true,
22 | };
23 | case actions.fetching.error.toString():
24 | return {
25 | ...state,
26 | finishedFetching: true,
27 | errorFetching: true,
28 | };
29 | case actions.gotGradesFrozen.toString():
30 | return {
31 | ...state,
32 | areGradesFrozen: payload,
33 | errorFetching: false,
34 | finishedFetching: true,
35 | };
36 | default:
37 | return state;
38 | }
39 | };
40 |
41 | export { initialState };
42 | export default assignmentTypes;
43 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentFilter/hooks.js:
--------------------------------------------------------------------------------
1 | import {
2 | selectors,
3 | actions,
4 | thunkActions,
5 | } from 'data/redux/hooks';
6 |
7 | export const useAssignmentFilterData = ({
8 | updateQueryParams,
9 | }) => {
10 | const assignmentFilterOptions = selectors.filters.useSelectableAssignmentLabels();
11 | const selectedAssignmentLabel = selectors.filters.useSelectedAssignmentLabel() || '';
12 |
13 | const updateAssignmentFilter = actions.filters.useUpdateAssignment();
14 | const conditionalFetch = thunkActions.grades.useFetchGradesIfAssignmentGradeFiltersSet();
15 |
16 | const handleChange = ({ target: { value: assignment } }) => {
17 | const selectedFilterOption = assignmentFilterOptions.find(
18 | ({ label }) => label === assignment,
19 | );
20 | const { type, id } = selectedFilterOption || {};
21 | updateAssignmentFilter({ label: assignment, type, id });
22 | updateQueryParams({ assignment: id });
23 | conditionalFetch();
24 | };
25 |
26 | return {
27 | handleChange,
28 | selectedAssignmentLabel,
29 | assignmentFilterOptions,
30 | };
31 | };
32 |
33 | export default useAssignmentFilterData;
34 |
--------------------------------------------------------------------------------
/src/components/GradebookHeader/hooks.js:
--------------------------------------------------------------------------------
1 | import { views } from 'data/constants/app';
2 | import { actions, selectors } from 'data/redux/hooks';
3 |
4 | import messages from './messages';
5 |
6 | export const useGradebookHeaderData = () => {
7 | const activeView = selectors.app.useActiveView();
8 | const courseId = selectors.app.useCourseId();
9 | const areGradesFrozen = selectors.assignmentTypes.useAreGradesFrozen();
10 | const canUserViewGradebook = selectors.roles.useCanUserViewGradebook();
11 | const showBulkManagement = selectors.root.useShowBulkManagement();
12 | const setView = actions.app.useSetView();
13 |
14 | const handleToggleViewClick = () => setView(
15 | activeView === views.grades
16 | ? views.bulkManagementHistory
17 | : views.grades,
18 | );
19 |
20 | const toggleViewMessage = activeView === views.grades
21 | ? messages.toActivityLog
22 | : messages.toGradesView;
23 |
24 | return {
25 | areGradesFrozen,
26 | canUserViewGradebook,
27 | courseId,
28 | showBulkManagement,
29 |
30 | handleToggleViewClick,
31 | toggleViewMessage,
32 | };
33 | };
34 |
35 | export default useGradebookHeaderData;
36 |
--------------------------------------------------------------------------------
/src/data/reducers/roles.test.js:
--------------------------------------------------------------------------------
1 | import roles, { initialState } from './roles';
2 | import actions from '../actions/roles';
3 |
4 | const testingState = {
5 | ...initialState,
6 | arbitraryField: 'arbitrary',
7 | };
8 |
9 | describe('roles reducer', () => {
10 | it('has initial state', () => {
11 | expect(
12 | roles(undefined, {}),
13 | ).toEqual(initialState);
14 | });
15 |
16 | describe('handling actions.received', () => {
17 | it('updates canUserViewGradebook to the received payload', () => {
18 | const expectedCanUserViewGradebook = true;
19 | expect(
20 | roles(testingState, actions.fetching.received(expectedCanUserViewGradebook)),
21 | ).toEqual({
22 | ...testingState,
23 | canUserViewGradebook: expectedCanUserViewGradebook,
24 | });
25 | });
26 | });
27 |
28 | describe('handling actions.errorFetching', () => {
29 | it('sets canUserViewGradebook to false', () => {
30 | expect(
31 | roles(testingState, actions.fetching.error()),
32 | ).toEqual({
33 | ...testingState,
34 | canUserViewGradebook: false,
35 | });
36 | });
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilteredUsersLabel/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import { useIntl } from '@edx/frontend-platform/i18n';
5 |
6 | import { selectors } from 'data/redux/hooks';
7 | import messages from './messages';
8 |
9 | export const BoldText = ({ text }) => (
10 | {text}
11 | );
12 | BoldText.propTypes = {
13 | text: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
14 | };
15 |
16 | /**
17 | *
18 | * Simple label component displaying the filtered and total users shown
19 | */
20 | export const FilteredUsersLabel = () => {
21 | const { filteredUsersCount, totalUsersCount } = selectors.grades.useUserCounts();
22 | const { formatMessage } = useIntl();
23 |
24 | if (!totalUsersCount) {
25 | return null;
26 | }
27 | return formatMessage(
28 | messages.visibilityLabel,
29 | {
30 | filteredUsers: ,
31 | totalUsers: ,
32 | },
33 | );
34 | };
35 | FilteredUsersLabel.propTypes = {};
36 |
37 | export default FilteredUsersLabel;
38 |
--------------------------------------------------------------------------------
/src/components/GradesView/ScoreViewInput/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Form } from '@openedx/paragon';
4 | import { useIntl } from '@edx/frontend-platform/i18n';
5 |
6 | import { actions, selectors } from 'data/redux/hooks';
7 | import messages from './messages';
8 |
9 | /**
10 | *
11 | * redux-connected select control for grade format (percent vs absolute)
12 | */
13 | export const ScoreViewInput = () => {
14 | const { formatMessage } = useIntl();
15 | const { gradeFormat } = selectors.grades.useGradeData();
16 | const toggleFormat = actions.grades.useToggleGradeFormat();
17 | return (
18 |
19 | {formatMessage(messages.scoreView)}:
20 |
25 |
26 |
27 |
28 |
29 | );
30 | };
31 | ScoreViewInput.propTypes = {};
32 |
33 | export default ScoreViewInput;
34 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/CourseGradeFilter/hooks.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors, thunkActions } from 'data/redux/hooks';
2 |
3 | export const useCourseGradeFilterData = ({
4 | updateQueryParams,
5 | }) => {
6 | const isDisabled = !selectors.app.useAreCourseGradeFiltersValid();
7 | const localCourseLimits = selectors.app.useCourseGradeLimits();
8 | const fetchGrades = thunkActions.grades.useFetchGrades();
9 | const setLocalFilter = actions.app.useSetLocalFilter();
10 | const updateFilter = actions.filters.useUpdateCourseGradeLimits();
11 |
12 | const handleApplyClick = () => {
13 | updateFilter(localCourseLimits);
14 | fetchGrades();
15 | updateQueryParams(localCourseLimits);
16 | };
17 |
18 | const { courseGradeMin, courseGradeMax } = localCourseLimits;
19 | return {
20 | max: {
21 | value: courseGradeMax,
22 | onChange: (e) => setLocalFilter({ courseGradeMax: e.target.value }),
23 | },
24 | min: {
25 | value: courseGradeMin,
26 | onChange: (e) => setLocalFilter({ courseGradeMin: e.target.value }),
27 | },
28 | handleApplyClick,
29 | isDisabled,
30 | };
31 | };
32 |
33 | export default useCourseGradeFilterData;
34 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/PercentGroup.test.jsx:
--------------------------------------------------------------------------------
1 | import { render, screen, initializeMocks } from 'testUtilsExtra';
2 |
3 | import PercentGroup from './PercentGroup';
4 |
5 | describe('PercentGroup', () => {
6 | let props = {
7 | id: 'group id',
8 | label: 'Group Label',
9 | value: 'group VALUE',
10 | disabled: false,
11 | };
12 |
13 | beforeEach(() => {
14 | initializeMocks();
15 | props = {
16 | ...props,
17 | onChange: jest.fn().mockName('props.onChange'),
18 | };
19 | });
20 |
21 | describe('Component', () => {
22 | test('is displayed', () => {
23 | render();
24 | expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeInTheDocument();
25 | expect(screen.getByText('Group Label')).toBeVisible();
26 | expect(screen.getByText('%')).toBeVisible();
27 | });
28 | test('disabled', () => {
29 | render();
30 | expect(screen.getByRole('spinbutton', { name: 'Group Label' })).toBeDisabled();
31 | expect(screen.getByText('Group Label')).toBeVisible();
32 | expect(screen.getByText('%')).toBeVisible();
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentTypeFilter/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { screen } from '@testing-library/react';
3 |
4 | import useAssignmentFilterTypeData from './hooks';
5 | import AssignmentFilterType from '.';
6 | import { renderWithIntl } from '../../../testUtilsExtra';
7 |
8 | jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
9 |
10 | const handleChange = jest.fn();
11 | const testType = 'test-type';
12 | const assignmentTypes = [testType, 'type1', 'type2', 'type3'];
13 | useAssignmentFilterTypeData.mockReturnValue({
14 | handleChange,
15 | selectedAssignmentType: testType,
16 | assignmentTypes,
17 | isDisabled: true,
18 | });
19 |
20 | const updateQueryParams = jest.fn();
21 |
22 | describe('AssignmentFilterType component', () => {
23 | beforeAll(() => {
24 | renderWithIntl();
25 | });
26 | describe('render', () => {
27 | test('filter options', () => {
28 | const options = screen.getAllByRole('option');
29 | expect(options.length).toEqual(5); // 4 types + "All Types"
30 | expect(options[1]).toHaveTextContent(testType);
31 | });
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/components/GradesView/SearchControls/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { initializeMocks, render, screen } from 'testUtilsExtra';
3 |
4 | import useSearchControlsData from './hooks';
5 | import SearchControls from '.';
6 |
7 | jest.mock('./hooks', () => jest.fn());
8 |
9 | const hookProps = {
10 | onSubmit: jest.fn().mockName('hooks.onSubmit'),
11 | onBlur: jest.fn().mockName('hooks.onBlur'),
12 | onClear: jest.fn().mockName('hooks.onClear'),
13 | searchValue: 'test-search-value',
14 | inputLabel: 'test-input-label',
15 | hintText: 'test-hint-text',
16 | };
17 | useSearchControlsData.mockReturnValue(hookProps);
18 | describe('SearchControls component', () => {
19 | beforeEach(() => {
20 | initializeMocks();
21 | render();
22 | jest.clearAllMocks();
23 | });
24 | describe('render', () => {
25 | test('search field', () => {
26 | expect(screen.getByLabelText(hookProps.inputLabel)).toBeInTheDocument();
27 | expect(screen.getByRole('searchbox')).toHaveValue(hookProps.searchValue);
28 | });
29 | test('hint text', () => {
30 | expect(screen.getByText(hookProps.hintText)).toBeInTheDocument();
31 | });
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, initializeMocks } from 'testUtilsExtra';
3 |
4 | import GradebookFilters from '.';
5 |
6 | const updateQueryParams = jest.fn();
7 |
8 | initializeMocks();
9 |
10 | describe('GradebookFilters', () => {
11 | beforeEach(() => {
12 | jest.clearAllMocks();
13 | render();
14 | });
15 | describe('All filters render together', () => {
16 | test('Assignment filters', () => {
17 | expect(screen.getByRole('combobox', { name: 'Assignment Types' })).toBeInTheDocument();
18 | expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument();
19 | });
20 | test('CourseGrade filters', () => {
21 | expect(screen.getByRole('button', { name: 'Overall Grade' })).toBeInTheDocument();
22 | });
23 | test('StudentGroups filters', () => {
24 | expect(screen.getByRole('button', { name: 'Student Groups' })).toBeInTheDocument();
25 | });
26 | test('includeCourseTeamMembers', () => {
27 | expect(screen.getByRole('button', { name: 'Include Course Team Members' })).toBeInTheDocument();
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/GradesView/GradebookTable/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { DataTable } from '@openedx/paragon';
4 |
5 | import useGradebookTableData from './hooks';
6 |
7 | /**
8 | *
9 | * This is the wrapper component for the Grades tab gradebook table, holding
10 | * a row for each user, with a column for their username, email, and total grade,
11 | * along with one for each subsection in their grade entry.
12 | */
13 | export const GradebookTable = () => {
14 | const {
15 | columns,
16 | data,
17 | grades,
18 | nullMethod,
19 | emptyContent,
20 | } = useGradebookTableData();
21 |
22 | return (
23 |
24 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | GradebookTable.propTypes = {};
41 |
42 | export default GradebookTable;
43 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportSuccessToast/hooks.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from '@edx/frontend-platform/i18n';
2 |
3 | import { actions, selectors } from 'data/redux/hooks';
4 | import { views } from 'data/constants/app';
5 | import messages from './messages';
6 |
7 | /**
8 | *
9 | * Toast component triggered by successful grade upload.
10 | * Provides a link to view the Bulk Management History tab.
11 | */
12 | export const useImportSuccessToastData = () => {
13 | const { formatMessage } = useIntl();
14 |
15 | const show = selectors.app.useShowImportSuccessToast();
16 | const setAppView = actions.app.useSetView();
17 | const setShow = actions.app.useSetShowImportSuccessToast();
18 |
19 | const onClose = () => {
20 | setShow(false);
21 | };
22 |
23 | const handleShowHistoryView = () => {
24 | setAppView(views.bulkManagementHistory);
25 | setShow(false);
26 | };
27 |
28 | return {
29 | action: {
30 | label: formatMessage(messages.showHistoryViewBtn),
31 | onClick: handleShowHistoryView,
32 | },
33 | onClose,
34 | show,
35 | description: formatMessage(messages.description),
36 | };
37 | };
38 |
39 | export default useImportSuccessToastData;
40 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/SelectGroup.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 |
4 | import SelectGroup from './SelectGroup';
5 |
6 | describe('SelectGroup', () => {
7 | let props = {
8 | id: 'group id',
9 | label: 'Group Label',
10 | value: 'group VALUE',
11 | disabled: false,
12 | options: [
13 | ,
14 | ,
15 | ,
16 | ],
17 | };
18 |
19 | beforeEach(() => {
20 | props = {
21 | ...props,
22 | onChange: jest.fn().mockName('props.onChange'),
23 | };
24 | });
25 |
26 | describe('Component', () => {
27 | test('rendered with all options and label', () => {
28 | render();
29 | expect(screen.getAllByRole('option')).toHaveLength(props.options.length);
30 | expect(screen.getByLabelText(props.label)).toBeInTheDocument();
31 | });
32 | test('disabled', () => {
33 | render();
34 | expect(screen.getByRole('combobox')).toBeDisabled();
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
2 | import React from 'react';
3 |
4 | import { DataTable } from '@openedx/paragon';
5 |
6 | import { formatDateForDisplay } from 'utils';
7 |
8 | import ReasonInput from './ReasonInput';
9 | import AdjustedGradeInput from './AdjustedGradeInput';
10 | import useOverrideTableData from './hooks';
11 |
12 | /**
13 | *
14 | * Table containing previous grade override entries, and an "edit" row
15 | * with todays date, an AdjustedGradeInput and a ReasonInput
16 | */
17 |
18 | export const OverrideTable = () => {
19 | const { hide, columns, data } = useOverrideTableData();
20 |
21 | if (hide) { return null; }
22 |
23 | const tableData = [
24 | ...data,
25 | {
26 | adjustedGrade: ,
27 | date: formatDateForDisplay(new Date()),
28 | reason: ,
29 | },
30 | ];
31 |
32 | return (
33 |
38 | );
39 | };
40 | OverrideTable.propTypes = {};
41 |
42 | export default OverrideTable;
43 |
--------------------------------------------------------------------------------
/src/components/GradesView/StatusAlerts/hooks.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from '@edx/frontend-platform/i18n';
2 |
3 | import { actions, selectors } from 'data/redux/hooks';
4 | import messages from './messages';
5 |
6 | export const useStatusAlertsData = () => {
7 | const { formatMessage } = useIntl();
8 |
9 | const limitValidity = selectors.app.useCourseGradeFilterValidity();
10 | const showSuccessBanner = selectors.grades.useShowSuccess();
11 | const handleCloseSuccessBanner = actions.grades.useCloseBanner();
12 |
13 | const isCourseGradeFilterAlertOpen = !limitValidity.isMinValid || !limitValidity.isMaxValid;
14 |
15 | const validityMessages = {
16 | min: limitValidity.isMinValid ? '' : formatMessage(messages.minGradeInvalid),
17 | max: limitValidity.isMaxValid ? '' : formatMessage(messages.maxGradeInvalid),
18 | };
19 |
20 | return {
21 | successBanner: {
22 | onClose: handleCloseSuccessBanner,
23 | show: showSuccessBanner,
24 | text: formatMessage(messages.editSuccessAlert),
25 | },
26 | gradeFilter: {
27 | show: isCourseGradeFilterAlertOpen,
28 | text: `${validityMessages.min}${validityMessages.max}`,
29 | },
30 | };
31 | };
32 | export default useStatusAlertsData;
33 |
--------------------------------------------------------------------------------
/src/components/GradesView/SearchControls/hooks.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from '@edx/frontend-platform/i18n';
2 |
3 | import { actions, selectors, thunkActions } from 'data/redux/hooks';
4 |
5 | import messages from './messages';
6 |
7 | /**
8 | * Controls for filtering the GradebookTable. Contains the "Edit Filters" button for opening the filter drawer
9 | * as well as the search box for searching by username/email.
10 | */
11 | export const useSearchControlsData = () => {
12 | const { formatMessage } = useIntl();
13 | const searchValue = selectors.app.useSearchValue();
14 | const fetchGrades = thunkActions.grades.useFetchGrades();
15 | const setSearchValue = actions.app.useSetSearchValue();
16 |
17 | const onBlur = (e) => {
18 | setSearchValue(e.target.value);
19 | };
20 |
21 | const onClear = () => {
22 | setSearchValue('');
23 | fetchGrades();
24 | };
25 |
26 | const onSubmit = (newValue) => {
27 | setSearchValue(newValue);
28 | fetchGrades();
29 | };
30 |
31 | return {
32 | onSubmit,
33 | onBlur,
34 | onClear,
35 | searchValue,
36 | inputLabel: formatMessage(messages.label),
37 | hintText: formatMessage(messages.hint),
38 | };
39 | };
40 |
41 | export default useSearchControlsData;
42 |
--------------------------------------------------------------------------------
/src/components/GradesView/GradebookTable/Fields.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import { StrictDict } from 'utils';
5 |
6 | /**
7 | * Fields.Username
8 | * simple label field for username, that optionally also displays external_user_key (userKey)
9 | * if it is provided.
10 | * @param {string} username - username for display
11 | * @param {userKey} userKey - external_user_key for display
12 | */
13 | const Username = ({ username, userKey }) => (
14 |
15 |
16 |
17 |
{username}
18 | {userKey &&
{userKey}
}
19 |
20 |
21 |
22 | );
23 | Username.defaultProps = {
24 | userKey: null,
25 | };
26 | Username.propTypes = {
27 | username: PropTypes.string.isRequired,
28 | userKey: PropTypes.string,
29 | };
30 |
31 | /**
32 | * Fields.Text
33 | * Simple label field for text value.
34 | * @param {string} value - value for display
35 | */
36 | const Text = ({ value }) => ({value});
37 | Text.propTypes = {
38 | value: PropTypes.string.isRequired,
39 | };
40 |
41 | export default StrictDict({
42 | Text,
43 | Username,
44 | });
45 |
--------------------------------------------------------------------------------
/src/components/GradesView/InterventionsReport/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useIntl } from '@edx/frontend-platform/i18n';
4 |
5 | import NetworkButton from 'components/NetworkButton';
6 |
7 | import messages from './messages';
8 | import useInterventionsReportData from './hooks';
9 |
10 | /**
11 | *
12 | * Provides download buttons for Bulk Management and Intervention reports, only if
13 | * showBulkManagement is set in redus.
14 | */
15 | export const InterventionsReport = () => {
16 | const { show, handleClick } = useInterventionsReportData();
17 | const { formatMessage } = useIntl();
18 |
19 | if (!show) {
20 | return null;
21 | }
22 |
23 | return (
24 |
25 |
26 | {formatMessage(messages.title)}
27 |
28 |
31 |
32 | {formatMessage(messages.description)}
33 |
34 |
38 |
39 |
40 | );
41 | };
42 |
43 | export default InterventionsReport;
44 |
--------------------------------------------------------------------------------
/src/utils/hoc.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { fireEvent, render } from '@testing-library/react';
3 |
4 | import { withLocation, withNavigate } from './hoc';
5 |
6 | const mockedNavigator = jest.fn();
7 |
8 | jest.mock('react-router-dom', () => ({
9 | useNavigate: () => mockedNavigator,
10 | useLocation: () => ({
11 | pathname: '/current-location',
12 | }),
13 | }));
14 |
15 | // eslint-disable-next-line react/prop-types
16 | const MockComponent = ({ navigate, location }) => (
17 | // eslint-disable-next-line react/button-has-type, react/prop-types
18 |
19 | );
20 | const WrappedComponent = withNavigate(withLocation(MockComponent));
21 |
22 | test('Provide Navigation to Component', () => {
23 | const wrapper = render(
24 | ,
25 | );
26 | const btn = wrapper.container.querySelector('#btn');
27 | fireEvent.click(btn);
28 |
29 | expect(mockedNavigator).toHaveBeenCalledWith('/some-route');
30 | });
31 |
32 | test('Provide Location object to Component', () => {
33 | const wrapper = render(
34 | ,
35 | );
36 |
37 | expect(wrapper.container.querySelector('#btn').textContent).toContain('/current-location');
38 | });
39 |
--------------------------------------------------------------------------------
/src/data/utils.test.js:
--------------------------------------------------------------------------------
1 | import simpleSelectorFactory from './utils';
2 |
3 | describe('Redux utilities - creators', () => {
4 | describe('simpleSelectors', () => {
5 | const data = { a: 1, b: 2, c: 3 };
6 | const state = {
7 | testGroup: data,
8 | other: 'stuff',
9 | };
10 | const transformer = ({ testGroup }) => testGroup;
11 |
12 | test('given a list of strings, returns a dict w/ a simple selector per string', () => {
13 | const keys = ['a', 'b'];
14 | const selectors = simpleSelectorFactory(transformer, keys);
15 | expect(Object.keys(selectors)).toEqual(['root', ...keys]);
16 | expect(selectors.root(state)).toEqual(data);
17 | expect(selectors.a(state)).toEqual(data.a);
18 | expect(selectors.b(state)).toEqual(data.b);
19 | });
20 | test('given an object for keys, returns a dict w/ simple selector per key', () => {
21 | const selectors = simpleSelectorFactory(transformer, data);
22 | expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]);
23 | expect(selectors.root(state)).toEqual(data);
24 | expect(selectors.a(state)).toEqual(data.a);
25 | expect(selectors.b(state)).toEqual(data.b);
26 | expect(selectors.c(state)).toEqual(data.c);
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/ResultsSummary.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import lms from 'data/services/lms';
4 | import { renderWithIntl, screen } from '../../testUtilsExtra';
5 | import ResultsSummary from './ResultsSummary';
6 |
7 | jest.mock('data/services/lms', () => ({
8 | urls: {
9 | bulkGradesUrlByRow: jest.fn((rowId) => (`www.edx.org/${rowId}`)),
10 | },
11 | }));
12 |
13 | describe('ResultsSummary component', () => {
14 | const props = {
15 | rowId: 42,
16 | text: 'texty',
17 | };
18 | let link;
19 | beforeEach(() => {
20 | renderWithIntl();
21 | link = screen.getByRole('link', { name: props.text });
22 | });
23 | test('Hyperlink has target="_blank" and rel="noopener noreferrer"', () => {
24 | expect(link).toHaveAttribute('target', '_blank');
25 | expect(link).toHaveAttribute('rel', 'noopener noreferrer');
26 | });
27 | test('Hyperlink has href to bulkGradesUrl', () => {
28 | expect(link).toHaveAttribute('href', lms.urls.bulkGradesUrlByRow(props.rowId));
29 | });
30 | test('displays Download Icon and text', () => {
31 | expect(link).toHaveTextContent(props.text);
32 | const icon = screen.getByRole('img', { hidden: true });
33 | expect(icon).toBeInTheDocument();
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/data/redux/transforms.test.js:
--------------------------------------------------------------------------------
1 | import selectors from 'data/selectors';
2 |
3 | import { GradeFormats } from 'data/constants/grades';
4 | import transforms from './transforms';
5 |
6 | jest.mock('data/selectors', () => {
7 | const {
8 | GradeFormats: { absolute, percent },
9 | } = jest.requireActual('data/constants/grades');
10 | return {
11 | grades: {
12 | subsectionGrade: {
13 | [absolute]: jest.fn(v => ({ absolute: v })),
14 | [percent]: jest.fn(v => ({ percent: v })),
15 | },
16 | roundGrade: jest.fn(),
17 | },
18 | };
19 | });
20 |
21 | describe('redux transforms', () => {
22 | describe('grades transforms', () => {
23 | test('subsectionGrade', () => {
24 | const subsection = 'test-subsection';
25 | expect(transforms.grades.subsectionGrade({
26 | gradeFormat: GradeFormats.absolute,
27 | subsection,
28 | })()).toEqual(selectors.grades.subsectionGrade.absolute(subsection));
29 | expect(transforms.grades.subsectionGrade({
30 | gradeFormat: GradeFormats.percent,
31 | subsection,
32 | })()).toEqual(selectors.grades.subsectionGrade.percent(subsection));
33 | });
34 | test('roundGrade', () => {
35 | expect(transforms.grades.roundGrade).toEqual(selectors.grades.roundGrade);
36 | });
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentGradeFilter/hooks.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import { selectors, actions, thunkActions } from 'data/redux/hooks';
3 |
4 | const useAssignmentGradeFilterData = ({ updateQueryParams }) => {
5 | const localAssignmentLimits = selectors.app.useAssignmentGradeLimits();
6 | const selectedAssignment = selectors.filters.useSelectedAssignmentLabel();
7 | const fetchGrades = thunkActions.grades.useFetchGrades();
8 | const setFilter = actions.app.useSetLocalFilter();
9 | const updateAssignmentLimits = actions.filters.useUpdateAssignmentLimits();
10 |
11 | const handleSubmit = () => {
12 | updateAssignmentLimits(localAssignmentLimits);
13 | fetchGrades();
14 | updateQueryParams(localAssignmentLimits);
15 | };
16 |
17 | const handleSetMax = ({ target: { value } }) => {
18 | setFilter({ assignmentGradeMax: value });
19 | };
20 |
21 | const handleSetMin = ({ target: { value } }) => {
22 | setFilter({ assignmentGradeMin: value });
23 | };
24 |
25 | const { assignmentGradeMax, assignmentGradeMin } = localAssignmentLimits;
26 | return {
27 | assignmentGradeMin,
28 | assignmentGradeMax,
29 | selectedAssignment,
30 | handleSetMax,
31 | handleSetMin,
32 | handleSubmit,
33 | };
34 | };
35 |
36 | export default useAssignmentGradeFilterData;
37 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentTypeFilter/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { useIntl } from '@edx/frontend-platform/i18n';
6 |
7 | import SelectGroup from '../SelectGroup';
8 | import messages from '../messages';
9 | import useAssignmentTypeFilterData from './hooks';
10 |
11 | export const AssignmentTypeFilter = ({ updateQueryParams }) => {
12 | const {
13 | assignmentTypes,
14 | handleChange,
15 | isDisabled,
16 | selectedAssignmentType,
17 | } = useAssignmentTypeFilterData({ updateQueryParams });
18 | const { formatMessage } = useIntl();
19 | return (
20 |
21 | All,
29 | ...assignmentTypes.map(entry => (
30 |
31 | )),
32 | ]}
33 | />
34 |
35 | );
36 | };
37 |
38 | AssignmentTypeFilter.propTypes = {
39 | updateQueryParams: PropTypes.func.isRequired,
40 | };
41 |
42 | export default AssignmentTypeFilter;
43 |
--------------------------------------------------------------------------------
/src/data/store.js:
--------------------------------------------------------------------------------
1 | import * as redux from 'redux';
2 | import thunkMiddleware from 'redux-thunk';
3 | import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
4 | import { createLogger } from 'redux-logger';
5 | import { createMiddleware } from 'redux-beacon';
6 | import Segment from '@redux-beacon/segment';
7 | import { getConfig } from '@edx/frontend-platform';
8 |
9 | import actions from './actions';
10 | import selectors from './selectors';
11 | import reducers from './reducers';
12 | import eventsMap from './services/segment/mapping';
13 |
14 | export const createStore = (preloadedState = undefined) => {
15 | const loggerMiddleware = createLogger();
16 |
17 | const middleware = [thunkMiddleware, loggerMiddleware];
18 | // Conditionally add the segmentMiddleware only if the SEGMENT_KEY environment variable exists.
19 | if (getConfig().SEGMENT_KEY) {
20 | middleware.push(createMiddleware(eventsMap, Segment()));
21 | }
22 | const store = redux.createStore(
23 | reducers,
24 | composeWithDevTools(redux.applyMiddleware(...middleware)),
25 | preloadedState,
26 | );
27 |
28 | /**
29 | * Dev tools for redux work
30 | */
31 | if (process.env.NODE_ENV === 'development') {
32 | window.store = store;
33 | window.actions = actions;
34 | window.selectors = selectors;
35 | }
36 |
37 | return store;
38 | };
39 |
40 | const store = createStore();
41 |
42 | export default store;
43 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import 'core-js/stable';
2 | import 'regenerator-runtime/runtime';
3 |
4 | import React, { StrictMode } from 'react';
5 | import { createRoot } from 'react-dom/client';
6 |
7 | import {
8 | APP_READY,
9 | initialize,
10 | mergeConfig,
11 | subscribe,
12 | } from '@edx/frontend-platform';
13 |
14 | import lightning from './lightning';
15 |
16 | import messages from './i18n';
17 | import App from './App';
18 |
19 | subscribe(APP_READY, () => {
20 | lightning();
21 |
22 | const root = createRoot(document.getElementById('root'));
23 |
24 | root.render(
25 |
26 |
27 | ,
28 | );
29 | });
30 |
31 | initialize({
32 | handlers: {
33 | config: () => {
34 | mergeConfig({
35 | BASE_URL: process.env.BASE_URL,
36 | LMS_BASE_URL: process.env.LMS_BASE_URL,
37 | LOGIN_URL: process.env.LOGIN_URL,
38 | LOGOUT_URL: process.env.LOGOUT_URL,
39 | CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
40 | REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
41 | DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
42 | SECURE_COOKIES: process.env.NODE_ENV !== 'development',
43 | SEGMENT_KEY: process.env.SEGMENT_KEY,
44 | ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
45 | });
46 | },
47 | },
48 | messages,
49 | requireAuthenticatedUser: true,
50 | });
51 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilterBadges/test.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | /* eslint-disable import/no-named-as-default */
3 | import React from 'react';
4 | import { render, screen } from '@testing-library/react';
5 | import userEvent from '@testing-library/user-event';
6 |
7 | import FilterBadges from '.';
8 |
9 | const order = ['filter1', 'filter2', 'filter3'];
10 | jest.mock('data/constants/filters', () => ({
11 | ...jest.requireActual('data/constants/filters'),
12 | badgeOrder: order,
13 | }));
14 |
15 | // eslint-disable-next-line react/button-has-type
16 | jest.mock('./FilterBadge', () => jest.fn(({ filterName, handleClose }) => ));
17 |
18 | const handleClose = jest.fn();
19 |
20 | describe('FilterBadges', () => {
21 | describe('component', () => {
22 | it('has a filterbadge with handleClose for each filter in badgeOrder', async () => {
23 | render();
24 | const user = userEvent.setup();
25 | const badge1 = screen.getByText(order[0]);
26 | const badge2 = screen.getByText(order[1]);
27 | const badge3 = screen.getByText(order[2]);
28 | expect(badge1).toBeInTheDocument();
29 | expect(badge2).toBeInTheDocument();
30 | expect(badge3).toBeInTheDocument();
31 | await user.click(badge1);
32 | expect(handleClose).toHaveBeenCalled();
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/data/thunkActions/cohorts.test.js:
--------------------------------------------------------------------------------
1 | import lms from 'data/services/lms';
2 |
3 | import actions from 'data/actions';
4 | import * as thunkActions from './cohorts';
5 | import { createTestFetcher } from './testUtils';
6 |
7 | jest.mock('data/services/lms', () => ({
8 | api: {
9 | fetch: { cohorts: jest.fn() },
10 | },
11 | }));
12 |
13 | const responseData = {
14 | data: {
15 | some: 'COHorts',
16 | other: 'cohORT$',
17 | },
18 | };
19 |
20 | describe('cohorts thunkActions', () => {
21 | describe('fetchCohorts', () => {
22 | const testFetch = createTestFetcher(
23 | lms.api.fetch.cohorts,
24 | thunkActions.fetchCohorts,
25 | [],
26 | () => expect(lms.api.fetch.cohorts).toHaveBeenCalledWith(),
27 | );
28 | describe('actions dispatched on valid response', () => {
29 | test('fetching.started, fetching.received', () => testFetch(
30 | (resolve) => resolve(responseData),
31 | [
32 | actions.cohorts.fetching.started(),
33 | actions.cohorts.fetching.received(responseData.data),
34 | ],
35 | ));
36 | });
37 | describe('actions dispatched on api error', () => {
38 | test('fetching.started, fetching.error', () => testFetch(
39 | (resolve, reject) => reject(),
40 | [
41 | actions.cohorts.fetching.started(),
42 | actions.cohorts.fetching.error(),
43 | ],
44 | ));
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentFilter/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { useIntl } from '@edx/frontend-platform/i18n';
6 |
7 | import messages from '../messages';
8 | import SelectGroup from '../SelectGroup';
9 | import useAssignmentFilterData from './hooks';
10 |
11 | const AssignmentFilter = ({ updateQueryParams }) => {
12 | const {
13 | handleChange,
14 | selectedAssignmentLabel,
15 | assignmentFilterOptions,
16 | } = useAssignmentFilterData({ updateQueryParams });
17 | const { formatMessage } = useIntl();
18 | const filterOptions = assignmentFilterOptions.map(({ label, subsectionLabel }) => (
19 |
22 | ));
23 | return (
24 |
25 | All,
33 | ...filterOptions,
34 | ]}
35 | />
36 |
37 | );
38 | };
39 |
40 | AssignmentFilter.propTypes = {
41 | updateQueryParams: PropTypes.func.isRequired,
42 | };
43 |
44 | export default AssignmentFilter;
45 |
--------------------------------------------------------------------------------
/src/data/thunkActions/roles.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/prefer-default-export */
2 | import { StrictDict } from 'utils';
3 | import roles from 'data/actions/roles';
4 | import selectors from 'data/selectors';
5 |
6 | import lms from 'data/services/lms';
7 |
8 | import { fetchCohorts } from './cohorts';
9 | import { fetchGrades } from './grades';
10 | import { fetchTracks } from './tracks';
11 | import { fetchAssignmentTypes } from './assignmentTypes';
12 |
13 | export const allowedRoles = ['staff', 'limited_staff', 'instructor', 'support'];
14 |
15 | export const fetchRoles = () => (
16 | (dispatch, getState) => {
17 | const courseId = selectors.app.courseId(getState());
18 | return lms.api.fetch.roles()
19 | .then(({ data }) => {
20 | const isAllowedRole = (role) => (
21 | (role.course_id === courseId) && allowedRoles.includes(role.role)
22 | );
23 | const canUserViewGradebook = (data.is_staff || (data.roles.some(isAllowedRole)));
24 | dispatch(roles.fetching.received({ canUserViewGradebook }));
25 | if (canUserViewGradebook) {
26 | dispatch(fetchGrades());
27 | dispatch(fetchTracks());
28 | dispatch(fetchCohorts());
29 | dispatch(fetchAssignmentTypes());
30 | }
31 | })
32 | .catch(() => {
33 | dispatch(roles.fetching.error());
34 | });
35 | }
36 | );
37 |
38 | export default StrictDict({
39 | allowedRoles,
40 | fetchRoles,
41 | });
42 |
--------------------------------------------------------------------------------
/src/data/services/segment/mapping.js:
--------------------------------------------------------------------------------
1 | import { trackPageView } from '@redux-beacon/segment';
2 |
3 | import { StrictDict } from 'utils';
4 | import { handleEvent } from './utils';
5 |
6 | import {
7 | courseId,
8 | events,
9 | eventNames,
10 | trackingCategory,
11 | triggers,
12 | } from './constants';
13 |
14 | const eventsMap = {};
15 | const loadTrigger = (event, options = {}) => {
16 | eventsMap[triggers[event]] = handleEvent(eventNames[event], options);
17 | };
18 |
19 | eventsMap[triggers[events.receivedRoles]] = trackPageView(() => ({
20 | category: trackingCategory,
21 | page: courseId,
22 | }));
23 | loadTrigger(events.receivedGrades, {
24 | propsFn: (payload) => ({
25 | assignmentType: payload.assignmentType,
26 | cohort: payload.cohort,
27 | prev: payload.prev,
28 | next: payload.next,
29 | track: payload.track,
30 | }),
31 | });
32 | loadTrigger(events.updateSucceeded, {
33 | propsFn: ({ responseData }) => ({ updatedGrades: responseData }),
34 | });
35 | loadTrigger(events.updateFailed, {
36 | propsFn: ({ error }) => ({ error }),
37 | });
38 | loadTrigger(events.uploadOverrideSucceeded);
39 | loadTrigger(events.uploadOverrideFailed, {
40 | propsFn: ({ error }) => ({ error }),
41 | });
42 | loadTrigger(events.filterApplied, {
43 | extrasFn: () => ({ label: courseId }),
44 | });
45 | loadTrigger(events.gradesReportDownloaded);
46 | loadTrigger(events.interventionReportDownloaded);
47 |
48 | export default StrictDict(eventsMap);
49 |
--------------------------------------------------------------------------------
/src/plugin-slots/FooterSlot/README.md:
--------------------------------------------------------------------------------
1 | # Footer Slot
2 |
3 | ### Slot ID: `org.openedx.frontend.layout.footer.v1`
4 |
5 | ### Slot ID Aliases
6 | * `footer_slot`
7 |
8 | ## Description
9 |
10 | This slot is used to replace/modify/hide the footer.
11 |
12 | The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
13 |
14 | ## Example
15 |
16 | The following `env.config.jsx` will replace the default footer.
17 |
18 | 
19 |
20 | with a simple custom footer
21 |
22 | 
23 |
24 | ```jsx
25 | import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
26 |
27 | const config = {
28 | pluginSlots: {
29 | 'org.openedx.frontend.layout.footer.v1': {
30 | plugins: [
31 | {
32 | // Hide the default footer
33 | op: PLUGIN_OPERATIONS.Hide,
34 | widgetId: 'default_contents',
35 | },
36 | {
37 | // Insert a custom footer
38 | op: PLUGIN_OPERATIONS.Insert,
39 | widget: {
40 | id: 'custom_footer',
41 | type: DIRECT_PLUGIN,
42 | RenderWidget: () => (
43 | 🦶
44 | ),
45 | },
46 | },
47 | ]
48 | }
49 | },
50 | }
51 |
52 | export default config;
53 | ```
54 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilterMenuToggle/index.test.jsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { formatMessage } from 'testUtils';
4 | import { thunkActions } from 'data/redux/hooks';
5 |
6 | import FilterMenuToggle from '.';
7 | import messages from './messages';
8 | import { renderWithIntl } from '../../../testUtilsExtra';
9 |
10 | jest.mock('data/redux/hooks', () => ({
11 | thunkActions: {
12 | app: {
13 | filterMenu: {
14 | useToggleMenu: jest.fn(),
15 | },
16 | },
17 | },
18 | }));
19 |
20 | const toggleFilterMenu = jest.fn().mockName('hooks.toggleFilterMenu');
21 | thunkActions.app.filterMenu.useToggleMenu.mockReturnValue(toggleFilterMenu);
22 |
23 | describe('FilterMenuToggle component', () => {
24 | beforeEach(() => {
25 | jest.clearAllMocks();
26 | renderWithIntl();
27 | });
28 | describe('behavior', () => {
29 | it('initializes redux hooks', () => {
30 | expect(thunkActions.app.filterMenu.useToggleMenu).toHaveBeenCalled();
31 | });
32 | });
33 | describe('renders', () => {
34 | it('button and triggers click', async () => {
35 | const user = userEvent.setup();
36 | const button = screen.getByRole('button', { name: formatMessage(messages.editFilters) });
37 | expect(button).toBeInTheDocument();
38 | await user.click(button);
39 | expect(toggleFilterMenu).toHaveBeenCalled();
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/GradesView/GradebookTable/Fields.test.jsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 |
3 | import Fields from './Fields';
4 |
5 | describe('Gradebook Table Fields', () => {
6 | describe('Username', () => {
7 | const username = 'MyNameFromHere';
8 | describe('with external_user_key', () => {
9 | const props = {
10 | username,
11 | userKey: 'My name from another land',
12 | };
13 | beforeEach(() => {
14 | render();
15 | });
16 | it('wraps external user key and username', () => {
17 | const usernameField = screen.getByText(username);
18 | expect(usernameField).toBeInTheDocument();
19 | const userKeyField = screen.getByText(props.userKey);
20 | expect(userKeyField).toBeInTheDocument();
21 | });
22 | });
23 | describe('without external_user_key', () => {
24 | beforeEach(() => {
25 | render();
26 | });
27 | it('wraps username only', () => {
28 | const usernameField = screen.getByText(username);
29 | expect(usernameField).toBeInTheDocument();
30 | });
31 | });
32 | });
33 |
34 | describe('Text', () => {
35 | const value = 'myTag@place.com';
36 | it('wraps entry value', () => {
37 | render();
38 | const textElement = screen.getByText(value);
39 | expect(textElement).toBeInTheDocument();
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/GradesView/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | // eslint-disable-next-line import/no-extraneous-dependencies
3 | import userEvent from '@testing-library/user-event';
4 | import { render, initializeMocks } from 'testUtilsExtra';
5 |
6 | import useGradesViewData from './hooks';
7 | import GradesView from '.';
8 |
9 | jest.mock('./hooks', () => jest.fn());
10 |
11 | const hookProps = {
12 | stepHeadings: {
13 | filter: 'filter-step-heading',
14 | gradebook: 'gradebook-step-heading',
15 | },
16 | handleFilterBadgeClose: jest.fn().mockName('hooks.handleFilterBadgeClose'),
17 | mastersHint: 'test-masters-hint',
18 | };
19 | useGradesViewData.mockReturnValue(hookProps);
20 |
21 | const updateQueryParams = jest.fn().mockName('props.updateQueryParams');
22 |
23 | let el;
24 | describe('GradesView component', () => {
25 | beforeAll(() => {
26 | initializeMocks();
27 | });
28 | beforeEach(() => {
29 | jest.clearAllMocks();
30 | el = render();
31 | });
32 | describe('render', () => {
33 | test('component to be rendered', () => {
34 | expect(el.container).toBeInTheDocument();
35 | });
36 | test('filterBadges load close behavior from hook', async () => {
37 | const user = userEvent.setup();
38 | await user.click(el.getAllByRole('button', { name: 'close' })[0]); // All the buttons use the same handler
39 | expect(hookProps.handleFilterBadgeClose).toHaveBeenCalled();
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/data/actions/utils.test.js:
--------------------------------------------------------------------------------
1 | import { createAction } from '@reduxjs/toolkit';
2 | import * as utils from './utils';
3 |
4 | jest.mock('@reduxjs/toolkit', () => ({
5 | createAction: (key, ...args) => ({ action: key, args }),
6 | }));
7 |
8 | describe('redux action utils', () => {
9 | describe('formatDateForDisplay', () => {
10 | it('returns the datetime as a formatted string', () => {
11 | // using toLocaleTimeString because mac/linux seems to generate strings
12 | const date = new Date('Jun 3 2021 11:59 AM EDT');
13 | expect(utils.formatDateForDisplay(date)).toEqual(
14 | `June 3, 2021 at ${date.toLocaleTimeString('en-US', utils.timeOptions)}`,
15 | );
16 | });
17 | });
18 | describe('sortAlphaAsc', () => {
19 | it('returns sorting value (-1, 0, 1) by uppercase username', () => {
20 | const sort = (v1, v2) => utils.sortAlphaAsc({ username: v1 }, { username: v2 });
21 | expect(sort('aName', 'ANAme')).toEqual(0);
22 | expect(sort('aName', 'laterName')).toEqual(-1);
23 | expect(sort('laterName', 'aName')).toEqual(1);
24 | });
25 | });
26 | describe('createActionFactory', () => {
27 | it('returns an action creator with the data key', () => {
28 | const dataKey = 'part-of-the-model';
29 | const actionKey = 'an-action';
30 | const args = ['some', 'args'];
31 | expect(utils.createActionFactory(dataKey)(actionKey, ...args)).toEqual(
32 | createAction(`${dataKey}/${actionKey}`, ...args),
33 | );
34 | });
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentFilter/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, initializeMocks } from 'testUtilsExtra';
3 |
4 | import useAssignmentFilterData from './hooks';
5 | import AssignmentFilter from '.';
6 |
7 | jest.mock('./hooks', () => ({ __esModule: true, default: jest.fn() }));
8 |
9 | const handleChange = jest.fn();
10 | const selectedAssignmentLabel = 'test-label';
11 | const assignmentFilterOptions = [
12 | { label: 'label1', subsectionLabel: 'sLabel1' },
13 | { label: 'label2', subsectionLabel: 'sLabel2' },
14 | { label: 'label3', subsectionLabel: 'sLabel3' },
15 | { label: 'label4', subsectionLabel: 'sLabel4' },
16 | ];
17 | useAssignmentFilterData.mockReturnValue({
18 | handleChange,
19 | selectedAssignmentLabel,
20 | assignmentFilterOptions,
21 | });
22 |
23 | const updateQueryParams = jest.fn();
24 |
25 | describe('AssignmentFilter component', () => {
26 | beforeAll(() => {
27 | initializeMocks();
28 | render();
29 | });
30 | describe('render', () => {
31 | test('filter options', () => {
32 | expect(screen.getByRole('combobox', { name: 'Assignment' })).toBeInTheDocument();
33 | expect(screen.getAllByRole('option')).toHaveLength(assignmentFilterOptions.length + 1); // +1 for the default option
34 | expect(screen.getAllByRole('option')[assignmentFilterOptions.length]).toHaveTextContent(assignmentFilterOptions[assignmentFilterOptions.length - 1].label);
35 | });
36 | });
37 | });
38 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/BulkManagementAlerts.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 | import { FormattedMessage } from '@edx/frontend-platform/i18n';
5 | import { connect } from 'react-redux';
6 |
7 | import { Alert } from '@openedx/paragon';
8 |
9 | import selectors from 'data/selectors';
10 | import messages from './messages';
11 |
12 | /**
13 | *
14 | * Alerts to display at the top of the BulkManagement tab
15 | */
16 | export const BulkManagementAlerts = ({
17 | bulkImportError,
18 | uploadSuccess,
19 | }) => (
20 | <>
21 |
26 | {bulkImportError}
27 |
28 |
33 |
34 |
35 | >
36 | );
37 |
38 | BulkManagementAlerts.defaultProps = {
39 | bulkImportError: '',
40 | uploadSuccess: false,
41 | };
42 |
43 | BulkManagementAlerts.propTypes = {
44 | // redux
45 | bulkImportError: PropTypes.string,
46 | uploadSuccess: PropTypes.bool,
47 | };
48 |
49 | export const mapStateToProps = (state) => ({
50 | bulkImportError: selectors.grades.bulkImportError(state),
51 | uploadSuccess: selectors.grades.uploadSuccess(state),
52 | });
53 |
54 | export default connect(mapStateToProps)(BulkManagementAlerts);
55 |
--------------------------------------------------------------------------------
/src/components/GradesView/StatusAlerts/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 |
4 | import useStatusAlertsData from './hooks';
5 | import StatusAlerts from '.';
6 |
7 | jest.mock('./hooks', () => jest.fn());
8 |
9 | const hookProps = {
10 | successBanner: {
11 | onClose: jest.fn().mockName('hooks.successBanner.onClose'),
12 | show: true,
13 | text: 'hooks.success-banner-text',
14 | },
15 | gradeFilter: {
16 | show: true,
17 | text: 'hooks.grade-filter-text',
18 | },
19 | };
20 | useStatusAlertsData.mockReturnValue(hookProps);
21 |
22 | describe('StatusAlerts component', () => {
23 | beforeEach(() => {
24 | jest.clearAllMocks();
25 | render();
26 | });
27 | describe('behavior', () => {
28 | it('initializes component hooks', () => {
29 | expect(useStatusAlertsData).toHaveBeenCalled();
30 | });
31 | });
32 | describe('render', () => {
33 | it('success banner', () => {
34 | const alerts = screen.getAllByRole('alert');
35 | const successAlert = alerts[0];
36 | expect(successAlert).toHaveTextContent(hookProps.successBanner.text);
37 | expect(successAlert).toHaveClass('alert-success');
38 | });
39 | it('grade filter banner', () => {
40 | const alerts = screen.getAllByRole('alert');
41 | const gradeFilter = alerts[1];
42 | expect(gradeFilter).toHaveTextContent(hookProps.gradeFilter.text);
43 | expect(gradeFilter).toHaveClass('alert-danger');
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/CourseGradeFilter/index.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 |
3 | import { Button } from '@openedx/paragon';
4 | import { useIntl } from '@edx/frontend-platform/i18n';
5 |
6 | import messages from '../messages';
7 | import PercentGroup from '../PercentGroup';
8 | import useCourseGradeFilterData from './hooks';
9 |
10 | export const CourseGradeFilter = ({ updateQueryParams }) => {
11 | const {
12 | max,
13 | min,
14 | isDisabled,
15 | handleApplyClick,
16 | } = useCourseGradeFilterData({ updateQueryParams });
17 | const { formatMessage } = useIntl();
18 |
19 | return (
20 | <>
21 |
35 |
36 |
43 |
44 | >
45 | );
46 | };
47 |
48 | CourseGradeFilter.propTypes = {
49 | updateQueryParams: PropTypes.func.isRequired,
50 | };
51 |
52 | export default CourseGradeFilter;
53 |
--------------------------------------------------------------------------------
/src/components/GradebookHeader/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | backToDashboard: {
5 | id: 'gradebook.GradebookHeader.backButton',
6 | defaultMessage: 'Back to Dashboard',
7 | description: 'Button text to take user back to LMS dashboard in Gradebook Header',
8 | },
9 | gradebook: {
10 | id: 'gradebook.GradebookHeader.appLabel',
11 | defaultMessage: 'Gradebook',
12 | description: 'Top-level app title in Gradebook Header component',
13 | },
14 | frozenWarning: {
15 | id: 'gradebook.GradebookHeader.frozenWarning',
16 | defaultMessage: 'The grades for this course are now frozen. Editing of grades is no longer allowed.',
17 | description: 'Warning message in Gradebook Header for frozen messages',
18 | },
19 | unauthorizedWarning: {
20 | id: 'gradebook.GradebookHeader.unauthorizedWarning',
21 | defaultMessage: 'You are not authorized to view the gradebook for this course.',
22 | description: 'Warning message in Gradebook Header when user is not allowed to view the app',
23 | },
24 | toActivityLog: {
25 | id: 'gradebook.GradebookHeader.toActivityLogButton',
26 | defaultMessage: 'View Bulk Management History',
27 | description: 'Button text for button navigating to Bulk Managment Activity Log',
28 | },
29 | toGradesView: {
30 | id: 'gradebook.GradebookHeader.toGradesView',
31 | defaultMessage: 'Return to Gradebook',
32 | description: 'Button text for button navigating to Grades view.',
33 | },
34 | });
35 |
36 | export default messages;
37 |
--------------------------------------------------------------------------------
/src/data/services/lms/utils.js:
--------------------------------------------------------------------------------
1 | import queryString from 'query-string';
2 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
3 | import { filters } from 'data/constants/filters';
4 |
5 | /**
6 | * get(url)
7 | * simple wrapper providing an authenticated Http client get action
8 | * @param {string} url - target url
9 | */
10 | export const get = (...args) => getAuthenticatedHttpClient().get(...args);
11 | /**
12 | * post(url, data)
13 | * simple wrapper providing an authenticated Http client post action
14 | * @param {string} url - target url
15 | * @param {object|string} data - post payload
16 | */
17 | export const post = (...args) => getAuthenticatedHttpClient().post(...args);
18 |
19 | /**
20 | * stringifyUrl(url, query)
21 | * simple wrapper around queryString.stringifyUrl that sets skip behavior
22 | * @param {string} url - base url string
23 | * @param {object} query - query parameters
24 | */
25 | export const stringifyUrl = (url, query) => queryString.stringifyUrl(
26 | { url, query },
27 | { skipNull: true, skipEmptyString: true },
28 | );
29 |
30 | /**
31 | * filterQuery(options)
32 | * Takes current filter object and returns it with only valid filters that are
33 | * set and have non-'All' values
34 | * @param {object} options - filter values
35 | * @return {object} - valid filters that are set and do not equal 'All'
36 | */
37 | export const filterQuery = (options) => Object.values(filters)
38 | .filter(filter => options[filter] && options[filter] !== 'All')
39 | .reduce(
40 | (obj, filter) => ({ ...obj, [filter]: options[filter] }),
41 | {},
42 | );
43 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/ModalHeaders.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { selectors } from 'data/redux/hooks';
3 |
4 | import { render, screen, initializeMocks } from 'testUtilsExtra';
5 | import ModalHeaders from './ModalHeaders';
6 |
7 | jest.mock('data/redux/hooks', () => ({
8 | selectors: {
9 | app: { useModalData: jest.fn() },
10 | grades: { useGradeData: jest.fn() },
11 | },
12 | }));
13 |
14 | const modalData = {
15 | assignmentName: 'test-assignment-name',
16 | updateUserName: 'test-user-name',
17 | };
18 | selectors.app.useModalData.mockReturnValue(modalData);
19 | const gradeData = {
20 | gradeOverrideCurrentEarnedGradedOverride: 'test-current-grade',
21 | gradeOriginalEarnedGraded: 'test-original-grade',
22 | };
23 | selectors.grades.useGradeData.mockReturnValue(gradeData);
24 | initializeMocks();
25 |
26 | describe('ModalHeaders', () => {
27 | beforeEach(() => {
28 | jest.clearAllMocks();
29 | render();
30 | });
31 | describe('render', () => {
32 | test('assignment header', () => {
33 | expect(screen.getByText(modalData.assignmentName)).toBeInTheDocument();
34 | });
35 | test('student header', () => {
36 | expect(screen.getByText(modalData.updateUserName)).toBeInTheDocument();
37 | });
38 | test('originalGrade header', () => {
39 | expect(screen.getByText(gradeData.gradeOriginalEarnedGraded)).toBeInTheDocument();
40 | });
41 | test('currentGrade header', () => {
42 | expect(screen.getByText(gradeData.gradeOverrideCurrentEarnedGradedOverride)).toBeInTheDocument();
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/data/selectors/tracks.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-self-import */
2 | import { StrictDict } from 'utils';
3 | import * as module from './tracks';
4 |
5 | export const mastersKey = 'masters';
6 |
7 | /**
8 | * hasMastersTrack(tracks)
9 | * returns true if at least one track in the list is masters track
10 | * @param {object[]} tracks - list of track objects
11 | * @return {bool} - are any of the tracks a masters track?
12 | */
13 | export const hasMastersTrack = tracks => tracks.some(({ slug }) => slug === mastersKey);
14 |
15 | // Selectors
16 | /**
17 | * allTracks(state)
18 | * returns all tracks resuls from top-level redux state
19 | * @param {object} state - redux state
20 | * @return {object[]} - list of track result entries
21 | */
22 | export const allTracks = state => state.tracks.results || [];
23 |
24 | /**
25 | * stateHasMastersTrack(state)
26 | * returns true if the state has a masters track entry.
27 | * @param {object} state - redux state
28 | * @return {bool} - does the state have a masters track entry?
29 | */
30 | export const stateHasMastersTrack = (state) => module.hasMastersTrack(module.allTracks(state));
31 |
32 | /**
33 | * tracksByName(state)
34 | * returns an object of all tracks keyed by name
35 | * @param {object} state - redux state
36 | * @return {object} - all tracks, keyed by name
37 | */
38 | export const tracksByName = (state) => module.allTracks(state).reduce(
39 | (obj, track) => ({ ...obj, [track.name]: track }),
40 | {},
41 | );
42 |
43 | export default StrictDict({
44 | allTracks,
45 | hasMastersTrack,
46 | stateHasMastersTrack,
47 | tracksByName,
48 | });
49 |
--------------------------------------------------------------------------------
/src/components/GradesView/ImportGradesButton/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/button-has-type, import/no-named-as-default */
2 | import React from 'react';
3 |
4 | import { useIntl } from '@edx/frontend-platform/i18n';
5 |
6 | import { Form } from '@openedx/paragon';
7 |
8 | import NetworkButton from 'components/NetworkButton';
9 | import messages from './messages';
10 | import useImportGradesButtonData from './hooks';
11 |
12 | /**
13 | *
14 | * File-type input wrapped with hidden control such that when a valid file is
15 | * added, it is automattically uploaded.
16 | */
17 | export const ImportGradesButton = () => {
18 | const {
19 | fileInputRef,
20 | gradeExportUrl,
21 | handleClickImportGrades,
22 | handleFileInputChange,
23 | } = useImportGradesButtonData();
24 | const { formatMessage } = useIntl();
25 | return (
26 | <>
27 |
29 |
37 |
38 |
39 |
45 | >
46 | );
47 | };
48 | ImportGradesButton.propTypes = {};
49 |
50 | export default ImportGradesButton;
51 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilteredUsersLabel/index.test.jsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 |
3 | import { selectors } from 'data/redux/hooks';
4 |
5 | import FilteredUsersLabel from '.';
6 | import { renderWithIntl } from '../../../testUtilsExtra';
7 |
8 | jest.mock('data/redux/hooks', () => ({
9 | selectors: {
10 | grades: {
11 | useUserCounts: jest.fn(),
12 | },
13 | },
14 | }));
15 |
16 | const userCounts = {
17 | filteredUsersCount: 100,
18 | totalUsersCount: 123,
19 | };
20 | selectors.grades.useUserCounts.mockReturnValue(userCounts);
21 |
22 | describe('FilteredUsersLabel component', () => {
23 | beforeEach(() => {
24 | jest.clearAllMocks();
25 | });
26 | describe('behavior', () => {
27 | it('initializes redux hooks', () => {
28 | renderWithIntl();
29 | expect(selectors.grades.useUserCounts).toHaveBeenCalled();
30 | });
31 | });
32 | describe('render', () => {
33 | it('null render if totalUsersCount is 0', () => {
34 | selectors.grades.useUserCounts.mockReturnValueOnce({
35 | ...userCounts,
36 | totalUsersCount: 0,
37 | });
38 | const { container } = renderWithIntl();
39 | expect(container.firstChild).toBeNull();
40 | });
41 | it('renders users count correctly', () => {
42 | renderWithIntl();
43 | expect(screen.getByText((text) => text.includes(userCounts.filteredUsersCount))).toBeInTheDocument();
44 | expect(screen.getByText((text) => text.includes(userCounts.totalUsersCount))).toBeInTheDocument();
45 | });
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/data/thunkActions/app.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-self-import */
2 | import { StrictDict } from 'utils';
3 |
4 | import actions from 'data/actions';
5 | import selectors from 'data/selectors';
6 | import { fetchGradeOverrideHistory } from './grades';
7 | import { fetchRoles } from './roles';
8 | import * as module from './app';
9 |
10 | export const initialize = (courseId, urlQuery) => (dispatch) => {
11 | dispatch(actions.app.setCourseId(courseId));
12 | dispatch(actions.filters.initialize(urlQuery));
13 | dispatch(fetchRoles());
14 | };
15 |
16 | export const filterMenu = StrictDict({
17 | close: () => (dispatch, getState) => {
18 | if (selectors.app.filterMenu.open(getState())) {
19 | dispatch(module.filterMenu.toggle());
20 | }
21 | },
22 | handleTransitionEnd: (event) => (dispatch) => {
23 | if (event.currentTarget === event.target) {
24 | dispatch(actions.app.filterMenu.endTransition());
25 | }
26 | },
27 | toggle: () => (dispatch) => {
28 | dispatch(actions.app.filterMenu.startTransition());
29 | const toggleMenu = () => dispatch(actions.app.filterMenu.toggle());
30 | const animationCb = () => window.setTimeout(toggleMenu);
31 | window.requestAnimationFrame(animationCb);
32 | },
33 | });
34 |
35 | export const setModalStateFromTable = ({ userEntry, subsection }) => (
36 | (dispatch) => {
37 | dispatch(fetchGradeOverrideHistory(subsection.module_id, userEntry.user_id));
38 | dispatch(actions.app.setModalStateFromTable({ subsection, userEntry }));
39 | }
40 | );
41 |
42 | export default StrictDict({
43 | initialize,
44 | filterMenu,
45 | setModalStateFromTable,
46 | });
47 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: node_js CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | branches:
9 | - '**'
10 |
11 | jobs:
12 | test:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 |
19 | - name: Setup Nodejs
20 | uses: actions/setup-node@v4
21 | with:
22 | node-version-file: '.nvmrc'
23 |
24 | - name: Install dependencies
25 | run: npm ci
26 |
27 | - name: Unit Tests
28 | run: npm run test
29 |
30 | - name: Validate Package Lock
31 | run: make validate-no-uncommitted-package-lock-changes
32 |
33 | - name: Run Lint
34 | run: npm run lint
35 |
36 | - name: Run Test
37 | run: npm run test
38 |
39 | - name: Run Build
40 | run: npm run build
41 |
42 | - name: Run Coverage
43 | uses: codecov/codecov-action@v5
44 | with:
45 | token: ${{ secrets.CODECOV_TOKEN }}
46 | fail_ci_if_error: true
47 |
48 | - name: Send failure notification
49 | if: ${{ failure() }}
50 | uses: dawidd6/action-send-mail@v6
51 | with:
52 | server_address: email-smtp.us-east-1.amazonaws.com
53 | server_port: 465
54 | username: ${{secrets.EDX_SMTP_USERNAME}}
55 | password: ${{secrets.EDX_SMTP_PASSWORD}}
56 | subject: CI workflow failed in ${{github.repository}}
57 | to: masters-grades@edx.org
58 | from: github-actions
59 | body: CI workflow in ${{github.repository}} failed! For details see "github.com/${{
60 | github.repository }}/actions/runs/${{ github.run_id }}"
61 |
--------------------------------------------------------------------------------
/src/data/constants/app.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 |
3 | export const views = StrictDict({
4 | grades: 'grades',
5 | bulkManagementHistory: 'bulkManagementHistory',
6 | });
7 |
8 | export const modalFieldKeys = StrictDict({
9 | adjustedGradePossible: 'adjustedGradePossible',
10 | adjustedGradeValue: 'adjustedGradeValue',
11 | assignmentName: 'assignmentName',
12 | reasonForChange: 'reasonForChange',
13 | todaysDate: 'todaysDate',
14 | updateModuleId: 'updateModuleId',
15 | updateUserId: 'updateUserId',
16 | updateUserName: 'updateUserName',
17 | open: 'open',
18 | });
19 |
20 | export const localFilterKeys = StrictDict({
21 | assignmentGradeMax: 'assignmentGradeMax',
22 | assignmentGradeMin: 'assignmentGradeMin',
23 | courseGradeMax: 'courseGradeMax',
24 | courseGradeMin: 'courseGradeMin',
25 | });
26 |
27 | /**
28 | * column configuration for bulk management tab's data table
29 | */
30 | export const bulkManagementColumns = [
31 | {
32 | accessor: 'filename',
33 | Header: 'Gradebook',
34 | columnSortable: false,
35 | width: 'col-5',
36 | },
37 | {
38 | accessor: 'resultsSummary',
39 | Header: 'Download Summary',
40 | columnSortable: false,
41 | width: 'col',
42 | },
43 | {
44 | accessor: 'user',
45 | Header: 'Who',
46 | columnSortable: false,
47 | width: 'col-1',
48 | },
49 | {
50 | accessor: 'timeUploaded',
51 | Header: 'When',
52 | columnSortable: false,
53 | width: 'col',
54 | },
55 | ];
56 |
57 | export const gradeOverrideHistoryColumns = StrictDict({
58 | adjustedGrade: 'adjustedGrade',
59 | date: 'date',
60 | grader: 'grader',
61 | reason: 'reason',
62 | });
63 |
--------------------------------------------------------------------------------
/src/components/GradesView/GradebookTable/LabelReplacements.test.jsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 | import { getLocale } from '@edx/frontend-platform/i18n';
3 | import LabelReplacements from './LabelReplacements';
4 | import messages from './messages';
5 | import { renderWithIntl } from '../../../testUtilsExtra';
6 |
7 | const {
8 | TotalGradeLabelReplacement,
9 | UsernameLabelReplacement,
10 | MastersOnlyLabelReplacement,
11 | } = LabelReplacements;
12 |
13 | jest.mock('@edx/frontend-platform/i18n', () => ({
14 | ...jest.requireActual('@edx/frontend-platform/i18n'),
15 | getLocale: jest.fn(),
16 | isRtl: jest.fn(),
17 | }));
18 |
19 | describe('LabelReplacements', () => {
20 | describe('TotalGradeLabelReplacement', () => {
21 | getLocale.mockImplementation(() => 'en');
22 | renderWithIntl();
23 | it('displays overlay tooltip', () => {
24 | const tooltip = screen.getByText(messages.totalGradePercentage.defaultMessage);
25 | expect(tooltip).toBeInTheDocument();
26 | });
27 | });
28 | describe('UsernameLabelReplacement', () => {
29 | it('renders correctly', () => {
30 | renderWithIntl();
31 | expect(screen.getByText(messages.usernameHeading.defaultMessage)).toBeInTheDocument();
32 | });
33 | });
34 | describe('MastersOnlyLabelReplacement', () => {
35 | it('renders correctly', () => {
36 | const message = {
37 | id: 'id',
38 | defaultMessage: 'defaultMessAge',
39 | description: 'desCripTion',
40 | };
41 | renderWithIntl();
42 | expect(screen.getByText(message.defaultMessage)).toBeInTheDocument();
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/data/redux/hooks/actions.js:
--------------------------------------------------------------------------------
1 | import { StrictDict } from 'utils';
2 | import actions from 'data/actions';
3 | import { actionHook } from './utils';
4 |
5 | const app = StrictDict({
6 | useSetLocalFilter: actionHook(actions.app.setLocalFilter),
7 | useSetSearchValue: actionHook(actions.app.setSearchValue),
8 | useSetShowImportSuccessToast: actionHook(actions.app.setShowImportSuccessToast),
9 | useSetView: actionHook(actions.app.setView),
10 | useCloseModal: actionHook(actions.app.closeModal),
11 | useSetModalState: actionHook(actions.app.setModalState),
12 | });
13 |
14 | const filters = StrictDict({
15 | useUpdateAssignment: actionHook(actions.filters.update.assignment),
16 | useUpdateAssignmentLimits: actionHook(actions.filters.update.assignmentLimits),
17 | useUpdateAssignmentType: actionHook(actions.filters.update.assignmentType),
18 | useUpdateCohort: actionHook(actions.filters.update.cohort),
19 | useUpdateCourseGradeLimits: actionHook(actions.filters.update.courseGradeLimits),
20 | useUpdateIncludeCourseRoleMembers: actionHook(actions.filters.update.includeCourseRoleMembers),
21 | useUpdateTrack: actionHook(actions.filters.update.track),
22 | useResetFilters: actionHook(actions.filters.reset),
23 | });
24 |
25 | const grades = StrictDict({
26 | useDoneViewingAssignment: actionHook(actions.grades.doneViewingAssignment),
27 | useDownloadBulkGradesReport: actionHook(actions.grades.downloadReport.bulkGrades),
28 | useDownloadInterventionReport: actionHook(actions.grades.downloadReport.intervention),
29 | useToggleGradeFormat: actionHook(actions.grades.toggleGradeFormat),
30 | useCloseBanner: actionHook(actions.grades.banner.close),
31 | });
32 |
33 | export default StrictDict({
34 | app,
35 | filters,
36 | grades,
37 | });
38 |
--------------------------------------------------------------------------------
/src/data/selectors/cohorts.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-self-import */
2 | import { StrictDict } from 'utils';
3 | import * as module from './cohorts';
4 |
5 | /**
6 | * allCohorts(state)
7 | * returns top-level cohorts results data
8 | * @param {object} state - redux state
9 | * @return {object[]} - list of cohort objects from fetch results
10 | */
11 | export const allCohorts = (state) => state.cohorts.results || [];
12 |
13 | /**
14 | * getCohortById(state, selectedCohortId)
15 | * returns cohort with given id
16 | * @param {object} state - redux state
17 | * @param {number} selectedCohortId - id of cohort to return
18 | * @return {object} cohort with given id.
19 | */
20 | export const getCohortById = (state, selectedCohortId) => {
21 | const cohort = module.allCohorts(state).find(({ id }) => id === selectedCohortId);
22 | return cohort;
23 | };
24 |
25 | /**
26 | * getCohortNameById(state, selectedCohortId)
27 | * return name of cohort with given id
28 | * @param {object} state - redux state
29 | * @param {number} selectedCohortId - id of cohort whose name is requested
30 | * @return {string} - name of cohort with the given id
31 | */
32 | const getCohortNameById = (state, selectedCohortId) => (
33 | module.getCohortById(state, selectedCohortId) || {}
34 | ).name;
35 |
36 | /**
37 | * cohortsByName(state)
38 | * returns an object of all cohorts keyed by name
39 | * @param {object} state - redux state
40 | * @return {object} - all cohorts, keyed by name
41 | */
42 | const cohortsByName = (state) => module.allCohorts(state).reduce(
43 | (obj, cohort) => ({ ...obj, [cohort.name]: cohort }),
44 | {},
45 | );
46 |
47 | export default StrictDict({
48 | cohortsByName,
49 | getCohortById,
50 | getCohortNameById,
51 | allCohorts,
52 | });
53 |
--------------------------------------------------------------------------------
/src/App.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen } from '@testing-library/react';
3 | import App from './App';
4 |
5 | jest.mock('react-router-dom', () => ({
6 | Routes: ({ children }) => children,
7 | Route: ({ element }) => element,
8 | }));
9 |
10 | jest.mock('@edx/frontend-platform/react', () => ({
11 | AppProvider: ({ children }) => children,
12 | }));
13 |
14 | jest.mock('@edx/frontend-component-header', () => ({
15 | __esModule: true,
16 | default: () => Header
,
17 | }));
18 |
19 | jest.mock('@edx/frontend-component-footer', () => ({
20 | FooterSlot: () => Footer
,
21 | }));
22 |
23 | jest.mock('./head/Head', () => ({
24 | __esModule: true,
25 | default: () => Head
,
26 | }));
27 |
28 | jest.mock('containers/GradebookPage', () => ({
29 | __esModule: true,
30 | default: () => Gradebook
,
31 | }));
32 |
33 | describe('App', () => {
34 | beforeEach(() => {
35 | render();
36 | });
37 |
38 | afterEach(() => {
39 | jest.clearAllMocks();
40 | });
41 |
42 | it('renders Head component', () => {
43 | const head = screen.getByText('Head');
44 | expect(head).toBeInTheDocument();
45 | });
46 |
47 | it('renders Header component', () => {
48 | const header = screen.getByText('Header');
49 | expect(header).toBeInTheDocument();
50 | });
51 |
52 | it('renders Footer component', () => {
53 | const footer = screen.getByText('Footer');
54 | expect(footer).toBeInTheDocument();
55 | });
56 |
57 | it('renders main content wrapper', () => {
58 | const main = screen.getByRole('main');
59 | expect(main).toBeInTheDocument();
60 | const gradebook = screen.getByText('Gradebook');
61 | expect(gradebook).toBeInTheDocument();
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/src/components/GradesView/FilterBadges/FilterBadge.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import { Button } from '@openedx/paragon';
5 | import { useIntl } from '@edx/frontend-platform/i18n';
6 |
7 | import { selectors } from 'data/redux/hooks';
8 |
9 | /**
10 | * FilterBadge
11 | * Base filter badge component, that displays a name and a close button.
12 | * If showValue is true, it will also display the included value.
13 | * @param {func} handleClose - close/dismiss filter event, taking a list of filternames
14 | * to reset when the filter badge closes.
15 | * @param {string} filterName - api filter name (for redux connector)
16 | */
17 | export const FilterBadge = ({
18 | filterName,
19 | handleClose,
20 | }) => {
21 | const { formatMessage } = useIntl();
22 | const {
23 | displayName,
24 | isDefault,
25 | hideValue,
26 | value,
27 | connectedFilters,
28 | } = selectors.root.useFilterBadgeConfig(filterName);
29 | if (isDefault) {
30 | return null;
31 | }
32 | return (
33 |
34 |
35 | {formatMessage(displayName)}
36 |
37 | {!hideValue ? `: ${value}` : ''}
38 |
39 |
46 |
47 |
48 |
49 | );
50 | };
51 |
52 | FilterBadge.propTypes = {
53 | handleClose: PropTypes.func.isRequired,
54 | filterName: PropTypes.string.isRequired,
55 | };
56 |
57 | export default FilterBadge;
58 |
--------------------------------------------------------------------------------
/src/components/GradebookHeader/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useIntl } from '@edx/frontend-platform/i18n';
4 | import { Button } from '@openedx/paragon';
5 |
6 | import { instructorDashboardUrl } from 'data/services/lms/urls';
7 | import useGradebookHeaderData from './hooks';
8 | import messages from './messages';
9 |
10 | export const GradebookHeader = () => {
11 | const { formatMessage } = useIntl();
12 | const {
13 | areGradesFrozen,
14 | canUserViewGradebook,
15 | courseId,
16 | handleToggleViewClick,
17 | showBulkManagement,
18 | toggleViewMessage,
19 | } = useGradebookHeaderData();
20 | const dashboardUrl = instructorDashboardUrl();
21 | return (
22 |
23 |
24 | {'<< '}
25 | {formatMessage(messages.backToDashboard)}
26 |
27 |
{formatMessage(messages.gradebook)}
28 |
29 |
{courseId}
30 | {showBulkManagement && (
31 |
34 | )}
35 |
36 | {areGradesFrozen && (
37 |
38 | {formatMessage(messages.frozenWarning)}
39 |
40 | )}
41 | {(canUserViewGradebook === false) && (
42 |
43 | {formatMessage(messages.unauthorizedWarning)}
44 |
45 | )}
46 |
47 | );
48 | };
49 |
50 | export default GradebookHeader;
51 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/ModalHeaders.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useIntl } from '@edx/frontend-platform/i18n';
4 |
5 | import { StrictDict } from 'utils';
6 | import { selectors } from 'data/redux/hooks';
7 |
8 | import messages from './messages';
9 | import HistoryHeader from './HistoryHeader';
10 |
11 | export const HistoryKeys = StrictDict({
12 | assignment: 'assignment',
13 | student: 'student',
14 | originalGrade: 'original-grade',
15 | currentGrade: 'current-grade',
16 | });
17 |
18 | /**
19 | *
20 | * Provides a list of HistoryHeaders for the student name, assignment,
21 | * original grade, and current override grade.
22 | */
23 | export const ModalHeaders = () => {
24 | const { assignmentName, updateUserName } = selectors.app.useModalData();
25 | const { gradeOverrideCurrentEarnedGradedOverride, gradeOriginalEarnedGraded } = selectors.grades.useGradeData();
26 | const { formatMessage } = useIntl();
27 | return (
28 |
29 |
34 |
39 |
44 |
49 |
50 | );
51 | };
52 |
53 | export default ModalHeaders;
54 |
--------------------------------------------------------------------------------
/src/data/reducers/tracks.test.js:
--------------------------------------------------------------------------------
1 | import tracks, { initialState } from './tracks';
2 | import actions from '../actions/tracks';
3 |
4 | const tracksData = [
5 | { someArbitraryField: 'arbitrary data' },
6 | { anotherArbitraryField: 'more arbitrary data' },
7 | ];
8 |
9 | const testingState = {
10 | ...initialState,
11 | results: tracksData,
12 | arbitraryField: 'arbitrary',
13 | };
14 |
15 | describe('tracks reducer', () => {
16 | it('has initial state', () => {
17 | expect(
18 | tracks(undefined, {}),
19 | ).toEqual(initialState);
20 | });
21 |
22 | describe('handling actions.fetching.started', () => {
23 | it('set start fetching to true. Preserve results if existed', () => {
24 | expect(
25 | tracks(testingState, actions.fetching.started()),
26 | ).toEqual({
27 | ...testingState,
28 | startedFetching: true,
29 | });
30 | });
31 | });
32 |
33 | describe('handling actions.fetching.received', () => {
34 | it('replace results then set finish fetching to true and error to false', () => {
35 | const newTracksData = [{ receivedData: 'new data' }];
36 | expect(
37 | tracks(testingState, actions.fetching.received(newTracksData)),
38 | ).toEqual({
39 | ...testingState,
40 | results: newTracksData,
41 | errorFetching: false,
42 | finishedFetching: true,
43 | });
44 | });
45 | });
46 |
47 | describe('handling actions.fetching.error', () => {
48 | it('set finish fetch and error to true. Preserve results if existed.', () => {
49 | expect(
50 | tracks(testingState, actions.fetching.error()),
51 | ).toEqual({
52 | ...testingState,
53 | errorFetching: true,
54 | finishedFetching: true,
55 | });
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/AssignmentGradeFilter/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import { useIntl } from '@edx/frontend-platform/i18n';
5 | import { Button } from '@openedx/paragon';
6 |
7 | import useAssignmentGradeFilterData from './hooks';
8 | import messages from '../messages';
9 | import PercentGroup from '../PercentGroup';
10 |
11 | export const AssignmentGradeFilter = ({ updateQueryParams }) => {
12 | const {
13 | assignmentGradeMin,
14 | assignmentGradeMax,
15 | selectedAssignment,
16 | handleSetMax,
17 | handleSetMin,
18 | handleSubmit,
19 | } = useAssignmentGradeFilterData({ updateQueryParams });
20 | const { formatMessage } = useIntl();
21 | return (
22 |
23 |
30 |
37 |
38 |
47 |
48 |
49 | );
50 | };
51 |
52 | AssignmentGradeFilter.propTypes = {
53 | updateQueryParams: PropTypes.func.isRequired,
54 | };
55 |
56 | export default AssignmentGradeFilter;
57 |
--------------------------------------------------------------------------------
/src/data/actions/testUtils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * testActionTypes(actionTypes, dataKey)
3 | * Takes a list of actionTypes and a module dataKey, and verifies that
4 | * * all actionTypes are unique
5 | * * all actionTypes begin with the dataKey
6 | * @param {string[]} actionTypes - list of action types
7 | * @param {string} dataKey - module data key
8 | */
9 | export const testActionTypes = (actionTypes, dataKey) => {
10 | test('all types are unique', () => {
11 | expect(actionTypes.length).toEqual((new Set(actionTypes)).size);
12 | });
13 | test('all types begin with the module dataKey', () => {
14 | actionTypes.forEach(type => {
15 | expect(type.startsWith(dataKey)).toEqual(true);
16 | });
17 | });
18 | };
19 |
20 | /**
21 | * testAction(action, args, expectedPayload)
22 | * Multi-purpose action creator test function.
23 | * If args/expectedPayload are passed, verifies that it produces the expected output when called
24 | * with the given args.
25 | * If none are passed, (for action creators with basic definition) it tests against a default
26 | * test payload.
27 | * @param {object} action - action creator object/method
28 | * @param {[object]} args - optional payload argument
29 | * @param {[object]} expectedPayload - optional expected payload.
30 | */
31 | export const testAction = (action, args, expectedPayload) => {
32 | const type = action.toString();
33 | if (args) {
34 | if (Array.isArray(args)) {
35 | expect(action(...args)).toEqual({ type, payload: expectedPayload });
36 | } else {
37 | expect(action(args)).toEqual({ type, payload: expectedPayload });
38 | }
39 | } else {
40 | const payload = { test: 'PAYload' };
41 | expect(action(payload)).toEqual({ type, payload });
42 | }
43 | };
44 |
45 | export default {
46 | testAction,
47 | testActionTypes,
48 | };
49 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/StudentGroupsFilter/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import { useIntl } from '@edx/frontend-platform/i18n';
6 |
7 | import messages from '../messages';
8 | import SelectGroup from '../SelectGroup';
9 | import useStudentGroupsFilterData from './hooks';
10 |
11 | const mapOptions = ({ value, name }) => (
12 |
13 | );
14 |
15 | export const StudentGroupsFilter = ({ updateQueryParams }) => {
16 | const { tracks, cohorts } = useStudentGroupsFilterData({ updateQueryParams });
17 | const { formatMessage } = useIntl();
18 | return (
19 | <>
20 |
27 | {formatMessage(messages.trackAll)}
28 | ,
29 | ...tracks.entries.map(mapOptions),
30 | ]}
31 | />
32 |
40 | {formatMessage(messages.cohortAll)}
41 | ,
42 | ...cohorts.entries.map(mapOptions),
43 | ]}
44 | />
45 | >
46 | );
47 | };
48 |
49 | StudentGroupsFilter.propTypes = {
50 | updateQueryParams: PropTypes.func.isRequired,
51 | };
52 |
53 | export default StudentGroupsFilter;
54 |
--------------------------------------------------------------------------------
/src/components/GradesView/GradebookTable/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | fullNameHeading: {
5 | id: 'gradebook.GradesView.table.headings.fullName',
6 | defaultMessage: 'Full Name',
7 | description: 'Gradebook table full name column header',
8 | },
9 | emailHeading: {
10 | id: 'gradebook.GradesView.table.headings.email',
11 | defaultMessage: 'Email',
12 | description: 'Gradebook table email column header',
13 | },
14 | totalGradeHeading: {
15 | id: 'gradebook.GradesView.table.headings.totalGrade',
16 | defaultMessage: 'Total Grade (%)',
17 | description: 'Gradebook table total grade column header',
18 | },
19 | usernameHeading: {
20 | id: 'gradebook.GradesView.table.headings.username',
21 | defaultMessage: 'Username',
22 | description: 'Gradebook table username column header',
23 | },
24 | studentKeyLabel: {
25 | id: 'gradebook.GradesView.table.labels.studentKey',
26 | defaultMessage: 'Student Key',
27 | description: 'Gradebook table Student Key label',
28 | },
29 | usernameLabel: {
30 | id: 'gradebook.GradesView.table.labels.username',
31 | defaultMessage: 'Username',
32 | description: 'Gradebook table username label',
33 | },
34 | totalGradePercentage: {
35 | id: 'gradebook.GradesView.table.totalGradePercentage',
36 | defaultMessage: 'Total Grade values are always displayed as a percentage',
37 | description: 'Gradebook table message that total grades are displayed in percent format',
38 | },
39 | noResultsFound: {
40 | id: 'gradebook.GradesView.table.noResultsFound',
41 | defaultMessage: 'No results found',
42 | description: 'Gradebook table message when no learner results were found',
43 | },
44 | });
45 |
46 | export default messages;
47 |
--------------------------------------------------------------------------------
/src/components/GradebookFilters/StudentGroupsFilter/hooks.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors, thunkActions } from 'data/redux/hooks';
2 |
3 | export const useStudentGroupsFilterData = ({ updateQueryParams }) => {
4 | const selectedCohortEntry = selectors.root.useSelectedCohortEntry();
5 | const selectedTrackEntry = selectors.root.useSelectedTrackEntry();
6 |
7 | const cohorts = selectors.cohorts.useAllCohorts();
8 | const tracks = selectors.tracks.useAllTracks();
9 |
10 | const updateCohort = actions.filters.useUpdateCohort();
11 | const updateTrack = actions.filters.useUpdateTrack();
12 |
13 | const fetchGrades = thunkActions.grades.useFetchGrades();
14 |
15 | const handleUpdateTrack = (event) => {
16 | const selectedTrackItem = tracks.find(track => track.slug === event.target.value);
17 | const track = selectedTrackItem ? selectedTrackItem.slug.toString() : null;
18 | updateQueryParams({ track });
19 | updateTrack(track);
20 | fetchGrades();
21 | };
22 |
23 | const handleUpdateCohort = (event) => {
24 | const selectedCohortItem = cohorts.find(cohort => cohort.id === parseInt(event.target.value, 10));
25 | const cohort = selectedCohortItem ? selectedCohortItem.id.toString() : null;
26 | // the param expected to be cohort_id
27 | updateQueryParams({ cohort });
28 | updateCohort(cohort);
29 | fetchGrades();
30 | };
31 | return {
32 | cohorts: {
33 | value: selectedCohortEntry?.id || '',
34 | isDisabled: cohorts.length === 0,
35 | handleChange: handleUpdateCohort,
36 | entries: cohorts.map(({ id: value, name }) => ({ value, name })),
37 | },
38 | tracks: {
39 | value: selectedTrackEntry?.slug || '',
40 | handleChange: handleUpdateTrack,
41 | entries: tracks.map(({ slug: value, name }) => ({ value, name })),
42 | },
43 | };
44 | };
45 |
46 | export default useStudentGroupsFilterData;
47 |
--------------------------------------------------------------------------------
/src/data/reducers/cohorts.test.js:
--------------------------------------------------------------------------------
1 | import cohorts, { initialState } from './cohorts';
2 | import actions from '../actions/cohorts';
3 |
4 | const cohortsData = [
5 | { arbitraryCohortField: 'some data' },
6 | { anotherArbitraryCohortField: 'some data' },
7 | ];
8 |
9 | const testingState = {
10 | ...initialState,
11 | results: cohortsData,
12 | arbitraryField: 'arbitrary',
13 | };
14 |
15 | describe('cohorts reducer', () => {
16 | it('has initial state', () => {
17 | expect(
18 | cohorts(undefined, {}),
19 | ).toEqual(initialState);
20 | });
21 |
22 | describe('handling actions.fetching.started', () => {
23 | it('sets startedFetching=true', () => {
24 | const expected = {
25 | ...testingState,
26 | startedFetching: true,
27 | };
28 | expect(
29 | cohorts(testingState, actions.fetching.started()),
30 | ).toEqual(expected);
31 | });
32 | });
33 |
34 | describe('handling actions.fetching.received', () => {
35 | it('loads results and sets finishedFetching=true and errorFetching=false', () => {
36 | const newCohortData = [{ newResultFields: 'recieved data' }];
37 | const expected = {
38 | ...testingState,
39 | results: newCohortData,
40 | errorFetching: false,
41 | finishedFetching: true,
42 | };
43 | expect(
44 | cohorts(testingState, actions.fetching.received(newCohortData)),
45 | ).toEqual(expected);
46 | });
47 | });
48 |
49 | describe('handling actions.fetching.error', () => {
50 | it('sets finishedFetching=true and errorFetching=true', () => {
51 | const expected = {
52 | ...testingState,
53 | errorFetching: true,
54 | finishedFetching: true,
55 | };
56 | expect(
57 | cohorts(testingState, actions.fetching.error()),
58 | ).toEqual(expected);
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/src/data/thunkActions/tracks.test.js:
--------------------------------------------------------------------------------
1 | import lms from 'data/services/lms';
2 | import actions from 'data/actions';
3 |
4 | import { createTestFetcher } from './testUtils';
5 |
6 | import { fetchTracks } from './tracks';
7 |
8 | jest.mock('data/services/lms', () => ({
9 | api: {
10 | fetch: { tracks: jest.fn() },
11 | },
12 | }));
13 | jest.mock('data/selectors', () => ({
14 | __esModule: true,
15 | default: {
16 | root: { showBulkManagement: jest.fn(() => false) },
17 | },
18 | }));
19 | jest.mock('./grades', () => ({
20 | fetchBulkUpgradeHistory: jest.fn((...args) => ({ type: 'fetchBulkUpgradeHistory', args })),
21 | }));
22 |
23 | const responseData = {
24 | couse_modes: ['some', 'course', 'modes'],
25 | };
26 |
27 | describe('tracks thunkActions', () => {
28 | describe('fetchTracks', () => {
29 | const testFetch = createTestFetcher(
30 | lms.api.fetch.tracks,
31 | fetchTracks,
32 | [],
33 | () => expect(lms.api.fetch.tracks).toHaveBeenCalledWith(),
34 | );
35 | describe('valid response', () => {
36 | describe('dispatched actions', () => {
37 | const expectedActions = [
38 | 'tracks.fetching.started',
39 | 'tracks.fetching.received with course_modes',
40 | ];
41 | it(`dispatches [${expectedActions.join(', ')}]`, () => testFetch(
42 | (resolve) => resolve({ data: responseData }),
43 | [
44 | actions.tracks.fetching.started(),
45 | actions.tracks.fetching.received(responseData.course_modes),
46 | ],
47 | ));
48 | });
49 | });
50 | describe('actions dispatched on api error', () => {
51 | test('errorFetching', () => testFetch(
52 | (resolve, reject) => reject(),
53 | [
54 | actions.tracks.fetching.started(),
55 | actions.tracks.fetching.error(),
56 | ],
57 | ));
58 | });
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/messages.js:
--------------------------------------------------------------------------------
1 | import { defineMessages } from '@edx/frontend-platform/i18n';
2 |
3 | const messages = defineMessages({
4 | assignmentHeader: {
5 | id: 'gradebook.GradesView.EditModal.headers.assignment',
6 | defaultMessage: 'Assignment',
7 | description: 'Edit Modal Assignment header',
8 | },
9 | currentGradeHeader: {
10 | id: 'gradebook.GradesView.EditModal.headers.currentGrade',
11 | defaultMessage: 'Current Grade',
12 | description: 'Edit Modal Current Grade header',
13 | },
14 | originalGradeHeader: {
15 | id: 'gradebook.GradesView.EditModal.headers.originalGrade',
16 | defaultMessage: 'Original Grade',
17 | description: 'Edit Modal Original Grade header',
18 | },
19 | studentHeader: {
20 | id: 'gradebook.GradesView.EditModal.headers.student',
21 | defaultMessage: 'Student',
22 | description: 'Edit Modal Student header',
23 | },
24 | title: {
25 | id: 'gradebook.GradesView.EditModal.title',
26 | defaultMessage: 'Edit Grades',
27 | description: 'Edit Modal title',
28 | },
29 | closeText: {
30 | id: 'gradebook.GradesView.EditModal.closeText',
31 | defaultMessage: 'Cancel',
32 | description: 'Edit Modal close button text',
33 | },
34 | visibility: {
35 | id: 'gradebook.GradesView.EditModal.contactSupport',
36 | defaultMessage: 'Showing most recent actions (max 5). To see more, please contact support',
37 | description: 'Edit Modal visibility hint message',
38 | },
39 | saveVisibility: {
40 | id: 'gradebook.GradesView.EditModal.saveVisibility',
41 | defaultMessage: 'Note: Once you save, your changes will be visible to students.',
42 | description: 'Edit Modal saved changes effect hint',
43 | },
44 | saveGrade: {
45 | id: 'gradebook.GradesView.EditModal.saveGrade',
46 | defaultMessage: 'Save Grades',
47 | description: 'Edit Modal Save button label',
48 | },
49 | });
50 |
51 | export default messages;
52 |
--------------------------------------------------------------------------------
/src/components/GradesView/InterventionsReport/hooks.test.js:
--------------------------------------------------------------------------------
1 | import { actions, selectors } from 'data/redux/hooks';
2 |
3 | import useInterventionsReportData from './hooks';
4 |
5 | jest.mock('data/redux/hooks', () => ({
6 | actions: {
7 | grades: {
8 | useDownloadInterventionReport: jest.fn(),
9 | },
10 | },
11 | selectors: {
12 | root: {
13 | useInterventionExportUrl: jest.fn(),
14 | useShowBulkManagement: jest.fn(),
15 | },
16 | },
17 | }));
18 |
19 | const downloadReport = jest.fn();
20 | actions.grades.useDownloadInterventionReport.mockReturnValue(downloadReport);
21 | selectors.root.useShowBulkManagement.mockReturnValue(true);
22 | const exportUrl = 'test-intervention-export-url';
23 | selectors.root.useInterventionExportUrl.mockReturnValue(exportUrl);
24 |
25 | let hook;
26 | let oldLocation;
27 | describe('useInterventionsReportData hooks', () => {
28 | beforeEach(() => {
29 | oldLocation = window.location;
30 | delete window.location;
31 | window.location = { assign: jest.fn() };
32 | hook = useInterventionsReportData();
33 | });
34 | afterEach(() => {
35 | window.location = oldLocation;
36 | });
37 | describe('behavior', () => {
38 | it('initializes hooks', () => {
39 | expect(selectors.root.useInterventionExportUrl).toHaveBeenCalled();
40 | expect(selectors.root.useShowBulkManagement).toHaveBeenCalled();
41 | expect(actions.grades.useDownloadInterventionReport).toHaveBeenCalled();
42 | });
43 | });
44 | describe('output', () => {
45 | test('show from showBulkManagement selector', () => {
46 | expect(hook.show).toEqual(true);
47 | });
48 | describe('handleClick', () => {
49 | it('downloads interventions report and navigates to export url', () => {
50 | hook.handleClick();
51 | expect(downloadReport).toHaveBeenCalled();
52 | expect(window.location.assign).toHaveBeenCalledWith(exportUrl);
53 | });
54 | });
55 | });
56 | });
57 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/HistoryTable.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/button-has-type, import/no-named-as-default */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 | import { connect } from 'react-redux';
5 |
6 | import { DataTable } from '@openedx/paragon';
7 |
8 | import { bulkManagementColumns } from 'data/constants/app';
9 | import selectors from 'data/selectors';
10 |
11 | import ResultsSummary from './ResultsSummary';
12 |
13 | export const mapHistoryRows = ({
14 | resultsSummary,
15 | originalFilename,
16 | user,
17 | ...rest
18 | }) => ({
19 | resultsSummary: (),
20 | filename: ({originalFilename}),
21 | user: ({user}),
22 | ...rest,
23 | });
24 |
25 | /**
26 | *
27 | * Table with history of bulk management uploads, including a results summary which
28 | * displays total, skipped, and failed uploads
29 | */
30 | export const HistoryTable = ({
31 | bulkManagementHistory,
32 | }) => (
33 |
40 | );
41 | HistoryTable.defaultProps = {
42 | bulkManagementHistory: [],
43 | };
44 | HistoryTable.propTypes = {
45 | // redux
46 | bulkManagementHistory: PropTypes.arrayOf(PropTypes.shape({
47 | originalFilename: PropTypes.string.isRequired,
48 | user: PropTypes.string.isRequired,
49 | timeUploaded: PropTypes.string.isRequired,
50 | resultsSummary: PropTypes.shape({
51 | rowId: PropTypes.number.isRequired,
52 | text: PropTypes.string.isRequired,
53 | }),
54 | })),
55 | };
56 |
57 | export const mapStateToProps = (state) => ({
58 | bulkManagementHistory: selectors.grades.bulkManagementHistoryEntries(state),
59 | });
60 |
61 | export default connect(mapStateToProps)(HistoryTable);
62 |
--------------------------------------------------------------------------------
/.env.development:
--------------------------------------------------------------------------------
1 | NODE_ENV='development'
2 | PORT=1994
3 | BASE_URL='localhost:1994'
4 | LMS_BASE_URL='http://localhost:18000'
5 | LOGIN_URL='http://localhost:18000/login'
6 | LOGOUT_URL='http://localhost:18000/login'
7 | LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
8 | LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
9 | LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
10 | FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
11 | CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
12 | REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
13 | ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
14 | LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
15 | USER_INFO_COOKIE_NAME='edx-user-info'
16 | SITE_NAME=localhost
17 | DATA_API_BASE_URL='http://localhost:8000'
18 | // LMS_CLIENT_ID should match the lms DOT client application id your LMS containe
19 | LMS_CLIENT_ID='login-service-client-id'
20 | SEGMENT_KEY=''
21 | FEATURE_FLAGS={}
22 | MARKETING_SITE_BASE_URL='http://localhost:18000'
23 | SUPPORT_URL='http://localhost:18000/support'
24 | CONTACT_URL='http://localhost:18000/contact'
25 | OPEN_SOURCE_URL='http://localhost:18000/openedx'
26 | ORDER_HISTORY_URL='http://localhost:1996/orders'
27 | TERMS_OF_SERVICE_URL='http://localhost:18000/terms-of-service'
28 | PRIVACY_POLICY_URL='http://localhost:18000/privacy-policy'
29 | FACEBOOK_URL='https://www.facebook.com'
30 | TWITTER_URL='https://twitter.com'
31 | YOU_TUBE_URL='https://www.youtube.com'
32 | LINKED_IN_URL='https://www.linkedin.com'
33 | REDDIT_URL='https://www.reddit.com'
34 | APPLE_APP_STORE_URL='https://www.apple.com/ios/app-store/'
35 | GOOGLE_PLAY_URL='https://play.google.com/store'
36 | ENTERPRISE_MARKETING_URL='http://example.com'
37 | ENTERPRISE_MARKETING_UTM_SOURCE='example.com'
38 | ENTERPRISE_MARKETING_UTM_CAMPAIGN='example.com Referral'
39 | ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM='Footer'
40 | APP_ID=''
41 | MFE_CONFIG_API_URL=''
42 | DISPLAY_FEEDBACK_WIDGET='false'
43 | # Fallback in local style files
44 | PARAGON_THEME_URLS={}
45 |
--------------------------------------------------------------------------------
/src/components/GradesView/GradebookTable/index.test.jsx:
--------------------------------------------------------------------------------
1 | import { screen } from '@testing-library/react';
2 |
3 | import useGradebookTableData from './hooks';
4 | import GradebookTable from '.';
5 | import { renderWithIntl } from '../../../testUtilsExtra';
6 |
7 | jest.mock('./hooks', () => jest.fn());
8 |
9 | const hookProps = {
10 | columns: [{ Header: 'Username', accessor: 'username' }, { Header: 'Email', accessor: 'email' }, { Header: 'Total Grade', accessor: 'totalGrade' }],
11 | data: [{ username: 'instructor', email: 'instructor@example.com', totalGrade: '100' }, { username: 'student', email: 'student@example.com', totalGrade: '90' }],
12 | grades: ['a', 'few', 'grades'],
13 | nullMethod: jest.fn().mockName('hooks.nullMethod'),
14 | emptyContent: 'empty-table-content',
15 | };
16 |
17 | describe('GradebookTable', () => {
18 | it('renders Datatable correctly', () => {
19 | useGradebookTableData.mockReturnValue(hookProps);
20 | renderWithIntl();
21 | expect(useGradebookTableData).toHaveBeenCalled();
22 | const headers = screen.getAllByRole('columnheader');
23 | expect(headers).toHaveLength(3);
24 | expect(headers[0]).toHaveTextContent(hookProps.columns[0].Header);
25 | expect(headers[1]).toHaveTextContent(hookProps.columns[1].Header);
26 | expect(headers[2]).toHaveTextContent(hookProps.columns[2].Header);
27 | const rows = screen.getAllByRole('row');
28 | expect(rows).toHaveLength(3);
29 | expect(screen.getByText(hookProps.data[0].username)).toBeInTheDocument();
30 | expect(screen.getByText(hookProps.data[0].email)).toBeInTheDocument();
31 | expect(screen.getByText(hookProps.data[0].totalGrade)).toBeInTheDocument();
32 | });
33 | it('renders empty table content when no data is available', () => {
34 | useGradebookTableData.mockReturnValue({
35 | ...hookProps,
36 | data: [],
37 | grades: [],
38 | });
39 | renderWithIntl();
40 | expect(screen.getByText(hookProps.emptyContent)).toBeInTheDocument();
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/GradesView/PageButtons/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { initializeMocks, render, screen } from 'testUtilsExtra';
4 |
5 | import usePageButtonsData from './hooks';
6 | import PageButtons from '.';
7 |
8 | jest.mock('./hooks', () => jest.fn());
9 |
10 | const hookProps = {
11 | prev: {
12 | disabled: false,
13 | onClick: jest.fn().mockName('hooks.prev.onClick'),
14 | text: 'prev-text',
15 | },
16 | next: {
17 | disabled: false,
18 | onClick: jest.fn().mockName('hooks.next.onClick'),
19 | text: 'next-text',
20 | },
21 | };
22 |
23 | describe('PageButtons component', () => {
24 | beforeAll(() => {
25 | jest.clearAllMocks();
26 | initializeMocks();
27 | });
28 | describe('renders enabled buttons', () => {
29 | beforeEach(() => {
30 | usePageButtonsData.mockReturnValue(hookProps);
31 | render();
32 | });
33 | test('prev button enabled', () => {
34 | expect(screen.getByText(hookProps.prev.text)).toBeInTheDocument();
35 | expect(screen.getByText(hookProps.next.text)).toBeEnabled();
36 | });
37 | test('next button enabled', () => {
38 | expect(screen.getByText(hookProps.next.text)).toBeInTheDocument();
39 | expect(screen.getByText(hookProps.prev.text)).toBeEnabled();
40 | });
41 | });
42 |
43 | describe('renders disabled buttons', () => {
44 | beforeAll(() => {
45 | hookProps.prev.disabled = true;
46 | hookProps.next.disabled = true;
47 | });
48 | beforeEach(() => {
49 | usePageButtonsData.mockReturnValue(hookProps);
50 | render();
51 | });
52 | test('prev button disabled', () => {
53 | expect(screen.getByText(hookProps.next.text)).toBeInTheDocument();
54 | expect(screen.getByText(hookProps.prev.text)).toBeDisabled();
55 | });
56 | test('next button disabled', () => {
57 | expect(screen.getByText(hookProps.prev.text)).toBeInTheDocument();
58 | expect(screen.getByText(hookProps.next.text)).toBeDisabled();
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/src/components/BulkManagementHistoryView/BulkManagementAlerts.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import selectors from 'data/selectors';
4 |
5 | import { BulkManagementAlerts, mapStateToProps } from './BulkManagementAlerts';
6 | import { renderWithIntl, screen } from '../../testUtilsExtra';
7 |
8 | jest.mock('data/selectors', () => ({
9 | __esModule: true,
10 | default: {
11 | grades: {
12 | bulkImportError: (state) => ({ bulkImportError: state }),
13 | uploadSuccess: (state) => ({ uploadSuccess: state }),
14 | },
15 | },
16 | }));
17 |
18 | const errorMessage = 'Oh noooooo';
19 |
20 | describe('BulkManagementAlerts', () => {
21 | describe('component', () => {
22 | describe('states of the warnings', () => {
23 | test('no alert shown', () => {
24 | renderWithIntl();
25 | expect(document.querySelectorAll('.alert').length).toEqual(0);
26 | });
27 | test('Just success alert shown', () => {
28 | renderWithIntl();
29 | expect(document.querySelectorAll('.alert-success').length).toEqual(1);
30 | });
31 | test('Just error alert shown', () => {
32 | renderWithIntl();
33 | expect(document.querySelectorAll('.alert-danger').length).toEqual(1);
34 | expect(screen.getByText(errorMessage)).toBeInTheDocument();
35 | });
36 | });
37 | });
38 | describe('mapStateToProps', () => {
39 | let mapped;
40 | const testState = { a: 'puppy', named: 'Ember' };
41 | beforeEach(() => {
42 | mapped = mapStateToProps(testState);
43 | });
44 | test('bulkImportError from grades.bulkImportError', () => {
45 | expect(mapped.bulkImportError).toEqual(selectors.grades.bulkImportError(testState));
46 | });
47 | test('uploadSuccess from grades.uploadSuccess', () => {
48 | expect(mapped.uploadSuccess).toEqual(selectors.grades.uploadSuccess(testState));
49 | });
50 | });
51 | });
52 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | npm-install-%: ## install specified % npm package
2 | npm ci $* --save-dev
3 | git add package.json
4 |
5 | intl_imports = ./node_modules/.bin/intl-imports.js
6 | transifex_utils = ./node_modules/.bin/transifex-utils.js
7 | i18n = ./src/i18n
8 | transifex_input = $(i18n)/transifex_input.json
9 |
10 | # This directory must match .babelrc .
11 | transifex_temp = ./temp/babel-plugin-formatjs
12 |
13 | NPM_TESTS=build i18n_extract lint test is-es5
14 |
15 | .PHONY: test
16 | test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
17 |
18 | .PHONY: test.npm.*
19 | test.npm.%: validate-no-uncommitted-package-lock-changes
20 | test -d node_modules || $(MAKE) requirements
21 | npm run $(*)
22 |
23 | .PHONY: requirements
24 | requirements: ## install ci requirements
25 | npm ci
26 |
27 | i18n.extract:
28 | # Pulling display strings from .jsx files into .json files...
29 | rm -rf $(transifex_temp)
30 | npm run-script i18n_extract
31 |
32 | i18n.concat:
33 | # Gathering JSON messages into one file...
34 | $(transifex_utils) $(transifex_temp) $(transifex_input)
35 |
36 | extract_translations: | requirements i18n.extract i18n.concat
37 |
38 |
39 | # Experimental: OEP-58 Pulls translations using atlas
40 | pull_translations:
41 | rm -rf src/i18n/messages
42 | mkdir src/i18n/messages
43 | cd src/i18n/messages \
44 | && atlas pull $(ATLAS_OPTIONS) \
45 | translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
46 | translations/frontend-component-header/src/i18n/messages:frontend-component-header \
47 | translations/frontend-platform/src/i18n/messages:frontend-platform \
48 | translations/paragon/src/i18n/messages:paragon \
49 | translations/frontend-app-gradebook/src/i18n/messages:frontend-app-gradebook
50 |
51 | $(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-gradebook
52 |
53 | # This target is used by CI.
54 | validate-no-uncommitted-package-lock-changes:
55 | # Checking for package-lock.json changes...
56 | git diff --exit-code package-lock.json
57 |
--------------------------------------------------------------------------------
/src/data/thunkActions/testUtils.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import configureMockStore from 'redux-mock-store';
3 | import thunk from 'redux-thunk';
4 |
5 | const mockStore = configureMockStore([thunk]);
6 |
7 | /** createTestFetcher(mockedMethod, thunkAction, args, onDispatch)
8 | * Creates a testFetch method, which will test a given thunkAction of the form:
9 | * ```
10 | * const = () => (dispatch, getState) => {
11 | * ...
12 | * return .then().catch();
13 | * ```
14 | * The returned function will take a promise handler function, a list of expected actions
15 | * to have been dispatched (objects only), and an optional verifyFn method to be called after
16 | * the fetch has been completed.
17 | *
18 | * @param {fn} mockedMethod - already-mocked api method being exercised by the thunkAction.
19 | * @param {fn} thunkAction - thunkAction to call/test
20 | * @param {array} args - array of args to dispatch the thunkAction with
21 | * @param {[fn]} onDispatch - optional function to be called after dispatch
22 | *
23 | * @return {fn} testFetch method
24 | * @param {fn} resolveFn - promise handler of the form (resolve, reject) => {}.
25 | * should return a call to resolve or reject with response data.
26 | * @param {object[]} expectedActions - array of action objects expected to have been dispatched
27 | * will be verified after the thunkAction resolves
28 | * @param {[fn]} verifyFn - optional function to be called after dispatch
29 | */
30 | export const createTestFetcher = (
31 | mockedMethod,
32 | thunkAction,
33 | args,
34 | onDispatch,
35 | ) => (
36 | resolveFn,
37 | expectedActions,
38 | ) => {
39 | const store = mockStore({});
40 | mockedMethod.mockReturnValue(new Promise(resolve => {
41 | resolve(new Promise(resolveFn));
42 | }));
43 | return store.dispatch(thunkAction(...args)).then(() => {
44 | onDispatch();
45 | if (expectedActions !== undefined) {
46 | expect(store.getActions()).toEqual(expectedActions);
47 | }
48 | });
49 | };
50 |
51 | export default {
52 | createTestFetcher,
53 | };
54 |
--------------------------------------------------------------------------------
/src/components/GradesView/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/sort-comp, react/button-has-type, import/no-named-as-default */
2 | import React from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | import BulkManagementControls from './BulkManagementControls';
6 | import EditModal from './EditModal';
7 | import FilterBadges from './FilterBadges';
8 | import FilteredUsersLabel from './FilteredUsersLabel';
9 | import FilterMenuToggle from './FilterMenuToggle';
10 | import GradebookTable from './GradebookTable';
11 | import ImportSuccessToast from './ImportSuccessToast';
12 | import InterventionsReport from './InterventionsReport';
13 | import PageButtons from './PageButtons';
14 | import ScoreViewInput from './ScoreViewInput';
15 | import SearchControls from './SearchControls';
16 | import SpinnerIcon from './SpinnerIcon';
17 | import StatusAlerts from './StatusAlerts';
18 |
19 | import useGradesViewData from './hooks';
20 |
21 | export const GradesView = ({ updateQueryParams }) => {
22 | const {
23 | stepHeadings,
24 | handleFilterBadgeClose,
25 | mastersHint,
26 | } = useGradesViewData({ updateQueryParams });
27 |
28 | return (
29 | <>
30 |
31 |
32 |
33 |
34 | {stepHeadings.filter}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | {stepHeadings.gradebook}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | * {mastersHint}
58 |
59 |
60 |
61 | >
62 | );
63 | };
64 |
65 | GradesView.propTypes = {
66 | updateQueryParams: PropTypes.func.isRequired,
67 | };
68 |
69 | export default GradesView;
70 |
--------------------------------------------------------------------------------
/src/components/GradesView/EditModal/OverrideTable/index.test.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { screen } from '@testing-library/react';
3 |
4 | import useOverrideTableData from './hooks';
5 | import OverrideTable from '.';
6 | import { renderWithIntl } from '../../../../testUtilsExtra';
7 |
8 | jest.mock('utils', () => ({
9 | ...jest.requireActual('utils'),
10 | formatDateForDisplay: (date) => ({ formatted: date }),
11 | }));
12 | jest.mock('./hooks', () => jest.fn());
13 |
14 | const hookProps = {
15 | hide: false,
16 | data: [
17 | { filename: 'data' },
18 | { resultsSummary: 'test-data' },
19 | ],
20 | columns: [{
21 | Header: 'Gradebook',
22 | accessor: 'filename',
23 | },
24 | {
25 | Header: 'Download Summary',
26 | accessor: 'resultsSummary',
27 | }],
28 | };
29 |
30 | describe('OverrideTable component', () => {
31 | beforeEach(() => {
32 | jest
33 | .clearAllMocks()
34 | .useFakeTimers('modern')
35 | .setSystemTime(new Date('2000-01-01').getTime());
36 | });
37 | describe('hooks', () => {
38 | it('initializes hook data', () => {
39 | useOverrideTableData.mockReturnValue(hookProps);
40 | renderWithIntl();
41 | expect(useOverrideTableData).toHaveBeenCalled();
42 | });
43 | });
44 | describe('behavior', () => {
45 | it('null render if hide', () => {
46 | useOverrideTableData.mockReturnValue({ ...hookProps, hide: true });
47 | renderWithIntl();
48 | expect(screen.queryByRole('table')).toBeNull();
49 | });
50 | it('renders table with correct data', () => {
51 | useOverrideTableData.mockReturnValue(hookProps);
52 | renderWithIntl();
53 | const table = screen.getByRole('table');
54 | expect(table).toBeInTheDocument();
55 | expect(screen.getByText(hookProps.columns[0].Header)).toBeInTheDocument();
56 | expect(screen.getByText(hookProps.columns[1].Header)).toBeInTheDocument();
57 | expect(screen.getByText(hookProps.data[0].filename)).toBeInTheDocument();
58 | expect(screen.getByText(hookProps.data[1].resultsSummary)).toBeInTheDocument();
59 | });
60 | });
61 | });
62 |
--------------------------------------------------------------------------------