diff --git a/packages/app/obojobo-repository/client/css/_defaults.scss b/packages/app/obojobo-repository/client/css/_defaults.scss index a8326fc902..cb167286a0 100644 --- a/packages/app/obojobo-repository/client/css/_defaults.scss +++ b/packages/app/obojobo-repository/client/css/_defaults.scss @@ -33,6 +33,11 @@ $color-reward-text: #947d00; $color-obojobo-blue: #0d4fa7; $color-preview: #af1b5c; +$color-nav-highlight: $color-obojobo-blue; +$color-nav-hover: darken($color-highlight, 25%); +$color-nav-bg-selected: lighten($color-obojobo-blue, 62%); +$color-nav-bg-hover: lighten($color-obojobo-blue, 58%); + $size-spacing-vertical-big: 40px; $size-spacing-vertical-half: $size-spacing-vertical-big / 2; $size-spacing-vertical-internal: $size-spacing-vertical-half / 2; diff --git a/packages/app/obojobo-repository/server/routes/stats.js b/packages/app/obojobo-repository/server/routes/stats.js index 766d8370ad..9d9b765189 100644 --- a/packages/app/obojobo-repository/server/routes/stats.js +++ b/packages/app/obojobo-repository/server/routes/stats.js @@ -23,4 +23,18 @@ router res.render('pages/page-stats-server.jsx', props) }) +router + .route('/statsCourses') + .get([requireCurrentUser, requireCanViewStatsPage]) + .get((req, res) => { + const props = { + title: 'StatsCourses', + currentUser: req.currentUser, + // must use webpackAssetPath for all webpack assets to work in dev and production! + appCSSUrl: webpackAssetPath('stats.css'), + appJsUrl: webpackAssetPath('stats.js') + } + res.render('pages/page-stats-server.jsx', props) + }) + module.exports = router diff --git a/packages/app/obojobo-repository/server/routes/stats.test.js b/packages/app/obojobo-repository/server/routes/stats.test.js index 94d2b0ee7c..0dbfdb84f1 100644 --- a/packages/app/obojobo-repository/server/routes/stats.test.js +++ b/packages/app/obojobo-repository/server/routes/stats.test.js @@ -112,4 +112,33 @@ describe('repository stats route', () => { expect(response.statusCode).toBe(200) }) }) + + test('get /statsCourses returns a "not authorized" if the viewer does not have canViewStatsPage', () => { + // always return false - a.k.a. the user does not have the right perms to use this + mockCurrentUser.hasPermission = () => false + + expect.hasAssertions() + + return request(app) + .get('/statsCourses') + .then(response => { + expect(mockStatsComponent).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(401) + }) + }) + + test('get /statsCourses sends the correct props to the Stats component when the user has canViewStatsPage', () => { + expect.hasAssertions() + + return request(app) + .get('/statsCourses') + .then(response => { + expect(mockStatsComponent).toHaveBeenCalledTimes(1) + expect(mockStatsComponentConstructor).toHaveBeenCalledWith({ + title: 'StatsCourses', + currentUser: mockCurrentUser + }) + expect(response.statusCode).toBe(200) + }) + }) }) diff --git a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js index c9071c48eb..46cddee7b2 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js @@ -2,7 +2,11 @@ const { MODE_RECENT, MODE_ALL, MODE_COLLECTION } = require('../repository-consta const debouncePromise = require('debounce-promise') const dayjs = require('dayjs') const advancedFormat = require('dayjs/plugin/advancedFormat') -const { apiGetAssessmentDetailsForDraft } = require('./shared-api-methods') +const { + apiGetAssessmentDetailsForDraft, + apiGetCoursesForDraft, + apiGetAssessmentDetailsForCourse +} = require('./shared-api-methods') dayjs.extend(advancedFormat) // =================== API ======================= @@ -232,6 +236,20 @@ const showAssessmentScoreData = module => ({ promise: apiGetAssessmentDetailsForDraft(module.draftId) }) +const SHOW_COURSES_BY_DRAFT = 'SHOW_COURSES_BY_DRAFT' +const showCoursesByDraft = module => ({ + type: SHOW_COURSES_BY_DRAFT, + meta: { module }, + promise: apiGetCoursesForDraft(module.draftId) +}) + +const SHOW_ASSESSMENTS_BY_COURSE = 'SHOW_ASSESSMENTS_BY_COURSE' +const showCourseAssessmentData = module => ({ + type: SHOW_ASSESSMENTS_BY_COURSE, + meta: { module }, + promise: apiGetAssessmentDetailsForCourse(module.draftId, module.courseId) +}) + const RESTORE_VERSION = 'RESTORE_VERSION' const restoreVersion = (draftId, versionId) => ({ type: RESTORE_VERSION, @@ -664,6 +682,8 @@ module.exports = { IMPORT_MODULE_FILE, CHECK_MODULE_LOCK, SHOW_ASSESSMENT_SCORE_DATA, + SHOW_COURSES_BY_DRAFT, + SHOW_ASSESSMENTS_BY_COURSE, GET_DELETED_MODULES, GET_MODULES, BULK_RESTORE_MODULES, @@ -705,6 +725,8 @@ module.exports = { importModuleFile, checkModuleLock, showAssessmentScoreData, + showCoursesByDraft, + showCourseAssessmentData, getDeletedModules, getModules, bulkRestoreModules diff --git a/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js b/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js index 97c8e4460c..f5a4378597 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js @@ -2,7 +2,9 @@ const dayjs = require('dayjs') const advancedFormat = require('dayjs/plugin/advancedFormat') jest.mock('./shared-api-methods', () => ({ - apiGetAssessmentDetailsForDraft: () => Promise.resolve() + apiGetAssessmentDetailsForDraft: () => Promise.resolve(), + apiGetCoursesForDraft: () => Promise.resolve(), + apiGetAssessmentDetailsForCourse: () => Promise.resolve() })) dayjs.extend(advancedFormat) @@ -1767,6 +1769,30 @@ describe('Dashboard Actions', () => { }) }) + test('showCourseByDraft returns expected object', async () => { + const result = await DashboardActions.showCoursesByDraft(['draft-id-1', 'draft-id-2']) + + expect(result).toEqual({ + type: 'SHOW_COURSES_BY_DRAFT', + meta: { + module: ['draft-id-1', 'draft-id-2'] + }, + promise: expect.any(Promise) + }) + }) + + test('showCourseAssessmentData returns expected object', async () => { + const result = await DashboardActions.showCourseAssessmentData(['draft-id-1', 'draft-id-2']) + + expect(result).toEqual({ + type: 'SHOW_ASSESSMENTS_BY_COURSE', + meta: { + module: ['draft-id-1', 'draft-id-2'] + }, + promise: expect.any(Promise) + }) + }) + test('restoreVersion makes all other sub-calls - draft json as object, restoration errors', () => { // numerous API calls are made in sequence const mockFetchJSON = jest diff --git a/packages/app/obojobo-repository/shared/actions/shared-api-methods.js b/packages/app/obojobo-repository/shared/actions/shared-api-methods.js index 0a990b25a2..49a8441416 100644 --- a/packages/app/obojobo-repository/shared/actions/shared-api-methods.js +++ b/packages/app/obojobo-repository/shared/actions/shared-api-methods.js @@ -21,7 +21,24 @@ const apiGetAssessmentDetailsForDraft = draftId => { const apiGetAssessmentDetailsForMultipleDrafts = draftIds => Promise.all(draftIds.map(id => apiGetAssessmentDetailsForDraft(id))).then(result => result.flat()) +const apiGetCoursesForDraft = draftId => { + return fetch(`/api/courses/${draftId}`, defaultOptions()) + .then(res => res.json()) + .then(res => res.value) +} + +const apiGetAssessmentDetailsForCourse = params => { + return fetch( + `/api/assessments/${params.draftId}/course/${params.contextId}/details`, + defaultOptions() + ) + .then(res => res.json()) + .then(res => res.value) +} + module.exports = { apiGetAssessmentDetailsForMultipleDrafts, - apiGetAssessmentDetailsForDraft + apiGetAssessmentDetailsForDraft, + apiGetCoursesForDraft, + apiGetAssessmentDetailsForCourse } diff --git a/packages/app/obojobo-repository/shared/actions/shared-api-methods.test.js b/packages/app/obojobo-repository/shared/actions/shared-api-methods.test.js index f9c82df766..c8ed558005 100644 --- a/packages/app/obojobo-repository/shared/actions/shared-api-methods.test.js +++ b/packages/app/obojobo-repository/shared/actions/shared-api-methods.test.js @@ -1,6 +1,8 @@ const { apiGetAssessmentDetailsForMultipleDrafts, - apiGetAssessmentDetailsForDraft + apiGetAssessmentDetailsForDraft, + apiGetCoursesForDraft, + apiGetAssessmentDetailsForCourse } = require('./shared-api-methods') describe('sharedAPIMethods', () => { @@ -123,4 +125,43 @@ describe('sharedAPIMethods', () => { } ]) }) + + test('apiGetCoursesForDraft returns expected object', async () => { + const result = await apiGetCoursesForDraft('draft-id-1') + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result).toEqual([ + { + attemptId: 'mock-attempt-1', + draftId: 'draft-id-1', + userRoles: ['A'] + }, + { + attemptId: 'mock-attempt-2', + draftId: 'draft-id-1', + userRoles: ['A'] + } + ]) + }) + + test('apiGetAssessmentDetailsForCourse returns expected object', async () => { + const result = await apiGetAssessmentDetailsForCourse({ + draftId: 'draft-id-1', + contextId: 'context-id-1' + }) + + expect(global.fetch).toHaveBeenCalledTimes(1) + expect(result).toEqual([ + { + attemptId: 'mock-attempt-1', + draftId: 'draft-id-1', + userRoles: ['A'] + }, + { + attemptId: 'mock-attempt-2', + draftId: 'draft-id-1', + userRoles: ['A'] + } + ]) + }) }) diff --git a/packages/app/obojobo-repository/shared/actions/stats-actions.js b/packages/app/obojobo-repository/shared/actions/stats-actions.js index 1d96acd7b8..83ee967dce 100644 --- a/packages/app/obojobo-repository/shared/actions/stats-actions.js +++ b/packages/app/obojobo-repository/shared/actions/stats-actions.js @@ -1,6 +1,9 @@ // =================== API ======================= -const { apiGetAssessmentDetailsForMultipleDrafts } = require('./shared-api-methods') +const { + apiGetAssessmentDetailsForMultipleDrafts, + apiGetAssessmentDetailsForCourse +} = require('./shared-api-methods') // ================== ACTIONS =================== @@ -29,9 +32,17 @@ const loadModuleAssessmentDetails = draftIds => ({ promise: apiGetAssessmentDetailsForMultipleDrafts(draftIds) }) +const LOAD_COURSE_ASSESSMENT_DATA = 'LOAD_COURSE_ASSESSMENT_DATA' +const loadCourseAssessmentData = params => ({ + type: LOAD_COURSE_ASSESSMENT_DATA, + promise: apiGetAssessmentDetailsForCourse(params) +}) + module.exports = { LOAD_STATS_PAGE_MODULES_FOR_USER, LOAD_MODULE_ASSESSMENT_DETAILS, + LOAD_COURSE_ASSESSMENT_DATA, loadUserModuleList, - loadModuleAssessmentDetails + loadModuleAssessmentDetails, + loadCourseAssessmentData } diff --git a/packages/app/obojobo-repository/shared/actions/stats-actions.test.js b/packages/app/obojobo-repository/shared/actions/stats-actions.test.js index e6fbe6d81d..a0e21f191d 100644 --- a/packages/app/obojobo-repository/shared/actions/stats-actions.test.js +++ b/packages/app/obojobo-repository/shared/actions/stats-actions.test.js @@ -1,7 +1,12 @@ -const { loadUserModuleList, loadModuleAssessmentDetails } = require('./stats-actions') +const { + loadUserModuleList, + loadModuleAssessmentDetails, + loadCourseAssessmentData +} = require('./stats-actions') jest.mock('./shared-api-methods', () => ({ - apiGetAssessmentDetailsForMultipleDrafts: () => Promise.resolve() + apiGetAssessmentDetailsForMultipleDrafts: () => Promise.resolve(), + apiGetAssessmentDetailsForCourse: () => Promise.resolve() })) describe('statsActions', () => { @@ -34,4 +39,16 @@ describe('statsActions', () => { promise: expect.any(Promise) }) }) + + test('loadCourseAssessmentData returns expected object', async () => { + const result = await loadCourseAssessmentData({ + draftId: 'draft-id-1', + contextId: 'context-id-2' + }) + + expect(result).toEqual({ + type: 'LOAD_COURSE_ASSESSMENT_DATA', + promise: expect.any(Promise) + }) + }) }) diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/course-score-data-dialog.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/course-score-data-dialog.test.js.snap new file mode 100644 index 0000000000..e61a1f4c61 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/__snapshots__/course-score-data-dialog.test.js.snap @@ -0,0 +1,571 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseScoreDataDialog CourseMenu Menu renders correctly with courses 1`] = ` +
+
+
+ +
+
+ mock-title +
+ +
+
+ +
+
+
+
+ Available Courses +
+
+ Recently Accessed First +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+ +
+
+ Mock Course 1 + ( + mock-course-1 + ) +
+
+ 10 + Learner + s +
+
+ Last Accessed   + + Jun 21o 2023 - 9:33 AM + +
+
+
+
+ Mock Course 2 + ( + mock-course-2 + ) +
+
+ 200 + Learner + s +
+
+ Last Accessed   + + Jun 22o 2023 - 10:33 AM + +
+
+
+
+ Mock Course 3 + ( + mock-course-3 + ) +
+
+ 3000 + Learner + s +
+
+ Last Accessed   + + Jun 23o 2023 - 11:33 AM + +
+
+
+
+ +
+
+
+
+ Select a Course to View Assessment Data +
+
+
+`; + +exports[`CourseScoreDataDialog Modal renders correctly when history is loading 1`] = ` +
+
+
+ +
+
+ mock-title +
+ +
+
+
+ Loading courses... +
+
+
+`; + +exports[`CourseScoreDataDialog Modal renders correctly when no course data 1`] = ` +
+
+
+ +
+
+ mock-title +
+ +
+
+ +
+
+
+
+ Available Courses +
+
+ Recently Accessed First +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+
+ No Courses Found +
+
+
+ +
+
+
+
+ Select a Course to View Assessment Data +
+
+
+`; + +exports[`CourseScoreDataDialog Modal renders correctly with courses 1`] = ` +
+
+
+ +
+
+ mock-title +
+ +
+
+ +
+
+
+
+ Available Courses +
+
+ Recently Accessed First +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+ +
+
+ Mock Course 1 + ( + mock-course-1 + ) +
+
+ 10 + Learner + s +
+
+ Last Accessed   + + Jun 21o 2023 - 9:33 AM + +
+
+
+
+ Mock Course 2 + ( + mock-course-2 + ) +
+
+ 200 + Learner + s +
+
+ Last Accessed   + + Jun 22o 2023 - 10:33 AM + +
+
+
+
+ Mock Course 3 + ( + mock-course-3 + ) +
+
+ 3000 + Learner + s +
+
+ Last Accessed   + + Jun 23o 2023 - 11:33 AM + +
+
+
+
+ +
+
+
+
+ Select a Course to View Assessment Data +
+
+
+`; diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/course-score-data-list-item.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/course-score-data-list-item.test.js.snap new file mode 100644 index 0000000000..7213e5a05c --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/__snapshots__/course-score-data-list-item.test.js.snap @@ -0,0 +1,100 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseScoreDataListItem renders correctly when selected 1`] = ` +
+
+ Mock Course Title + ( + MCT-101 + ) +
+
+ 10 + Learner + s +
+
+ Last Accessed   + + Apr 1st 2020 - 9:15 AM + +
+
+`; + +exports[`CourseScoreDataListItem renders with standard expected props 1`] = ` +
+
+ Mock Course Title + ( + MCT-101 + ) +
+
+ 10 + Learner + s +
+
+ Last Accessed   + + Apr 1st 2020 - 9:15 AM + +
+
+`; + +exports[`CourseScoreDataListItem user count displays properly without the "s" for 1 learner 1`] = ` +
+
+ Mock Course Title + ( + MCT-101 + ) +
+
+ 1 + Learner + +
+
+ Last Accessed   + + Apr 1st 2020 - 9:15 AM + +
+
+`; diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap index a907aa7bba..da4d331d58 100644 --- a/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap +++ b/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap @@ -109,6 +109,21 @@ exports[`ModuleOptionsDialog renders correctly with Full access level 1`] = ` View scores by student. +
+ +
+ View scores by course. +
+
@@ -292,6 +307,21 @@ exports[`ModuleOptionsDialog renders correctly with Minimal access level 1`] = ` View scores by student.
+
+ +
+ View scores by course. +
+
@@ -428,6 +458,21 @@ exports[`ModuleOptionsDialog renders correctly with Partial access level 1`] = ` View scores by student.
+
+ +
+ View scores by course. +
+
@@ -628,6 +673,21 @@ exports[`ModuleOptionsDialog renders correctly with standard expected props 1`] View scores by student.
+
+ +
+ View scores by course. +
+
diff --git a/packages/app/obojobo-repository/shared/components/course-score-data-dialog.jsx b/packages/app/obojobo-repository/shared/components/course-score-data-dialog.jsx new file mode 100644 index 0000000000..18dfe760b8 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/course-score-data-dialog.jsx @@ -0,0 +1,226 @@ +require('./course-score-data-dialog.scss') + +const { CSSTransition } = require('react-transition-group') +const React = require('react') +const ModuleImage = require('./module-image') +const Button = require('./button') +const Loading = require('./loading') +const ReactModal = require('react-modal') +const dayjs = require('dayjs') +const CourseStats = require('./stats/course-stats') +const CourseScoreDataListItem = require('./course-score-data-list-item') +const CourseStatsTypeSelect = require('./stats/course-stats-type-select') +const CourseStatsFilterControls = require('./stats/course-stats-filter-controls') +const CourseStatsSearchControls = require('./stats/course-stats-search-controls') +const Search = require('./search') + +const VIEW_MODE_FINAL_ASSESSMENT_SCORE = 'final-assessment-scores' + +const CourseScoreDataDialog = ({ draftId, title, onClose, isCoursesLoading, courses }) => { + // const [fetchUrl, setFetchUrl] = React.useState(null) + const [selectedIndex, setSelectedIndex] = React.useState(null) + const [selectedCourse, setSelectedCourse] = React.useState(null) + const [courseIsLoading, setCourseIsLoading] = React.useState(false) + const [courseHasLoaded, setCourseHasLoaded] = React.useState(false) + const [courseData, setCourseData] = React.useState(null) + const [isMenuOpen, setIsMenuOpen] = React.useState(true) + const [courseSearch, setCourseSearch] = React.useState('') + + const [searchViewMode, setSearchViewMode] = React.useState(VIEW_MODE_FINAL_ASSESSMENT_SCORE) + const [searchSettings, setSearchSettings] = React.useState('') + const [searchContent, setSearchContent] = React.useState('') + const [searchFilterSettings, setSearchFilterSettings] = React.useState({ + showIncompleteAttempts: false, + showPreviewAttempts: false, + showAdvancedFields: false + }) + + const baseUrl = `/api/assessments/${draftId}` + const menuRef = React.createRef() + + const filterCourses = courses => { + if (courseSearch.trim().length == 0) { + return courses + } + // This turns the search string into an array of words. + const lowerCaseArray = courseSearch + .trim() + .toLowerCase() + .split(' ') + return courses.filter(course => { + const haystack = course.contextTitle.toLowerCase() + course.contextLabel.toLowerCase() + + // If each word in the search can be found in the course title and label, return true + let containsAll = true + lowerCaseArray.forEach(word => { + if (!haystack.includes(word)) { + containsAll = false + } + }) + return containsAll + }) + } + + const filteredCourses = filterCourses(courses) + + const selectCourse = async contextId => { + if (selectedCourse == contextId) { + return + } + setCourseData(null) + setCourseIsLoading(true) + setCourseHasLoaded(false) + filteredCourses.forEach(course => { + if (course.contextId == contextId) { + setSelectedCourse(course) + } + }) + + const newFetchUrl = `${baseUrl}/course/${contextId}/details` + setSelectedIndex(contextId) + const courseData = await runFetch(newFetchUrl) + loadCourseData(courseData) + } + + const runFetch = fetchUrl => { + return fetch(fetchUrl, {}) + .then(res => res.json()) + .then(res => res.value) + } + + const loadCourseData = courseData => { + setCourseData(courseData) + setCourseIsLoading(false) + setCourseHasLoaded(true) + } + + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen) + } + + const renderMenuToggleButton = () => { + return ( + + ) + } + + const renderCourseMenu = () => { + return ( + +
+
+
+
Available Courses
+
Recently Accessed First
+
+ +
+ {renderMenuToggleButton()} +
+ {filteredCourses.length === 0 ? ( +
No Courses Found
+ ) : ( + '' + )} + {filteredCourses.map(course => ( + + ))} +
+
{renderMenuToggleButton()}
+
+
+ ) + } + + const changeViewMode = mode => { + setSearchViewMode(mode) + } + + const validCourseSelected = selectedIndex !== null + const currentCourseTitle = + validCourseSelected && selectedCourse + ? `${selectedCourse.contextTitle} (${selectedCourse.contextLabel})` + : 'Select a Course to View Assessment Data' + const currentCourseAccessed = + validCourseSelected && selectedCourse + ? `Last Accessed ${dayjs(selectedCourse.lastAccessed).format('MMM Do YYYY - h:mm A')}` + : '' + + return ( +
+
+ +
{title}
+ +
+
+ + {renderCourseMenu()} + + {validCourseSelected ? ( +
+
+
+
{currentCourseTitle}
+ {currentCourseAccessed} +
+
+ +
+
+
+ + +
+ {courseHasLoaded ? ( + + ) : ( +
+ {!courseIsLoading ? '' : 'Loading course data...'} +
+ )} +
+ ) : ( +
Select a Course to View Assessment Data
+ )} +
+
+
+ ) +} + +module.exports = CourseScoreDataDialog diff --git a/packages/app/obojobo-repository/shared/components/course-score-data-dialog.scss b/packages/app/obojobo-repository/shared/components/course-score-data-dialog.scss new file mode 100644 index 0000000000..4bfca10a0c --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/course-score-data-dialog.scss @@ -0,0 +1,316 @@ +@import '../../client/css/defaults'; + +$border-color: rgba(0, 0, 0, 0.1); +$border: 1px solid $border-color; +$menu-transition-time: 0.25s; + +.course-score-data-dialog { + font-family: $font-default; + width: calc(100vw - 3em); + + .loading--container { + height: 100%; + position: absolute; + top: 50%; + width: 100%; + text-align: center; + } +} + +.course-score-data-dialog--header { + height: 3em; + box-sizing: border-box; + position: relative; + border-bottom: $border; + + .repository--module-icon--image { + width: 1.5em; + height: 1.75em; + display: inline-block; + vertical-align: middle; + position: absolute; + left: 0.75em; + top: 50%; + transform: translate(0, -50%); + } + + .title { + position: absolute; + left: 4em; + font-size: 0.725em; + font-weight: bold; + color: black; + margin: 0; + max-width: 50%; + vertical-align: middle; + top: 50%; + transform: translate(0, -50%); + } + + .close-button { + position: absolute; + right: 0.5em; + top: 50%; + transform: translate(0, -50%); + } +} + +.course-score-data-dialog--body { + background-color: #f2f2f2; + display: flex; + height: calc(95vh - 3em); + width: 100%; + max-width: 100%; + + span { + display: block; + } +} + +.text-loader { + width: 100%; + text-align: center; + padding: 24px; +} + +.no-courses-text { + text-align: center; + padding: 24px; +} + +.course-score-data-list { + $open-width-max: 20em; + $open-width-min: 15em; + $closed-width: 1.5em; + + max-width: $open-width-max; + min-width: $open-width-min; + background-color: white; + border-right: $border; + overflow-y: auto; + overflow-x: hidden; + transition: min-width $menu-transition-time, max-width $menu-transition-time; + + .menu-collapsed { + display: none; + position: relative; + height: 100%; + background-color: white; + padding-top: 0.9em; + } + + .toggle-button { + background-image: url('../../../obojobo-document-engine/src/scripts/viewer/components/arrow.svg'); + background-color: transparent; + background-position: center center; + background-repeat: no-repeat; + border: none; + cursor: pointer; + height: 1.32rem; + overflow: hidden; + text-indent: -9999px; + transition: background-color $menu-transition-time; + transition: transform $menu-transition-time; + width: 1.6rem; + margin-right: 0.5em; + position: absolute; + top: 1em; + right: 0; + } + + // Classes used by CSSTransition when the + // menu is collapsed/expanded + &.exit-done, + &.enter-active { + overflow-y: hidden; + + .menu-expanded { + display: none; + } + + .menu-collapsed { + display: block; + } + + .toggle-button { + transform: rotate(180deg); + float: none; + margin: 0 auto; + display: block; + + &:not(:hover) { + background-image: url('../../../obojobo-document-engine/src/scripts/viewer/components/hamburger.svg'); + } + } + } + + &.enter-active { + min-width: 0; + max-width: 0; + } + + &.exit { + min-width: 0; + max-width: 0; + } + + &.exit-done { + min-width: $closed-width; + max-width: $closed-width; + } + + .course-score-data-list--title { + white-space: nowrap; // prevent wrapping during animation + padding: 0.9em 0 0.9em 1.5em; + font-weight: bold; + font-size: 0.8em; + width: 100%; + box-sizing: border-box; + background-color: white; + position: sticky; + top: 0; + border-bottom: $border; + + .desc { + font-weight: 300; + font-size: 0.8em; + font-style: italic; + color: #6f6e6e; + } + + .course-search { + padding-right: 1.5em; + padding-top: 0.5em; + font-size: 1.25em; + } + } + + .course-score-data-list--item { + border-bottom: $border; + padding: 1em 1.5em; + background-color: white; + font-size: 0.75em; + min-width: 14em; + cursor: pointer; + + .courseTitle { + margin-bottom: 0.4em; + font-weight: bold; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .userCount { + color: $color-text-minor; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + margin-bottom: 0.2em; + } + + .accessed { + color: #0d4fa7; + margin-top: 0.4em; + font-size: 0.8em; + white-space: nowrap; + + .timestamp { + display: inline; + background-color: lighten($color-nav-highlight, 55%); + color: $color-nav-highlight; + text-align: center; + border-radius: 5em; + margin-top: 0.6em; + padding: 0.2em 0.75em; + } + } + + &.is-selected { + background-image: url('./hex_transparent.svg'); + background-repeat: no-repeat; + background-origin: content-box; + background-position-x: 100%; + background-color: $color-nav-bg-selected; + + .courseTitle { + color: $color-nav-highlight; + } + } + + &:hover { + background-color: $color-nav-bg-hover; + + .courseTitle { + color: $color-nav-hover; + } + } + } +} + +.data-viewer { + flex: 4; + padding: 1em; + justify-content: center; + align-items: center; + background-color: #f2f2f2; + overflow-x: auto; + + .data-viewer--header { + padding: 0.8em; + border: $border; + background-color: $color-nav-bg-selected; + display: flex; + flex-direction: row; + justify-content: space-between; + height: 104px; + box-sizing: border-box; + overflow-y: hidden; + + .data-viewer--header--title-container { + display: flex; + flex-grow: 1; + flex-shrink: 1; + flex-direction: column; + justify-content: space-between; + + .data-viewer--header--title { + font-weight: bold; + } + + small { + display: block; + color: $color-text-minor; + margin-top: 0.5em; + font-size: 0.7em; + } + } + + .data-viewer--header--filter-container { + font-size: 0.75em; + } + } + + .data-viewer--filter-controls { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0.5em 16px; + box-sizing: border-box; + background-color: white; + border: 1px solid $border-color; + border-top: none; + height: 46px; + max-height: 46px; + + .view-mode { + font-size: 0.8em; + + select { + font-size: 1.25em; + margin-left: 0.5em; + } + } + } +} diff --git a/packages/app/obojobo-repository/shared/components/course-score-data-dialog.test.js b/packages/app/obojobo-repository/shared/components/course-score-data-dialog.test.js new file mode 100644 index 0000000000..c4ee3293ac --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/course-score-data-dialog.test.js @@ -0,0 +1,382 @@ +import React from 'react' +import Loading from './loading' +import { act, create } from 'react-test-renderer' +import CourseScoreDataDialog from './course-score-data-dialog' +import CourseScoreDataListItem from './course-score-data-list-item' +import CourseStatsTypeSelect from './stats/course-stats-type-select' + +const VIEW_MODE_FINAL_ASSESSMENT_SCORE = 'final-assessment-scores' +const VIEW_MODE_ALL_ATTEMPTS = 'all-attempts' + +jest.mock('./stats/course-stats', () => props => { + return +}) + +jest.mock('react-transition-group', () => ({ + // eslint-disable-next-line react/display-name + CSSTransition: props => {props.children} +})) + +describe('CourseScoreDataDialog', () => { + const originalFetch = global.fetch + + const dialogProps = () => ({ + draftId: 'mock-draft-id', + title: 'mock-title', + onClose: jest.fn(), + isCoursesLoading: true, + courses: [] + }) + + const courses = [ + { + contextTitle: 'Mock Course 1', + contextLabel: 'mock-course-1', + userCount: '10', + lastAccessed: '2023-06-21 13:33:03.34966+00', + contextId: 'S3294471' + }, + { + contextTitle: 'Mock Course 2', + contextLabel: 'mock-course-2', + userCount: '200', + lastAccessed: '2023-06-22 14:33:03.34966+00', + contextId: 'S3294472' + }, + { + contextTitle: 'Mock Course 3', + contextLabel: 'mock-course-3', + userCount: '3000', + lastAccessed: '2023-06-23 15:33:03.34966+00', + contextId: 'S3294473' + } + ] + + beforeEach(() => { + jest.resetAllMocks() + + global.fetch = jest.fn() + }) + + afterAll(() => { + global.fetch = originalFetch + }) + + test('Modal renders correctly when history is loading', () => { + const component = create() + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Modal renders correctly when no course data', () => { + const component = create() + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Modal renders correctly with courses', () => { + const component = create( + + ) + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Modal - clicking the close button calls onClose', () => { + const onClose = jest.fn() + const component = create() + + expect(onClose).not.toHaveBeenCalled() + component.root + .findByProps({ className: 'close-button' }) + .props.onClick({ target: 'mock-target' }) + expect(onClose).toHaveBeenCalledWith({ target: 'mock-target' }) + }) + + describe('CourseMenu', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + const assignments = [ + { + user_username: 'sis:tstlearner15', + user_first_name: 'Test4', + user_last_name: 'Learner4', + user_roles: '{Learner}', + assessment_score_id: 13, + user_id: 15, + draft_id: 'ff85b3d8-a6c8-4446-a25a-bf0c16d5a8b9', + assessment_id: 'my-assessment', + attempt_id: '33d4335b-48fb-4897-aedd-7e6abc17e3fb', + assessment_score: 100, + is_preview: false, + assessment_score_details: { + status: 'passed', + rewardTotal: 0, + attemptScore: 100, + rewardedMods: [], + attemptNumber: 1, + assessmentScore: 100, + assessmentModdedScore: 100 + }, + draft_content_id: '8d108c19-4f67-4237-a78d-c9827af4053e', + is_imported: false, + imported_assessment_score_id: null, + attempt_state: { + chosen: [ + { + id: 'e32101dd-8f0e-4bec-b519-968270efb426', + type: 'ObojoboDraft.Chunks.Question' + }, + { + id: 'f1ebeb0e-606f-4d1e-b8b9-2ba4fbfbfa0f', + type: 'ObojoboDraft.Chunks.Question' + }, + { + id: 'fa4c4db0-30f1-4386-9ce9-956aaf228378', + type: 'ObojoboDraft.Chunks.Question' + }, + { + id: '5345c7c6-ac69-4e9f-80da-6037765fa612', + type: 'ObojoboDraft.Chunks.QuestionBank' + }, + { + id: 'af4ea08a-e488-4156-a2a0-c0f9a64c58eb', + type: 'ObojoboDraft.Chunks.QuestionBank' + } + ] + }, + attempt_result: { + attemptScore: 100, + questionScores: [ + { + id: 'e32101dd-8f0e-4bec-b519-968270efb426', + score: 100 + }, + { + id: 'f1ebeb0e-606f-4d1e-b8b9-2ba4fbfbfa0f', + score: 100 + }, + { + id: 'fa4c4db0-30f1-4386-9ce9-956aaf228378', + score: 100 + } + ] + }, + created_at: '2023-06-20 14:32:55.07198+00', + completed_at: '2023-06-20 14:33:03.34966+00', + resource_link_id: 'course_3', + imported_attempt_id: null, + lti_status: 'success', + lti_status_details: null, + lti_gradebook_status: 'ok_gradebook_matches_assessment_score', + lti_score_sent: 1, + context_id: 'S3294478', + course_title: 'Obojobo Local Dev 103', + resource_link_title: null, + launch_presentation_return_url: null, + module_title: 'Obojobo Example Document Copy' + } + ] + + test('Menu renders correctly with courses', () => { + const component = create( + + ) + + expect( + component.root.findAllByProps({ className: 'course-score-data-list--item' }).length + ).toBe(3) + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Menu filters correctly with text that matches all items', () => { + const component = create( + + ) + + const courseSearch = component.root + .findAllByProps({ className: 'course-search' })[0] + .findByType('input') + // execute + act(() => { + const mockChangeEvent = { target: { value: 'mock' } } + courseSearch.props.onChange(mockChangeEvent) + }) + expect( + component.root.findAllByProps({ className: 'course-score-data-list--item' }).length + ).toBe(3) + }) + + test('Menu filters correctly with text that matches no items', () => { + const component = create( + + ) + + const courseSearch = component.root + .findAllByProps({ className: 'course-search' })[0] + .findByType('input') + // execute + act(() => { + const mockChangeEvent = { target: { value: 'unused-mock' } } + courseSearch.props.onChange(mockChangeEvent) + }) + expect( + component.root.findAllByProps({ className: 'course-score-data-list--item' }).length + ).toBe(0) + }) + + test('Menu filters correctly with text that matches some items', () => { + const component = create( + + ) + + const courseSearch = component.root + .findAllByProps({ className: 'course-search' })[0] + .findByType('input') + // execute + act(() => { + const mockChangeEvent = { target: { value: 'mock-course-3' } } + courseSearch.props.onChange(mockChangeEvent) + }) + expect( + component.root.findAllByProps({ className: 'course-score-data-list--item' }).length + ).toBe(1) + }) + + test('Menu hides and expands correctly', () => { + const componentTemplate = ( + + ) + + let component + act(() => { + component = create(componentTemplate) + }) + + // The menu starts out open (prop: in == true) + expect(component.root.findByType(Loading).children[0].props.in).toBe(true) + + // component.root.toggleMenu = jest.fn() + // const toggleButtons = component.root.findAllByProps({ className: 'toggle-button' }) + // toggleButtons[0].props.onClick() + const courseList = component.root.findByProps({ className: 'course-score-data-list' }) + const toggleButtons = courseList.findAllByType('button') + + act(() => { + toggleButtons[0].props.onClick() + component.update(componentTemplate) + }) + expect(component.root.findByType(Loading).children[0].props.in).toBe(false) + + // Click again to open (prop: in == true) + act(() => { + toggleButtons[1].props.onClick() + component.update(componentTemplate) + }) + expect(component.root.findByType(Loading).children[0].props.in).toBe(true) + }) + + test('Menu selects course when clicked on', async () => { + const mockFetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ value: assignments }) + }) + ) + global.fetch = mockFetch + + const component = create( + + ) + + const course1Button = component.root.findAllByType(CourseScoreDataListItem)[0] + const course2Button = component.root.findAllByType(CourseScoreDataListItem)[1] + const course3Button = component.root.findAllByType(CourseScoreDataListItem)[2] + const course1ButtonIndex = course1Button.props.index + const course2ButtonIndex = course2Button.props.index + const course3ButtonIndex = course3Button.props.index + + // Initially, no buttons are selected + expect(course1Button.props.isSelected).toBe(false) + expect(course2Button.props.isSelected).toBe(false) + expect(course3Button.props.isSelected).toBe(false) + + // Test if nothing is sent: this is to test for if(selectedCourse == contextId) + await act(async () => { + await course3Button.props.courseClick(null) + }) + expect(course1Button.props.isSelected).toBe(false) + expect(course2Button.props.isSelected).toBe(false) + expect(course3Button.props.isSelected).toBe(false) + + // Click the first course, and it should be selected + await act(async () => { + await course1Button.props.courseClick(course1ButtonIndex) + }) + expect(course1Button.props.isSelected).toBe(true) + expect(course2Button.props.isSelected).toBe(false) + expect(course3Button.props.isSelected).toBe(false) + + // Click the second course, and it should be selected and the first should deselect. + await act(async () => { + await course2Button.props.courseClick(course2ButtonIndex) + }) + expect(course1Button.props.isSelected).toBe(false) + expect(course2Button.props.isSelected).toBe(true) + expect(course3Button.props.isSelected).toBe(false) + + // Click the second course AGAIN, and it should stay selected. + await act(async () => { + await course2Button.props.courseClick(course2ButtonIndex) + }) + expect(course1Button.props.isSelected).toBe(false) + expect(course2Button.props.isSelected).toBe(true) + expect(course3Button.props.isSelected).toBe(false) + + // Click the third course, and it should be selected + await act(async () => { + await course3Button.props.courseClick(course3ButtonIndex) + }) + expect(course1Button.props.isSelected).toBe(false) + expect(course2Button.props.isSelected).toBe(false) + expect(course3Button.props.isSelected).toBe(true) + }) + + test('Changing the view type is caught and saved', async () => { + const mockFetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ value: assignments }) + }) + ) + global.fetch = mockFetch + + const component = create( + + ) + + const course1Button = component.root.findAllByType(CourseScoreDataListItem)[0] + const course1ButtonIndex = course1Button.props.index + + // Initially, no buttons are selected + expect(course1Button.props.isSelected).toBe(false) + + // Click the first course, and it should be selected + await act(async () => { + await course1Button.props.courseClick(course1ButtonIndex) + }) + expect(course1Button.props.isSelected).toBe(true) + + const typeSelector = component.root.findByType(CourseStatsTypeSelect) + expect(typeSelector.props.viewMode).toBe(VIEW_MODE_FINAL_ASSESSMENT_SCORE) + act(() => { + typeSelector.props.onChangeViewMode(VIEW_MODE_ALL_ATTEMPTS) + }) + expect(typeSelector.props.viewMode).toBe(VIEW_MODE_ALL_ATTEMPTS) + }) + }) +}) diff --git a/packages/app/obojobo-repository/shared/components/course-score-data-list-item.jsx b/packages/app/obojobo-repository/shared/components/course-score-data-list-item.jsx new file mode 100644 index 0000000000..8e903f0eb3 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/course-score-data-list-item.jsx @@ -0,0 +1,43 @@ +const React = require('react') + +// This component is the clickable list item for the "Course Stats" dialog. Each instance is a different available course. +const CourseScoreDataListItem = ({ + courseTitle, + courseLabel, + courseUserCount, + courseLastAccessed, + courseClick, + isSelected, + index +}) => { + const onClickHandler = React.useCallback(() => { + courseClick(index) + }, [index]) + + const className = 'course-score-data-list--item' + (isSelected ? ' is-selected' : '') + + const titleAndLabel = ( +
+ {courseTitle} ({courseLabel}) +
+ ) + const userCount = ( +
+ {courseUserCount} Learner{courseUserCount === 1 ? '' : 's'} +
+ ) + const accessed = ( +
+ Last Accessed   {courseLastAccessed} +
+ ) + return ( +
+ {titleAndLabel} + {userCount} + {accessed} +
+ ) +} + +module.exports = CourseScoreDataListItem diff --git a/packages/app/obojobo-repository/shared/components/course-score-data-list-item.test.js b/packages/app/obojobo-repository/shared/components/course-score-data-list-item.test.js new file mode 100644 index 0000000000..2453a6cb88 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/course-score-data-list-item.test.js @@ -0,0 +1,45 @@ +import React from 'react' +import { create } from 'react-test-renderer' + +import CourseScoreDataListItem from './course-score-data-list-item' + +describe('CourseScoreDataListItem', () => { + const standardProps = { + courseTitle: 'Mock Course Title', + courseLabel: 'MCT-101', + courseUserCount: 10, + courseLastAccessed: 'Apr 1st 2020 - 9:15 AM', + courseClick: jest.fn(), + isSelected: false, + index: 3 + } + + test('renders with standard expected props', () => { + const component = create() + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('renders correctly when selected', () => { + const component = create() + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('user count displays properly without the "s" for 1 learner', () => { + standardProps.courseUserCount = 1 + + const component = create() + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('onClick callback is called properly', () => { + const component = create() + + component.root.findByProps({ className: 'course-score-data-list--item' }).props.onClick() + + expect(standardProps.courseClick).toHaveBeenCalledTimes(1) + expect(standardProps.courseClick).toHaveBeenCalledWith(standardProps.index) + }) +}) diff --git a/packages/app/obojobo-repository/shared/components/dashboard-hoc.js b/packages/app/obojobo-repository/shared/components/dashboard-hoc.js index a54ef88455..0f0a58138c 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard-hoc.js +++ b/packages/app/obojobo-repository/shared/components/dashboard-hoc.js @@ -31,6 +31,7 @@ const { moduleRemoveFromCollection, showVersionHistory, showAssessmentScoreData, + showCoursesByDraft, restoreVersion, importModuleFile, checkModuleLock, @@ -70,6 +71,7 @@ const mapActionsToProps = { moduleRemoveFromCollection, showVersionHistory, showAssessmentScoreData, + showCoursesByDraft, restoreVersion, importModuleFile, checkModuleLock, diff --git a/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js b/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js index 65bbcf9a53..68b99eeb77 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js @@ -42,6 +42,7 @@ describe('Dashboard HOC', () => { deselectModules: DashboardActions.deselectModules, selectModules: DashboardActions.selectModules, showAssessmentScoreData: DashboardActions.showAssessmentScoreData, + showCoursesByDraft: DashboardActions.showCoursesByDraft, filterModules: DashboardActions.filterModules, importModuleFile: DashboardActions.importModuleFile, filterCollections: DashboardActions.filterCollections, diff --git a/packages/app/obojobo-repository/shared/components/dashboard.jsx b/packages/app/obojobo-repository/shared/components/dashboard.jsx index 5001ae824b..d973d2fa46 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.jsx +++ b/packages/app/obojobo-repository/shared/components/dashboard.jsx @@ -14,6 +14,7 @@ const MultiButton = require('./multi-button') const Search = require('./search') const ReactModal = require('react-modal') const AssessmentScoreDataDialog = require('./assessment-score-data-dialog') +const CourseScoreDataDialog = require('./course-score-data-dialog') const Spinner = require('./spinner') const Collection = require('./collection') const ModuleManageCollectionsDialog = require('./module-manage-collections-dialog') @@ -37,6 +38,8 @@ const renderOptionsDialog = (props, extension) => ( onClose={props.closeModal} showVersionHistory={props.showVersionHistory} showAssessmentScoreData={props.showAssessmentScoreData} + showCoursesByDraft={props.showCoursesByDraft} + showCourseAssessmentData={props.showCourseAssessmentData} startLoadingAnimation={props.startLoadingAnimation} stopLoadingAnimation={props.stopLoadingAnimation} showModuleManageCollections={props.showModuleManageCollections} @@ -84,6 +87,18 @@ const renderAssessmentScoreDataDialog = props => { ) } +const renderCourseScoreDataDialog = props => { + return ( + + ) +} + const renderModalDialog = props => { let dialog let title @@ -155,6 +170,11 @@ const renderModalDialog = props => { dialog = renderAssessmentScoreDataDialog(props) break + case 'module-course-score-data': + title = 'Module Course Score Data' + dialog = renderCourseScoreDataDialog(props) + break + case 'module-manage-collections': title = 'Module Collections' dialog = renderModuleManageCollectionsDialog(props, extendedProps) diff --git a/packages/app/obojobo-repository/shared/components/dashboard.test.js b/packages/app/obojobo-repository/shared/components/dashboard.test.js index 1a1801955d..2e617b23cc 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard.test.js @@ -52,6 +52,9 @@ jest.mock('./module-options-dialog', () => props => { jest.mock('./assessment-score-data-dialog', () => props => { return }) +jest.mock('./course-score-data-dialog', () => props => { + return +}) import React from 'react' import { create, act } from 'react-test-renderer' @@ -73,6 +76,7 @@ import ModulePermissionsDialog from './module-permissions-dialog' import ModuleOptionsDialog from './module-options-dialog' import VersionHistoryDialog from './version-history-dialog' import AssessmentScoreDataDialog from './assessment-score-data-dialog' +import CourseScoreDataDialog from './course-score-data-dialog' import MessageDialog from './message-dialog' const { MODE_RECENT, MODE_ALL, MODE_COLLECTION, MODE_DELETED } = require('../repository-constants') @@ -227,6 +231,11 @@ describe('Dashboard', () => { hasFetched: false, items: [] }, + courses: { + isFetching: false, + hasFetched: false, + items: [] + }, closeModal: jest.fn(), getModules: jest.fn(), getDeletedModules: jest.fn(), @@ -510,7 +519,7 @@ describe('Dashboard', () => { const component = create() // there shouldn't ever be a case where 'mode' is missing - // but the default case is equivalent to MODE_RECENT + // but the default case is equivalent to MODE_RECENT expectModeRecentRender(component) expect(component.toJSON()).toMatchSnapshot() @@ -1836,6 +1845,25 @@ describe('Dashboard', () => { component.unmount() }) + test('renders "Course Scores" dialog and runs callbacks properly', () => { + dashboardProps.dialog = 'module-course-score-data' + dashboardProps.selectedModule.title = 'Mock Module Title' + + let component + act(() => { + component = create() + }) + + expectDialogToBeRendered(component, CourseScoreDataDialog, 'Module Course Score Data') + const dialogComponent = component.root.findByType(CourseScoreDataDialog) + expect(dialogComponent.props.title).toBe('Assessment Scores by Course') + + dialogComponent.props.onClose() + expect(dashboardProps.closeModal).toHaveBeenCalledTimes(1) + + component.unmount() + }) + test('renders "Module Collections" dialog and adjusts callbacks for each mode', async () => { dashboardProps.dialog = 'module-manage-collections' dashboardProps.loadModuleCollections = jest.fn() diff --git a/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx b/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx index 57c1bb2a99..1e26aeb81b 100644 --- a/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx +++ b/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx @@ -79,6 +79,18 @@ const ModuleOptionsDialog = props => (
View scores by student.
+
+ +
View scores by course.
+
+ {props.accessLevel !== MINIMAL && (
+
+ +
+ + +
+ +`; diff --git a/packages/app/obojobo-repository/shared/components/stats/__snapshots__/course-stats-type-select.test.js.snap b/packages/app/obojobo-repository/shared/components/stats/__snapshots__/course-stats-type-select.test.js.snap new file mode 100644 index 0000000000..53d6aa7fa7 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/__snapshots__/course-stats-type-select.test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseStatsTypeSelect mode selection element renders properly 1`] = ` + +`; diff --git a/packages/app/obojobo-repository/shared/components/stats/__snapshots__/course-stats.test.js.snap b/packages/app/obojobo-repository/shared/components/stats/__snapshots__/course-stats.test.js.snap new file mode 100644 index 0000000000..b53b5015df --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/__snapshots__/course-stats.test.js.snap @@ -0,0 +1,974 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CourseStats CourseStats renders all attempts scores 1`] = ` +
+
+
+
+
+ react-data-table-component +
+
+ + ⬇️   Download + + Table as CSV File ( + 2 + row + s + ) + +
+
+
+`; + +exports[`CourseStats CourseStats renders assessment scores 1`] = ` +
+
+
+
+
+ react-data-table-component +
+
+ + ⬇️   Download + + Table as CSV File ( + 3 + row + s + ) + +
+
+
+`; + +exports[`CourseStats CourseStats renders final assessment scores 1`] = ` +
+
+
+
+
+ react-data-table-component +
+
+ + ⬇️   Download + + Table as CSV File ( + 1 + row + + ) + +
+
+
+`; + +exports[`CourseStats CourseStats renders preview scores 1`] = ` +
+
+
+
+
+ react-data-table-component +
+
+ + ⬇️   Download + + Table as CSV File ( + 3 + row + s + ) + +
+
+
+`; + +exports[`CourseStats CourseStats renders unfiltered scores 1`] = ` +
+
+
+
+
+ react-data-table-component +
+
+ + ⬇️   Download + Advanced + Table as CSV File ( + 4 + row + s + ) + +
+
+
+`; diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.jsx b/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.jsx new file mode 100644 index 0000000000..ad9128e57d --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.jsx @@ -0,0 +1,64 @@ +require('./course-stats-filter-controls.scss') +const React = require('react') + +function CourseStatsFilterControls({ filterSettings, onChangeFilterSettings }) { + const onChangeShowIncompleteAttempts = event => { + onChangeFilterSettings({ + showPreviewAttempts: filterSettings.showPreviewAttempts, + showAdvancedFields: filterSettings.showAdvancedFields, + showIncompleteAttempts: event.target.checked + }) + } + + const onChangeShowPreviewAttempts = event => { + onChangeFilterSettings({ + showIncompleteAttempts: filterSettings.showIncompleteAttempts, + showAdvancedFields: filterSettings.showAdvancedFields, + showPreviewAttempts: event.target.checked + }) + } + + const onChangeShowAdvancedFields = event => { + onChangeFilterSettings({ + showIncompleteAttempts: filterSettings.showIncompleteAttempts, + showPreviewAttempts: filterSettings.showPreviewAttempts, + showAdvancedFields: event.target.checked + }) + } + + return ( +
+
+ + + +
+
+ ) +} + +module.exports = CourseStatsFilterControls diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.scss b/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.scss new file mode 100644 index 0000000000..05466120ba --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.scss @@ -0,0 +1,28 @@ +@import '../../../client/css/defaults'; + +.repository--course-stats-filter-controls { + display: flex; + flex-direction: row; + + .container { + font-size: 0.75em; + display: flex; + flex-direction: row; + + input { + margin-right: 0.5em; + margin-left: 0; + } + + label { + margin-left: 1em; + cursor: pointer; + display: flex; + align-items: center; + + span { + white-space: nowrap; + } + } + } +} diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.test.js b/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.test.js new file mode 100644 index 0000000000..5e2ab99bea --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-filter-controls.test.js @@ -0,0 +1,57 @@ +import React from 'react' +import { create, act } from 'react-test-renderer' +import CourseStatsFilterControls from './course-stats-filter-controls' + +describe('CourseStatsFilterControls', () => { + const standardProps = { + filterSettings: { + showIncompleteAttempts: false, + showPreviewAttempts: false, + showAdvancedFields: false + }, + onChangeFilterSettings: jest.fn() + } + + beforeEach(() => { + jest.resetAllMocks() + }) + + test('filter controls element renders properly', () => { + const component = create() + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('checking the controls calls the onChangeFilterSettings function', () => { + const component = create() + + const inputFields = component.root.findAllByType('input') + act(() => { + inputFields[0].props.onChange({ target: { checked: true } }) + inputFields[1].props.onChange({ target: { checked: true } }) + inputFields[2].props.onChange({ target: { checked: true } }) + }) + expect(standardProps.onChangeFilterSettings).toHaveBeenCalledTimes(3) + }) + + test('unchecking the controls calls the onChangeFilterSettings function', () => { + const component = create( + + ) + + const inputFields = component.root.findAllByType('input') + act(() => { + inputFields[0].props.onChange({ target: { checked: false } }) + inputFields[1].props.onChange({ target: { checked: false } }) + inputFields[2].props.onChange({ target: { checked: false } }) + }) + expect(standardProps.onChangeFilterSettings).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.jsx b/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.jsx new file mode 100644 index 0000000000..9368991fb5 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.jsx @@ -0,0 +1,180 @@ +require('./course-stats-search-controls.scss') + +const React = require('react') +const Button = require('../button') + +const SEARCH_INPUT_DEBOUNCE_MS = 500 + +const CourseStatsSearchControls = ({ + searchSettings, + onChangeSearchSettings, + onChangeSearchContent, + debounceSearch = true +}) => { + const [textInput, setTextInput] = React.useState('') + const [startDate, setStartDate] = React.useState('') + const [endDate, setEndDate] = React.useState('') + const [timerId, setTimerId] = React.useState(null) + + // The oldTimerId is just the timerId, but I have to pass it for the unit tests to work properly + const clearOldTimer = oldTimerId => { + clearTimeout(oldTimerId) + setTimerId(null) + } + + const debouncedOnChangeSearchContent = (searchTerm, timerId) => { + clearOldTimer(timerId) + const newTimerId = setTimeout( + () => + onChangeSearchContent({ + text: searchTerm, + date: { start: startDate, end: endDate } + }), + SEARCH_INPUT_DEBOUNCE_MS + ) + setTimerId(newTimerId) + } + + const handleSearchSettingsChange = event => { + const value = event.target.value + onChangeSearchSettings(value) + } + + const handleSearchContentChange = event => { + const value = event.target.value.trim() + setTextInput(value) + + // If the user clears out the input (length == 0) go ahead and update the search without a delay + if (!debounceSearch || value.length == 0) { + clearOldTimer(timerId) + onChangeSearchContent({ + text: value, + date: { start: startDate, end: endDate } + }) + } else { + debouncedOnChangeSearchContent(value, timerId) + } + } + + const clearFilter = () => { + setTextInput('') + clearOldTimer(timerId) + onChangeSearchContent({ + text: '', + date: { start: startDate, end: endDate } + }) + } + + const onChangeStartDate = newDate => { + setStartDate(newDate) + clearOldTimer(timerId) + onChangeSearchContent({ + text: textInput, + date: { start: newDate, end: endDate } + }) + } + + const onChangeEndDate = newDate => { + setEndDate(newDate) + clearOldTimer(timerId) + onChangeSearchContent({ + text: textInput, + date: { start: startDate, end: newDate } + }) + } + + const showTextInput = searchSettings !== '' + + const textPlaceholder = searchSettings + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.substring(1)) + .join(' ') + + const searchFilterActive = showTextInput && textInput !== '' + + return ( +
+
+ +
+ + {showTextInput && ( + + )} +
+
+ +
+
+
+ + + +
+
+ ) +} + +module.exports = CourseStatsSearchControls diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.scss b/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.scss new file mode 100644 index 0000000000..e8bcc08d37 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.scss @@ -0,0 +1,145 @@ +@import '../../../client/css/defaults'; + +.repository--course-stats-search-controls { + $color-disabled: rgba(0, 0, 0, 0.3); + + display: flex; + flex-direction: row; + + select { + @include select-input(); + + border: 1px solid $color-shadow; + font-size: 0.8em; + width: 11em; + } + + input { + font-family: $font-monospace; + } + + .clear-button-container { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 0.5em; + + .clear-button { + border: 0.1em solid $color-button-bg; + background-color: $color-button-bg; + color: white; + padding: 0 0.3em; + border-radius: 50%; + font-size: 1em; + + &:disabled { + background-color: $color-disabled; + border-color: transparent; + } + + &:hover:not(:disabled) { + background-color: $color-action; + } + } + } + + .search-by-text { + margin-right: 2em; + display: flex; + align-items: baseline; + + label { + width: 5em; + } + + select { + width: 100%; + height: 32px; + box-sizing: border-box; + + &.filter-active { + background-color: $color-action-bg; + border: 1px solid $color-action; + } + } + + input { + height: 32px; + box-sizing: border-box; + + &.filter-active { + background-color: $color-action-bg; + border: 1px solid $color-action; + } + } + + .text-input { + border-radius: $dimension-rounded-radius; + padding: 0.6em 0.5em; + font-size: 0.8em; + border: 0.1em solid $color-shadow; + } + + .controls { + display: flex; + flex-direction: column; + gap: 0.5em; + flex-grow: 1; + width: 150px; + } + } + + .search-by-date { + display: flex; + flex-direction: column; + gap: 0.5em; + + .label { + margin-bottom: 0.5em; + } + + label { + display: flex; + align-items: baseline; + + > span { + width: 3.5em; + } + } + + .date-range { + display: flex; + flex-direction: column; + gap: 0.1em; + flex-grow: 1; + + button { + border: none; + padding: 0; + width: 4em; + + &:disabled { + visibility: hidden; + } + } + + input[type='date'] { + border-radius: $dimension-rounded-radius; + padding: 0.5em; + font-size: 0.8em; + border: 0.1em solid $color-shadow; + height: 32px; + box-sizing: border-box; + + &[value=''] { + color: $color-text-minor; + } + + &:not([value='']) { + background-color: $color-action-bg; + border: 1px solid $color-action; + } + } + } + } +} diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.test.js b/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.test.js new file mode 100644 index 0000000000..d989be998f --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-search-controls.test.js @@ -0,0 +1,232 @@ +import React from 'react' +import { create, act } from 'react-test-renderer' +import CourseStatsSearchControls from './course-stats-search-controls' + +const SEARCH_INPUT_DEBOUNCE_MS = 500 +const RESOURCE_LINK_TITLE = 'resource-link-title' +const USER_FIRST_NAME = 'user-first-name' +const USER_LAST_NAME = 'user-last-name' + +describe('CourseStatsSearchControls', () => { + const standardProps = { + searchSettings: '', + onChangeSearchSettings: jest.fn(), + onChangeSearchContent: jest.fn() + } + + beforeEach(() => { + jest.resetAllMocks() + }) + + test('search controls element renders properly', () => { + const component = create() + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('selecting a search mode calls the onChangeSearchSettings function', () => { + const component = create() + + const selectField = component.root.findByType('select') + act(() => { + selectField.props.onChange({ target: { value: RESOURCE_LINK_TITLE } }) + selectField.props.onChange({ target: { value: USER_FIRST_NAME } }) + selectField.props.onChange({ target: { value: USER_LAST_NAME } }) + }) + expect(standardProps.onChangeSearchSettings).toHaveBeenCalledTimes(3) + }) + + test('changing the search text DOES NOT immediately call onChangeSearchContent (debounces)', async () => { + const component = create( + + ) + + jest.useFakeTimers() + jest.spyOn(global, 'setTimeout') + + const textField = component.root.findAllByType('input')[0] + act(() => { + textField.props.onChange({ target: { value: 'mock-search-content' } }) + }) + expect(setTimeout).toHaveBeenCalledTimes(1) + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), SEARCH_INPUT_DEBOUNCE_MS) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(0) + + jest.runAllTimers() + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(1) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: 'mock-search-content', + date: { end: '', start: '' } + }) + }) + + test('changing the search text DOES call onChangeSearchContent (debounce off)', () => { + const component = create( + + ) + + const textField = component.root.findAllByType('input')[0] + expect(textField.props.className).toBe('text-input') + act(() => { + textField.props.onChange({ target: { value: 'mock-search-content' } }) + }) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(1) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: 'mock-search-content', + date: { end: '', start: '' } + }) + expect(textField.props.className).toBe('text-input filter-active') + + act(() => { + textField.props.onChange({ target: { value: '' } }) + }) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(2) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: '', start: '' } + }) + expect(textField.props.className).toBe('text-input') + }) + + test('multiple quick searches resets the debounce timer', () => { + const component = create( + + ) + + const textField = component.root.findAllByType('input')[0] + expect(textField.props.className).toBe('text-input') + act(() => { + textField.props.onChange({ target: { value: 'mock-search-content' } }) + textField.props.onChange({ target: { value: '' } }) + }) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(1) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: '', start: '' } + }) + expect(textField.props.className).toBe('text-input') + }) + + test("clicking the text input's clear filter button clears text search (debounce off)", () => { + const component = create( + + ) + + const mockClick = { + preventDefault: jest.fn() + } + + const textField = component.root.findAllByType('input')[0] + const textClearButton = component.root + .findByProps({ className: 'search-by-text' }) + .findByType('button') + + // Puts something in the text field and verifies that it's active/highlighted + act(() => { + textField.props.onChange({ target: { value: 'mock-search-content' } }) + }) + expect(textField.props.className).toBe('text-input filter-active') + expect(textClearButton.props.disabled).toBe(false) + + // Clicks the clear button, then verifies that the field is no longer active/highlighted + + act(() => { + textClearButton.props.onClick(mockClick) + }) + expect(textField.props.className).toBe('text-input') + expect(textClearButton.props.disabled).toBe(true) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(2) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: '', start: '' } + }) + }) + + test('changing the start date and clearing the start date both call onChangeSearchContent', () => { + const component = create( + + ) + + const mockClick = { + preventDefault: jest.fn() + } + + const startDateField = component.root + .findByProps({ className: 'search-by-date' }) + .findAllByType('input')[0] + const startDateClearButton = component.root + .findByProps({ className: 'search-by-date' }) + .findAllByType('button')[0] + + // Adding a start date calls onChangeSearchContent + expect(startDateClearButton.props.disabled).toBe(true) + act(() => { + startDateField.props.onChange({ target: { value: 'mock-start-date' } }) + }) + expect(startDateClearButton.props.disabled).toBe(false) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(1) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: '', start: 'mock-start-date' } + }) + + // Clicking the clear button calls onChangeSearchContent + act(() => { + startDateClearButton.props.onClick(mockClick) + }) + expect(startDateClearButton.props.disabled).toBe(true) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(2) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: '', start: '' } + }) + }) + + test('changing the end date and clearing the end date both call onChangeSearchContent', () => { + const component = create( + + ) + + const mockClick = { + preventDefault: jest.fn() + } + + const endDateField = component.root + .findByProps({ className: 'search-by-date' }) + .findAllByType('input')[1] + const endDateClearButton = component.root + .findByProps({ className: 'search-by-date' }) + .findAllByType('button')[1] + + // Adding a start date calls onChangeSearchContent + expect(endDateClearButton.props.disabled).toBe(true) + act(() => { + endDateField.props.onChange({ target: { value: 'mock-end-date' } }) + }) + expect(endDateClearButton.props.disabled).toBe(false) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(1) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: 'mock-end-date', start: '' } + }) + + // Clicking the clear button calls onChangeSearchContent + act(() => { + endDateClearButton.props.onClick(mockClick) + }) + expect(endDateClearButton.props.disabled).toBe(true) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledTimes(2) + expect(standardProps.onChangeSearchContent).toHaveBeenCalledWith({ + text: '', + date: { end: '', start: '' } + }) + }) +}) diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-type-select.jsx b/packages/app/obojobo-repository/shared/components/stats/course-stats-type-select.jsx new file mode 100644 index 0000000000..333aad3c1e --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-type-select.jsx @@ -0,0 +1,22 @@ +const React = require('react') + +const VIEW_MODE_FINAL_ASSESSMENT_SCORE = 'final-assessment-scores' +const VIEW_MODE_ALL_ATTEMPTS = 'all-attempts' + +function CourseStatsTypeSelect({ viewMode, onChangeViewMode }) { + const changeViewMode = event => { + onChangeViewMode(event.target.value) + } + + return ( + + ) +} + +module.exports = CourseStatsTypeSelect diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats-type-select.test.js b/packages/app/obojobo-repository/shared/components/stats/course-stats-type-select.test.js new file mode 100644 index 0000000000..0c1ee90453 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats-type-select.test.js @@ -0,0 +1,31 @@ +import React from 'react' +import { create, act } from 'react-test-renderer' + +const VIEW_MODE_FINAL_ASSESSMENT_SCORE = 'final-assessment-scores' +const VIEW_MODE_ALL_ATTEMPTS = 'all-attempts' + +import CourseStatsTypeSelect from './course-stats-type-select' + +describe('CourseStatsTypeSelect', () => { + const standardProps = { + viewMode: VIEW_MODE_FINAL_ASSESSMENT_SCORE, + onChangeViewMode: jest.fn() + } + + test('mode selection element renders properly', () => { + const component = create() + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('changing the selection triggers onChangeViewMode', () => { + const component = create() + + act(() => { + component.root + .findByType('select') + .props.onChange({ target: { value: VIEW_MODE_ALL_ATTEMPTS } }) + }) + expect(standardProps.onChangeViewMode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats.jsx b/packages/app/obojobo-repository/shared/components/stats/course-stats.jsx new file mode 100644 index 0000000000..98b2eb2088 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats.jsx @@ -0,0 +1,63 @@ +require('./course-stats.scss') + +const React = require('react') +const DataGridAttempts = require('./data-grid-attempts') +const DataGridAssessments = require('./data-grid-assessments') + +const VIEW_MODE_FINAL_ASSESSMENT_SCORE = 'final-assessment-scores' +const VIEW_MODE_ALL_ATTEMPTS = 'all-attempts' + +const renderDataGrid = ( + viewMode, + filteredAttempts, + filterSettings, + searchSettings, + searchContent +) => { + switch (viewMode) { + case VIEW_MODE_ALL_ATTEMPTS: + return ( + + ) + + case VIEW_MODE_FINAL_ASSESSMENT_SCORE: + default: + return ( + + ) + } +} + +const filterAttempts = (attempts, { showIncompleteAttempts, showPreviewAttempts }) => { + if (showIncompleteAttempts && showPreviewAttempts) { + return attempts + } + + return attempts.filter( + attempt => + (showIncompleteAttempts || attempt.completedAt !== null) && + (showPreviewAttempts || !attempt.isPreview) + ) +} + +const CourseStats = ({ attempts, viewMode, searchSettings, searchContent, filterSettings }) => { + const filteredAttempts = filterAttempts(attempts, filterSettings) + + return ( +
+ {renderDataGrid(viewMode, filteredAttempts, filterSettings, searchSettings, searchContent)} +
+ ) +} + +module.exports = CourseStats diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats.scss b/packages/app/obojobo-repository/shared/components/stats/course-stats.scss new file mode 100644 index 0000000000..6bcbb8f320 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats.scss @@ -0,0 +1,72 @@ +@import '../../../client/css/defaults'; + +$border-color: rgba(0, 0, 0, 0.1); +$border: 1px solid $border-color; + +.repository--course-stats { + background: $color-bg; + height: calc(100% - 150px); + border-radius: $dimension-rounded-radius; + box-sizing: border-box; + padding: 0; + display: flex; + gap: 1em; + + .repository--data-grid-assessments, + .repository--data-grid-attempts { + height: 100%; + width: 100%; + overflow: scroll; + } + + .repository--data-grid-scores { + display: flex; + flex-direction: column; + height: 100%; + box-sizing: border-box; + border: $border; + border-top: none; + border-radius: 0 0 $dimension-rounded-radius $dimension-rounded-radius; + + .repository--button { + display: inline-block; + margin: 1em; + align-self: center; + } + + .data-grid { + width: 100%; + height: calc(100% - 5em); + flex-grow: 1; + display: flex; + flex-direction: column; + border: 2px solid $color-action; + box-sizing: border-box; + + // Tweaks to the react-data-table-component + > .rdt_TableHeader { + display: none; + } + + > div:nth-child(2) { + overflow-y: scroll; + flex-grow: 1; + } + + nav { + border-bottom: 1px solid $border-color; + } + + .rdt_TableHead { + position: sticky; + top: 0; + z-index: 1; + } + + .rdt_TableHeadRow { + background-color: $color-action-bg; + padding: 0.25em 0; + } + } + } +} diff --git a/packages/app/obojobo-repository/shared/components/stats/course-stats.test.js b/packages/app/obojobo-repository/shared/components/stats/course-stats.test.js new file mode 100644 index 0000000000..e1f3d091ba --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/stats/course-stats.test.js @@ -0,0 +1,144 @@ +import React from 'react' +import { create } from 'react-test-renderer' +import CourseStats from './course-stats' + +const VIEW_MODE_FINAL_ASSESSMENT_SCORE = 'final-assessment-scores' +const VIEW_MODE_ALL_ATTEMPTS = 'all-attempts' + +jest.mock('react-data-table-component', () => ({ + default: props => ( +
+ react-data-table-component +
+ ) +})) + +describe('CourseStats', () => { + const getTestProps = () => ({ + attempts: [ + { + draftId: 'Draft-A', + draftContentId: 'Version-1', + resourceLinkId: 'Resource-X', + assessmentId: 'Assessment-1', + userId: 'User-Alpha', + assessmentScore: null, + completedAt: 'mock-date', + isPreview: false + }, + { + draftId: 'Draft-A', + draftContentId: 'Version-1', + resourceLinkId: 'Resource-X', + assessmentId: 'Assessment-1', + userId: 'User-Alpha', + assessmentScore: 10, + completedAt: 'mock-date', + isPreview: true + }, + { + draftId: 'Draft-A', + draftContentId: 'Version-1', + resourceLinkId: 'Resource-X', + assessmentId: 'Assessment-1', + userId: 'User-Alpha', + assessmentScore: 0, + completedAt: 'mock-date', + isPreview: false + }, + { + draftId: 'Draft-A', + draftContentId: 'Version-1', + resourceLinkId: 'Resource-X', + assessmentId: 'Assessment-1', + userId: 'User-Alpha', + assessmentScore: 100, + completedAt: null, + isPreview: false + } + ], + viewMode: VIEW_MODE_FINAL_ASSESSMENT_SCORE, + searchSettings: '', + searchContent: '', + filterSettings: { + showIncompleteAttempts: false, + showPreviewAttempts: false, + showAdvancedFields: false + } + }) + + test('CourseStats renders final assessment scores', () => { + const component = create() + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + const DataGrid = component.root.findByProps({ className: 'react-data-table-component' }) + expect(DataGrid.props.data.length).toEqual(1) + }) + + test('CourseStats renders all attempts scores', () => { + const component = create() + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + const DataGrid = component.root.findByProps({ className: 'react-data-table-component' }) + expect(DataGrid.props.data.length).toEqual(2) + }) + + test('CourseStats renders preview scores', () => { + const component = create( + + ) + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + const DataGrid = component.root.findByProps({ className: 'react-data-table-component' }) + expect(DataGrid.props.data.length).toEqual(3) + }) + + test('CourseStats renders assessment scores', () => { + const component = create( + + ) + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + const DataGrid = component.root.findByProps({ className: 'react-data-table-component' }) + expect(DataGrid.props.data.length).toEqual(3) + }) + + test('CourseStats renders unfiltered scores', () => { + const component = create( + + ) + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + const DataGrid = component.root.findByProps({ className: 'react-data-table-component' }) + expect(DataGrid.props.data.length).toEqual(4) + }) +}) diff --git a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js index 00ba884f43..d6668e9be4 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js @@ -36,6 +36,7 @@ const { SHOW_VERSION_HISTORY, RESTORE_VERSION, SHOW_ASSESSMENT_SCORE_DATA, + SHOW_COURSES_BY_DRAFT, GET_DELETED_MODULES, GET_MODULES, BULK_RESTORE_MODULES @@ -376,6 +377,28 @@ function DashboardReducer(state, action) { }) }) + case SHOW_COURSES_BY_DRAFT: + return handle(state, action, { + start: prevState => ({ + ...prevState, + dialog: 'module-course-score-data', + selectedModule: action.meta.module, + courses: { + isFetching: true, + hasFetched: false, + items: [] + } + }), + success: prevState => ({ + ...prevState, + courses: { + isFetching: false, + hasFetched: true, + items: action.payload + } + }) + }) + case RESTORE_VERSION: return handle(state, action, { start: prevState => ({ diff --git a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js index 2da368adaf..e8883504ec 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js @@ -42,6 +42,7 @@ const { DELETE_COLLECTION, SHOW_VERSION_HISTORY, SHOW_ASSESSMENT_SCORE_DATA, + SHOW_COURSES_BY_DRAFT, RESTORE_VERSION, GET_MODULES, GET_DELETED_MODULES, @@ -1188,6 +1189,51 @@ describe('Dashboard Reducer', () => { }) }) + test('SHOW_COURSES_BY_DRAFT action modifies state correctly', () => { + const initialState = { + assessmentStats: { + isFetching: false, + hasFetched: false, + items: [] + } + } + + const mockAttemptItems = [ + { + id: 'mockCourseId1' + }, + { + id: 'mockCourseId2' + } + ] + const action = { + type: SHOW_COURSES_BY_DRAFT, + payload: mockAttemptItems, + meta: { + module: jest.fn() + } + } + + // asynchronous action - state changes on success + const handler = dashboardReducer(initialState, action) + let newState + + newState = handleStart(handler) + expect(newState.courses).toEqual({ + isFetching: true, + hasFetched: false, + items: [] + }) + + newState = handleSuccess(handler) + expect(newState.courses).not.toEqual(initialState.courses) + expect(newState.courses).toEqual({ + isFetching: false, + hasFetched: true, + items: mockAttemptItems + }) + }) + test('GET_MODULES action modifies state correctly', () => { // With user currently looking at "My Deleted Modules" page const initialState = { diff --git a/packages/app/obojobo-repository/shared/reducers/stats-reducer.js b/packages/app/obojobo-repository/shared/reducers/stats-reducer.js index 8f457fdd39..d44a71d926 100644 --- a/packages/app/obojobo-repository/shared/reducers/stats-reducer.js +++ b/packages/app/obojobo-repository/shared/reducers/stats-reducer.js @@ -2,7 +2,8 @@ const { handle } = require('redux-pack') const { LOAD_STATS_PAGE_MODULES_FOR_USER, - LOAD_MODULE_ASSESSMENT_DETAILS + LOAD_MODULE_ASSESSMENT_DETAILS, + LOAD_COURSE_ASSESSMENT_DATA } = require('../actions/stats-actions') function StatsReducer(state, action) { @@ -28,6 +29,7 @@ function StatsReducer(state, action) { }) case LOAD_MODULE_ASSESSMENT_DETAILS: + case LOAD_COURSE_ASSESSMENT_DATA: return handle(state, action, { start: prevState => ({ ...prevState, diff --git a/packages/obonode/obojobo-sections-assessment/server/express.js b/packages/obonode/obojobo-sections-assessment/server/express.js index 0fb760fd11..85af3e503a 100644 --- a/packages/obonode/obojobo-sections-assessment/server/express.js +++ b/packages/obonode/obojobo-sections-assessment/server/express.js @@ -208,6 +208,43 @@ router }) }) +router + .route('/api/courses/:draftId') + .get([requireCurrentUser]) + .get((req, res) => { + let currentUserHasPermissionToDraft + + return DraftPermissions.getUserAccessLevelToDraft(req.currentUser.id, req.params.draftId) + .then(result => { + currentUserHasPermissionToDraft = result !== null + + // Users must either have some level of permissions to this draft, or have + // the canViewSystemStats permission + if ( + !currentUserHasPermissionToDraft && + !req.currentUser.hasPermission('canViewSystemStats') + ) { + throw Error(ERROR_NO_PERMISSIONS_TO_DATA) + } + + return AssessmentModel.fetchCoursesByDraft(req.params.draftId) + }) + .then(courseList => { + // TODO: At this point, filter the list of courses the current user can view + // by which courses they have Canvas permissions to. + return res.success(courseList) + }) + .catch(error => { + switch (error.message) { + case ERROR_NO_PERMISSIONS_TO_DATA: + return res.notAuthorized() + + default: + logAndRespondToUnexpected('Unexpected error fetching course details', res, req, error) + } + }) + }) + router .route('/api/assessments/:draftId/details') .get([requireCurrentUser]) @@ -268,6 +305,66 @@ router }) }) +router + .route('/api/assessments/:draftId/course/:contextId/details') + .get([requireCurrentUser]) + .get((req, res) => { + let currentUserHasPermissionToDraft + + return DraftPermissions.getUserAccessLevelToDraft(req.currentUser.id, req.params.draftId) + .then(result => { + currentUserHasPermissionToDraft = result !== null + + // Users must either have some level of permissions to this draft, or have + // the canViewSystemStats permission + if ( + !currentUserHasPermissionToDraft && + !req.currentUser.hasPermission('canViewSystemStats') + ) { + throw Error(ERROR_NO_PERMISSIONS_TO_DATA) + } + + return AssessmentModel.fetchAttemptHistoryDetails(req.params.draftId, req.params.contextId) + }) + .then(attemptsDetails => { + // If the user doesn't own the draft (aka they only have canViewSystemStats), + // anonymize the user data: + if (!currentUserHasPermissionToDraft) { + const anonUUIDsByUserId = {} + attemptsDetails = attemptsDetails.map(attemptDetails => { + if (!anonUUIDsByUserId[attemptDetails.user_id]) { + anonUUIDsByUserId[attemptDetails.user_id] = uuid() + } + + const userAnonUUID = anonUUIDsByUserId[attemptDetails.user_id] + + attemptDetails.user_username = `(anonymized-${userAnonUUID})` + attemptDetails.user_first_name = `(anonymized-${userAnonUUID})` + attemptDetails.user_last_name = `(anonymized-${userAnonUUID})` + attemptDetails.user_id = `(anonymized-${userAnonUUID})` + + return attemptDetails + }) + } + + return res.success(attemptsDetails) + }) + .catch(error => { + switch (error.message) { + case ERROR_NO_PERMISSIONS_TO_DATA: + return res.notAuthorized() + + default: + logAndRespondToUnexpected( + 'Unexpected error fetching assessment details', + res, + req, + error + ) + } + }) + }) + // register the event listeners require('./events') diff --git a/packages/obonode/obojobo-sections-assessment/server/express.test.js b/packages/obonode/obojobo-sections-assessment/server/express.test.js index 37e3103aa4..2c2e9ee0c5 100644 --- a/packages/obonode/obojobo-sections-assessment/server/express.test.js +++ b/packages/obonode/obojobo-sections-assessment/server/express.test.js @@ -14,7 +14,7 @@ jest.mock('uuid', () => ({ })) const mockCurrentUser = { id: 'mockCurrentUserId' } -const mockCurrentDocument = { draftId: 'mockDraftId' } +const mockCurrentDocument = { draftId: 'mockDraftId', contextId: 'mockContextId' } const mockCurrentVisit = { id: 'mockCurrentVisitId', resource_link_id: 'mockResourceLinkId', @@ -512,6 +512,93 @@ describe('server/express', () => { }) }) + test('GET /api/assessments/:draftId/course/:contextId/details returns non-anonymized data if the current user owns the draft', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: () => { + return false + } + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) + AssessmentModel.fetchAttemptHistoryDetails.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/assessments/mock-draft-id/course/mock-context-id/details') + .type('application/json') + + expect(response.statusCode).toBe(200) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchAttemptHistoryDetails).toHaveBeenCalledWith( + 'mock-draft-id', + 'mock-context-id' + ) + expect(response.body).toEqual({ + status: 'ok', + value: [ + { + userUsername: 'Test', + userFirstName: 'First', + userLastName: 'Last', + userId: 'user-id' + } + ] + }) + }) + + test('GET /api/courses/:draftId returns non-anonymized data if the current user owns the draft', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: () => { + return false + } + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) + AssessmentModel.fetchCoursesByDraft.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/courses/mock-draft-id') + .type('application/json') + + expect(response.statusCode).toBe(200) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchCoursesByDraft).toHaveBeenCalledWith('mock-draft-id') + expect(response.body).toEqual({ + status: 'ok', + value: [ + { + userUsername: 'Test', + userFirstName: 'First', + userLastName: 'Last', + userId: 'user-id' + } + ] + }) + }) + test('GET /api/assessments/:draftId/details returns non-anonymized data if the current user owns the draft, with canViewSystemStats', async () => { expect.hasAssertions() const mockReturnValue = [ @@ -554,6 +641,93 @@ describe('server/express', () => { }) }) + test('GET /api/assessments/:draftId/course/:contextId/details returns non-anonymized data if the current user owns the draft, with canViewSystemStats', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: perm => { + return Boolean(perm === 'canViewSystemStats') + } + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) + AssessmentModel.fetchAttemptHistoryDetails.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/assessments/mock-draft-id/course/mock-context-id/details') + .type('application/json') + + expect(response.statusCode).toBe(200) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchAttemptHistoryDetails).toHaveBeenCalledWith( + 'mock-draft-id', + 'mock-context-id' + ) + expect(response.body).toEqual({ + status: 'ok', + value: [ + { + userUsername: 'Test', + userFirstName: 'First', + userLastName: 'Last', + userId: 'user-id' + } + ] + }) + }) + + test('GET /api/courses/:draftId returns non-anonymized data if the current user owns the draft, with canViewSystemStats', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: perm => { + return Boolean(perm === 'canViewSystemStats') + } + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) + AssessmentModel.fetchCoursesByDraft.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/courses/mock-draft-id') + .type('application/json') + + expect(response.statusCode).toBe(200) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchCoursesByDraft).toHaveBeenCalledWith('mock-draft-id') + expect(response.body).toEqual({ + status: 'ok', + value: [ + { + userUsername: 'Test', + userFirstName: 'First', + userLastName: 'Last', + userId: 'user-id' + } + ] + }) + }) + test('GET /api/assessments/:draftId/details returns anonymized data if the current user does not own the draft but has canViewSystemStats', async () => { expect.hasAssertions() const mockReturnValue = [ @@ -626,6 +800,81 @@ describe('server/express', () => { }) }) + test('GET /api/assessments/:draftId/coures/:contextId/details returns anonymized data if the current user does not own the draft but has canViewSystemStats', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id', + attemptId: 'attempt-1' + }, + { + user_username: 'Test2', + user_first_name: 'First2', + user_last_name: 'Last2', + userId: 'user-id2', + attemptId: 'attempt-2' + }, + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id', + attemptId: 'attempt-3' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: perm => { + return Boolean(perm === 'canViewSystemStats') + } + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(null) + AssessmentModel.fetchAttemptHistoryDetails.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/assessments/mock-draft-id/course/mock-context-id/details') + .type('application/json') + + expect(response.statusCode).toBe(200) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchAttemptHistoryDetails).toHaveBeenCalledWith( + 'mock-draft-id', + 'mock-context-id' + ) + expect(response.body).toEqual({ + status: 'ok', + value: [ + { + userUsername: '(anonymized-mock-uuid)', + userFirstName: '(anonymized-mock-uuid)', + userLastName: '(anonymized-mock-uuid)', + userId: '(anonymized-mock-uuid)', + attemptId: 'attempt-1' + }, + { + userUsername: '(anonymized-mock-uuid)', + userFirstName: '(anonymized-mock-uuid)', + userLastName: '(anonymized-mock-uuid)', + userId: '(anonymized-mock-uuid)', + attemptId: 'attempt-2' + }, + { + userUsername: '(anonymized-mock-uuid)', + userFirstName: '(anonymized-mock-uuid)', + userLastName: '(anonymized-mock-uuid)', + userId: '(anonymized-mock-uuid)', + attemptId: 'attempt-3' + } + ] + }) + }) + test('GET /api/assessments/:draftId/details returns a 401 if the current user does not own the draft and does not have canViewSystemStats', async () => { expect.hasAssertions() const mockReturnValue = [ @@ -661,6 +910,76 @@ describe('server/express', () => { }) }) + test('GET /api/assessments/:draftId/course/:contextId/details returns a 401 if the current user does not own the draft and does not have canViewSystemStats', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: () => false + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(null) + AssessmentModel.fetchAttemptHistoryDetails.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/assessments/mock-draft-id/course/mock-context-id/details') + .type('application/json') + + expect(response.statusCode).toBe(401) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchAttemptHistoryDetails).toHaveBeenCalledTimes(0) + expect(response.body).toEqual({ + status: 'error', + value: { + type: 'notAuthorized' + } + }) + }) + + test('GET /api/courses/:draftId returns a 401 if the current user does not own the draft and does not have canViewSystemStats', async () => { + expect.hasAssertions() + const mockReturnValue = [ + { + user_username: 'Test', + user_first_name: 'First', + user_last_name: 'Last', + user_id: 'user-id' + } + ] + requireCurrentUser.mockImplementationOnce((req, res, next) => { + req.currentUser = { + id: 'mockCurrentUserId', + hasPermission: () => false + } + next() + }) + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(null) + AssessmentModel.fetchCoursesByDraft.mockResolvedValueOnce(mockReturnValue) + + const response = await request(app) + .get('/api/courses/mock-draft-id') + .type('application/json') + + expect(response.statusCode).toBe(401) + expect(requireCurrentUser).toHaveBeenCalledTimes(1) + expect(AssessmentModel.fetchCoursesByDraft).toHaveBeenCalledTimes(0) + expect(response.body).toEqual({ + status: 'error', + value: { + type: 'notAuthorized' + } + }) + }) + test('GET /api/assessments/:draftId/details fails', async () => { expect.hasAssertions() DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) @@ -680,6 +999,44 @@ describe('server/express', () => { }) }) + test('GET /api/assessments/:draftId/course/:contextId/details fails', async () => { + expect.hasAssertions() + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) + AssessmentModel.fetchAttemptHistoryDetails.mockRejectedValueOnce(Error('Oops!')) + + const response = await request(app) + .get('/api/assessments/:draftId/course/:contextId/details') + .type('application/json') + + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + status: 'error', + value: { + message: expect.any(String), + type: 'unexpected' + } + }) + }) + + test('GET /api/courses/:draftId fails', async () => { + expect.hasAssertions() + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(FULL) + AssessmentModel.fetchCoursesByDraft.mockRejectedValueOnce(Error('Oops!')) + + const response = await request(app) + .get('/api/courses/:draftId') + .type('application/json') + + expect(response.statusCode).toBe(500) + expect(response.body).toEqual({ + status: 'error', + value: { + message: expect.any(String), + type: 'unexpected' + } + }) + }) + test('POST /api/assessments/:draftId/:assessmentId/import-score', async () => { expect.hasAssertions() requireCurrentVisit.mockImplementationOnce((req, res, next) => { diff --git a/packages/obonode/obojobo-sections-assessment/server/models/assessment.js b/packages/obonode/obojobo-sections-assessment/server/models/assessment.js index c72a747a2d..b1871d4a75 100644 --- a/packages/obonode/obojobo-sections-assessment/server/models/assessment.js +++ b/packages/obonode/obojobo-sections-assessment/server/models/assessment.js @@ -113,7 +113,45 @@ class AssessmentModel { })) } - static fetchAttemptHistoryDetails(draftId) { + // Fetches a list of unique [Canvas] courses that have used the draft (as determined by launches) + static fetchCoursesByDraft(draftId) { + return db.manyOrNone( + ` + SELECT + DISTINCT(A.resource_link_id), + A.last_accessed, + A.user_count, + N.data ->> 'context_id' AS context_id, + N.data ->> 'context_title' AS context_title, + N.data ->> 'context_label' AS context_label + + FROM ( + SELECT + COUNT(DISTINCT(user_id)) AS user_count, + MAX(completed_at) AS last_accessed, + resource_link_id + FROM + attempts + WHERE + draft_id = $[draftId] + GROUP BY resource_link_id + ) A + + LEFT JOIN + launches N + ON + N.data ->> 'resource_link_id' = A.resource_link_id + + ORDER BY + A.last_accessed DESC + `, + { + draftId + } + ) + } + + static fetchAttemptHistoryDetails(draftId, contextId = null) { return db.manyOrNone( ` SELECT @@ -184,6 +222,7 @@ class AssessmentModel { A.draft_content_id = C.id WHERE S.draft_id = $[draftId] + ${contextId !== null ? "AND N.data ->> 'context_id' = '" + contextId + "'" : ''} ORDER BY A.created_at `, diff --git a/packages/obonode/obojobo-sections-assessment/server/models/assessment.test.js b/packages/obonode/obojobo-sections-assessment/server/models/assessment.test.js index 52d32f1a8b..c27ed1b15b 100644 --- a/packages/obonode/obojobo-sections-assessment/server/models/assessment.test.js +++ b/packages/obonode/obojobo-sections-assessment/server/models/assessment.test.js @@ -115,6 +115,17 @@ describe('AssessmentModel', () => { `) }) + test('fetchCoursesByDraft calls db.manyOrNone', () => { + AssessmentModel.fetchCoursesByDraft('mock-draft-id') + + expect(db.manyOrNone).toHaveBeenCalledTimes(1) + expect(db.manyOrNone.mock.calls[0][1]).toMatchInlineSnapshot(` + Object { + "draftId": "mock-draft-id", + } + `) + }) + test('fetchAttemptHistoryDetails calls db.manyOrNone', () => { AssessmentModel.fetchAttemptHistoryDetails('mock-draft-id') @@ -126,6 +137,13 @@ describe('AssessmentModel', () => { `) }) + test('fetchAttemptHistoryDetails runs distinct call when contextId is included', () => { + AssessmentModel.fetchAttemptHistoryDetails('mock-draft-id', 'mock-context-id') + + expect(db.manyOrNone).toHaveBeenCalledTimes(1) + expect(JSON.stringify(db.manyOrNone.mock.calls).includes('mock-context-id')).toBe(true) + }) + test('fetchAttemptsForUserDraftAndResourceLinkId returns an array AssessmentModel', async () => { db.manyOrNone.mockResolvedValueOnce([makeMockAttempt()])