├── .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 | edX logo 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 | ![Screenshot of Default Footer](./images/default_footer.png) 19 | 20 | with a simple custom footer 21 | 22 | ![Screenshot of Custom Footer](./images/custom_footer.png) 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 |
22 | 28 | 34 |
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 |
28 | 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 | --------------------------------------------------------------------------------