Compare commits
45 Commits
dependabot
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c83447347 | ||
|
|
ca058d534c | ||
|
|
a0866142f4 | ||
|
|
4f429080af | ||
|
|
2f635baa29 | ||
|
|
33cc019c6f | ||
|
|
3e8f2ba4e7 | ||
|
|
4af279edeb | ||
|
|
5148ff06d7 | ||
|
|
d0544bc4e2 | ||
|
|
ceed6cb287 | ||
|
|
004315fdc4 | ||
|
|
8baaa1d50c | ||
|
|
0bd9483cdb | ||
|
|
13eea81fe7 | ||
|
|
1d0ab113e7 | ||
|
|
e34382aa11 | ||
|
|
bf127d5292 | ||
|
|
522ebb0003 | ||
|
|
630e843816 | ||
|
|
9c5ac6ac5b | ||
|
|
f43ac7bcc3 | ||
|
|
b282bc05df | ||
|
|
d987aed861 | ||
|
|
9ece337504 | ||
|
|
d3235af879 | ||
|
|
d0a8778015 | ||
|
|
f8381e7900 | ||
|
|
1d5484ff1d | ||
|
|
52692dc662 | ||
|
|
f91af211f6 | ||
|
|
7318fb3ef7 | ||
|
|
7233f08d3d | ||
|
|
d6d229f1c3 | ||
|
|
47b9a436a6 | ||
|
|
e556d5b74c | ||
|
|
694d95a816 | ||
|
|
e83813da8e | ||
|
|
a54a1b8c3c | ||
|
|
d3188efbcc | ||
|
|
33f737579a | ||
|
|
870263001e | ||
|
|
af50d5a6ed | ||
|
|
7fccf7794c | ||
|
|
c760bc479b |
1
.env
1
.env
@@ -53,4 +53,3 @@ OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT=''
|
||||
|
||||
@@ -55,4 +55,3 @@ OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
||||
|
||||
2
.github/workflows/validate.yml
vendored
2
.github/workflows/validate.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: code-coverage-report
|
||||
pattern: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
6474
package-lock.json
generated
6474
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -33,10 +33,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/browserslist-config": "1.5.0",
|
||||
"@edx/browserslist-config": "1.5.1",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.23.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.24.0",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -44,7 +44,6 @@
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-build": "^14.5.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@popperjs/core": "2.11.8",
|
||||
@@ -74,15 +73,14 @@
|
||||
"truncate-html": "1.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@pact-foundation/pact": "^13.0.0",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@pact-foundation/pact": "^16.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"bundlewatch": "^0.4.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.9",
|
||||
"jest": "^29.7.0",
|
||||
"jest-console-group-reporter": "^1.1.1",
|
||||
"jest-when": "^3.6.0",
|
||||
"rosie": "2.1.1"
|
||||
|
||||
@@ -17,7 +17,21 @@ Factory.define('progressTabData')
|
||||
percent: 1,
|
||||
is_passing: true,
|
||||
},
|
||||
final_grades: 0.5,
|
||||
credit_course_requirements: null,
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
short_label: 'HW',
|
||||
weight: 1,
|
||||
average_grade: 1,
|
||||
weighted_grade: 1,
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
has_hidden_contribution: 'none',
|
||||
last_grade_publish_date: null,
|
||||
},
|
||||
],
|
||||
section_scores: [
|
||||
{
|
||||
display_name: 'First section',
|
||||
|
||||
@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
|
||||
let dropCount = numDroppable;
|
||||
// Drop the lowest grades
|
||||
while (dropCount && points.length >= dropCount) {
|
||||
const lowestScore = Math.min(...points);
|
||||
const lowestScoreIndex = points.indexOf(lowestScore);
|
||||
points.splice(lowestScoreIndex, 1);
|
||||
dropCount--;
|
||||
}
|
||||
let averageGrade = 0;
|
||||
let weightedGrade = 0;
|
||||
if (points.length) {
|
||||
// Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
|
||||
// reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
|
||||
// exists in edx-platform.
|
||||
averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
|
||||
weightedGrade = averageGrade * assignmentWeight;
|
||||
}
|
||||
return { averageGrade, weightedGrade };
|
||||
};
|
||||
|
||||
function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
|
||||
const gradeByAssignmentType = {};
|
||||
assignmentPolicies.forEach(assignment => {
|
||||
// Create an array with the number of total assignments and set the scores to 0
|
||||
// as placeholders for assignments that have not yet been released
|
||||
gradeByAssignmentType[assignment.type] = {
|
||||
grades: Array(assignment.numTotal).fill(0),
|
||||
numAssignmentsCreated: 0,
|
||||
numTotalExpectedAssignments: assignment.numTotal,
|
||||
};
|
||||
});
|
||||
|
||||
sectionScores.forEach((chapter) => {
|
||||
chapter.subsections.forEach((subsection) => {
|
||||
if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
assignmentType,
|
||||
numPointsEarned,
|
||||
numPointsPossible,
|
||||
} = subsection;
|
||||
|
||||
// If a subsection's assignment type does not match an assignment policy in Studio,
|
||||
// we won't be able to include it in this accumulation of grades by assignment type.
|
||||
// This may happen if a course author has removed/renamed an assignment policy in Studio and
|
||||
// neglected to update the subsection's of that assignment type
|
||||
if (!gradeByAssignmentType[assignmentType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let {
|
||||
numAssignmentsCreated,
|
||||
} = gradeByAssignmentType[assignmentType];
|
||||
|
||||
numAssignmentsCreated++;
|
||||
if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
|
||||
// Remove a placeholder grade so long as the number of recorded created assignments is less than the number
|
||||
// of expected assignments
|
||||
gradeByAssignmentType[assignmentType].grades.shift();
|
||||
}
|
||||
// Add the graded assignment to the list
|
||||
gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
|
||||
// Record the created assignment
|
||||
gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
|
||||
});
|
||||
});
|
||||
|
||||
return assignmentPolicies.map((assignment) => {
|
||||
const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
|
||||
gradeByAssignmentType[assignment.type].grades,
|
||||
assignment.weight,
|
||||
assignment.numDroppable,
|
||||
);
|
||||
|
||||
return {
|
||||
averageGrade,
|
||||
numDroppable: assignment.numDroppable,
|
||||
shortLabel: assignment.shortLabel,
|
||||
type: assignment.type,
|
||||
weight: assignment.weight,
|
||||
weightedGrade,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
@@ -236,11 +149,6 @@ export async function getProgressTabData(courseId, targetUserId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
const camelCasedData = camelCaseObject(data);
|
||||
|
||||
camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
|
||||
camelCasedData.gradingPolicy.assignmentPolicies,
|
||||
camelCasedData.sectionScores,
|
||||
);
|
||||
|
||||
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
|
||||
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
|
||||
// in order to preserve a course team's desired grade formatting.
|
||||
@@ -471,3 +379,24 @@ export async function searchCourseContentFromAPI(courseId, searchKeyword, option
|
||||
|
||||
return camelCaseObject(response);
|
||||
}
|
||||
|
||||
export async function getExamsData(courseId, sequenceId) {
|
||||
let url;
|
||||
|
||||
if (!getConfig().EXAMS_BASE_URL) {
|
||||
url = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
} else {
|
||||
url = `${getConfig().EXAMS_BASE_URL}/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
} catch (error) {
|
||||
const { httpErrorStatus } = error && error.customAttributes;
|
||||
if (httpErrorStatus === 404) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { getTimeOffsetMillis } from './api';
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getTimeOffsetMillis, getExamsData } from './api';
|
||||
import { initializeMockApp } from '../../setupTest';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
|
||||
describe('Calculate the time offset properly', () => {
|
||||
it('Should return 0 if the headerDate is not set', async () => {
|
||||
@@ -14,3 +22,156 @@ describe('Calculate the time offset properly', () => {
|
||||
expect(offset).toBe(86398750);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExamsData', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345';
|
||||
let originalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock.reset();
|
||||
originalConfig = getConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.reset();
|
||||
if (originalConfig) {
|
||||
setConfig(originalConfig);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use LMS URL when EXAMS_BASE_URL is not configured', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'created',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
exam: {
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceId,
|
||||
examName: 'Test Exam',
|
||||
attemptStatus: 'created',
|
||||
},
|
||||
});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('should use EXAMS_BASE_URL when configured', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'submitted',
|
||||
},
|
||||
};
|
||||
|
||||
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
exam: {
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceId,
|
||||
examName: 'Test Exam',
|
||||
attemptStatus: 'submitted',
|
||||
},
|
||||
});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
});
|
||||
|
||||
it('should return empty object when API returns 404', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
|
||||
// Mock a 404 error with the custom error response function to add customAttributes
|
||||
axiosMock.onGet(expectedUrl).reply(() => {
|
||||
const error = new Error('Request failed with status code 404');
|
||||
error.response = { status: 404, data: {} };
|
||||
error.customAttributes = { httpErrorStatus: 404 };
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
const result = await getExamsData(courseId, sequenceId);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw error for non-404 HTTP errors', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: undefined,
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const expectedUrl = `http://localhost:18000/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}?is_learning_mfe=true&content_id=${encodeURIComponent(sequenceId)}`;
|
||||
|
||||
// Mock a 500 error with custom error response
|
||||
axiosMock.onGet(expectedUrl).reply(() => {
|
||||
const error = new Error('Request failed with status code 500');
|
||||
error.response = { status: 500, data: { error: 'Server Error' } };
|
||||
error.customAttributes = { httpErrorStatus: 500 };
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
await expect(getExamsData(courseId, sequenceId)).rejects.toThrow();
|
||||
expect(axiosMock.history.get).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should properly encode URL parameters', async () => {
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
LMS_BASE_URL: 'http://localhost:18000',
|
||||
});
|
||||
|
||||
const specialCourseId = 'course-v1:edX+Demo X+Demo Course';
|
||||
const specialSequenceId = 'block-v1:edX+Demo X+Demo Course+type@sequential+block@test sequence';
|
||||
|
||||
const mockExamData = { exam: { id: 1 } };
|
||||
const expectedUrl = `http://localhost:18740/api/v1/student/exam/attempt/course_id/${encodeURIComponent(specialCourseId)}/content_id/${encodeURIComponent(specialSequenceId)}`;
|
||||
axiosMock.onGet(expectedUrl).reply(200, mockExamData);
|
||||
|
||||
await getExamsData(specialCourseId, specialSequenceId);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toBe(expectedUrl);
|
||||
expect(axiosMock.history.get[0].url).toContain('course-v1%3AedX%2BDemo%20X%2BDemo%20Course');
|
||||
expect(axiosMock.history.get[0].url).toContain('block-v1%3AedX%2BDemo%20X%2BDemo%20Course%2Btype%40sequential%2Bblock%40test%20sequence');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,4 +297,178 @@ describe('Data layer integration tests', () => {
|
||||
expect(enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test fetchExamAttemptsData', () => {
|
||||
const sequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@abcde',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock individual exam endpoints with different responses
|
||||
sequenceIds.forEach((sequenceId, index) => {
|
||||
// Handle both LMS and EXAMS service URL patterns
|
||||
const lmsExamUrl = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceId)}.*`);
|
||||
const examsServiceUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
|
||||
|
||||
let attemptStatus = 'ready_to_start';
|
||||
if (index === 0) {
|
||||
attemptStatus = 'created';
|
||||
} else if (index === 1) {
|
||||
attemptStatus = 'submitted';
|
||||
}
|
||||
|
||||
const mockExamData = {
|
||||
exam: {
|
||||
id: index + 1,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: `Test Exam ${index + 1}`,
|
||||
attempt_status: attemptStatus,
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
};
|
||||
|
||||
// Mock both URL patterns
|
||||
axiosMock.onGet(lmsExamUrl).reply(200, mockExamData);
|
||||
axiosMock.onGet(examsServiceUrl).reply(200, mockExamData);
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch exam data for all sequence IDs and dispatch setExamsData', async () => {
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify the examsData was set in the store
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: sequenceIds[0],
|
||||
examName: 'Test Exam 1',
|
||||
attemptStatus: 'created',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
courseId,
|
||||
contentId: sequenceIds[1],
|
||||
examName: 'Test Exam 2',
|
||||
attemptStatus: 'submitted',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
courseId,
|
||||
contentId: sequenceIds[2],
|
||||
examName: 'Test Exam 3',
|
||||
attemptStatus: 'ready_to_start',
|
||||
timeRemainingSeconds: 3600,
|
||||
},
|
||||
]);
|
||||
|
||||
// Verify all API calls were made
|
||||
expect(axiosMock.history.get).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle 404 responses and include empty objects in results', async () => {
|
||||
// Override one endpoint to return 404 for both URL patterns
|
||||
const examUrl404LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl404Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
axiosMock.onGet(examUrl404LMS).reply(404);
|
||||
axiosMock.onGet(examUrl404Exams).reply(404);
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify the examsData includes empty object for 404 response
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[1]).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle API errors and log them while continuing with other requests', async () => {
|
||||
// Override one endpoint to return 500 error for both URL patterns
|
||||
const examUrl500LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl500Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
axiosMock.onGet(examUrl500LMS).reply(500, { error: 'Server Error' });
|
||||
axiosMock.onGet(examUrl500Exams).reply(500, { error: 'Server Error' });
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
// Verify error was logged for the failed request
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
|
||||
// Verify the examsData still includes results for successful requests
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
// First item should be the error result (just empty object for API errors)
|
||||
expect(state.courseHome.examsData[0]).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle empty sequence IDs array', async () => {
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, []), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseHome.examsData).toEqual([]);
|
||||
expect(axiosMock.history.get).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle mixed success and error responses', async () => {
|
||||
// Setup mixed responses
|
||||
const examUrl1LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl1Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[0])}.*`);
|
||||
const examUrl2LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl2Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[1])}.*`);
|
||||
const examUrl3LMS = new RegExp(`.*edx_proctoring/v1/proctored_exam/attempt/course_id/${encodeURIComponent(courseId)}.*content_id=${encodeURIComponent(sequenceIds[2])}.*`);
|
||||
const examUrl3Exams = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceIds[2])}.*`);
|
||||
|
||||
axiosMock.onGet(examUrl1LMS).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
exam_name: 'Success Exam',
|
||||
course_id: courseId,
|
||||
content_id: sequenceIds[0],
|
||||
attempt_status: 'created',
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(examUrl1Exams).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
exam_name: 'Success Exam',
|
||||
course_id: courseId,
|
||||
content_id: sequenceIds[0],
|
||||
attempt_status: 'created',
|
||||
time_remaining_seconds: 3600,
|
||||
},
|
||||
});
|
||||
axiosMock.onGet(examUrl2LMS).reply(404);
|
||||
axiosMock.onGet(examUrl2Exams).reply(404);
|
||||
axiosMock.onGet(examUrl3LMS).reply(500, { error: 'Server Error' });
|
||||
axiosMock.onGet(examUrl3Exams).reply(500, { error: 'Server Error' });
|
||||
|
||||
await executeThunk(thunks.fetchExamAttemptsData(courseId, sequenceIds), store.dispatch);
|
||||
|
||||
const state = store.getState();
|
||||
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[0]).toMatchObject({
|
||||
id: 1,
|
||||
examName: 'Success Exam',
|
||||
courseId,
|
||||
contentId: sequenceIds[0],
|
||||
});
|
||||
expect(state.courseHome.examsData[1]).toEqual({});
|
||||
expect(state.courseHome.examsData[2]).toEqual({});
|
||||
|
||||
// Verify error was logged for the 500 error (may be called more than once due to multiple URL patterns)
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ const slice = createSlice({
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchProctoringInfoResolved: (state) => {
|
||||
@@ -53,6 +54,9 @@ const slice = createSlice({
|
||||
setShowSearch: (state, { payload }) => {
|
||||
state.showSearch = payload;
|
||||
},
|
||||
setExamsData: (state, { payload }) => {
|
||||
state.examsData = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,6 +68,7 @@ export const {
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setShowSearch,
|
||||
setExamsData,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
145
src/course-home/data/slice.test.js
Normal file
145
src/course-home/data/slice.test.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import { reducer, setExamsData } from './slice';
|
||||
|
||||
describe('course home data slice', () => {
|
||||
describe('setExamsData reducer', () => {
|
||||
it('should set examsData in state', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: null,
|
||||
};
|
||||
|
||||
const mockExamsData = [
|
||||
{
|
||||
id: 1,
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
examName: 'Midterm Exam',
|
||||
attemptStatus: 'created',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
examName: 'Final Exam',
|
||||
attemptStatus: 'submitted',
|
||||
},
|
||||
];
|
||||
|
||||
const action = setExamsData(mockExamsData);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toEqual(mockExamsData);
|
||||
expect(newState).toEqual({
|
||||
...initialState,
|
||||
examsData: mockExamsData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update examsData when state already has data', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: [{ id: 1, examName: 'Old Exam' }],
|
||||
};
|
||||
|
||||
const newExamsData = [
|
||||
{
|
||||
id: 2,
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
examName: 'New Exam',
|
||||
attemptStatus: 'ready_to_start',
|
||||
},
|
||||
];
|
||||
|
||||
const action = setExamsData(newExamsData);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toEqual(newExamsData);
|
||||
expect(newState.examsData).not.toEqual(initialState.examsData);
|
||||
});
|
||||
|
||||
it('should set examsData to empty array', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: [{ id: 1, examName: 'Some Exam' }],
|
||||
};
|
||||
|
||||
const action = setExamsData([]);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set examsData to null', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'loading',
|
||||
tabFetchStates: {},
|
||||
toastBodyText: '',
|
||||
toastBodyLink: null,
|
||||
toastHeader: '',
|
||||
showSearch: false,
|
||||
examsData: [{ id: 1, examName: 'Some Exam' }],
|
||||
};
|
||||
|
||||
const action = setExamsData(null);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
expect(newState.examsData).toBeNull();
|
||||
});
|
||||
|
||||
it('should not affect other state properties when setting examsData', () => {
|
||||
const initialState = {
|
||||
courseStatus: 'loaded',
|
||||
courseId: 'test-course-id',
|
||||
metadataModel: 'courseHomeCourseMetadata',
|
||||
proctoringPanelStatus: 'complete',
|
||||
tabFetchStates: { progress: 'loaded' },
|
||||
toastBodyText: 'Toast message',
|
||||
toastBodyLink: 'http://example.com',
|
||||
toastHeader: 'Toast Header',
|
||||
showSearch: true,
|
||||
examsData: null,
|
||||
};
|
||||
|
||||
const mockExamsData = [{ id: 1, examName: 'Test Exam' }];
|
||||
const action = setExamsData(mockExamsData);
|
||||
const newState = reducer(initialState, action);
|
||||
|
||||
// Verify that only examsData changed
|
||||
expect(newState).toEqual({
|
||||
...initialState,
|
||||
examsData: mockExamsData,
|
||||
});
|
||||
|
||||
// Verify other properties remain unchanged
|
||||
expect(newState.courseStatus).toBe(initialState.courseStatus);
|
||||
expect(newState.courseId).toBe(initialState.courseId);
|
||||
expect(newState.showSearch).toBe(initialState.showSearch);
|
||||
expect(newState.toastBodyText).toBe(initialState.toastBodyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
executePostFromPostEvent,
|
||||
getCourseHomeCourseMetadata,
|
||||
getDatesTabData,
|
||||
getExamsData,
|
||||
getOutlineTabData,
|
||||
getProgressTabData,
|
||||
postCourseDeadlines,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
fetchTabRequest,
|
||||
fetchTabSuccess,
|
||||
setCallToActionToast,
|
||||
setExamsData,
|
||||
} from './slice';
|
||||
|
||||
import mapSearchResponse from '../courseware-search/map-search-response';
|
||||
@@ -223,3 +225,19 @@ export function searchCourseContent(courseId, searchKeyword) {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchExamAttemptsData(courseId, sequenceIds) {
|
||||
return async (dispatch) => {
|
||||
const results = await Promise.all(sequenceIds.map(async (sequenceId) => {
|
||||
try {
|
||||
const response = await getExamsData(courseId, sequenceId);
|
||||
return response.exam || {};
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return {};
|
||||
}
|
||||
}));
|
||||
|
||||
dispatch(setExamsData(results));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,122 +204,122 @@ const messages = defineMessages({
|
||||
notStartedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.notStarted',
|
||||
defaultMessage: 'Not Started',
|
||||
description: 'It indcate that proctortrack onboarding exam hasn’t started yet',
|
||||
description: 'It indcate that proctored onboarding exam hasn’t started yet',
|
||||
},
|
||||
startedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.started',
|
||||
defaultMessage: 'Started',
|
||||
description: 'Label to indicate the starting status of the proctortrack onboarding exam',
|
||||
description: 'Label to indicate the starting status of the proctored onboarding exam',
|
||||
},
|
||||
submittedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.submitted',
|
||||
defaultMessage: 'Submitted',
|
||||
description: 'Label to indicate the submitted status of proctortrack onboarding exam',
|
||||
description: 'Label to indicate the submitted status of proctored onboarding exam',
|
||||
},
|
||||
verifiedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.verified',
|
||||
defaultMessage: 'Verified',
|
||||
description: 'Label to indicate the verified status of the proctortrack onboarding exam',
|
||||
description: 'Label to indicate the verified status of the proctored onboarding exam',
|
||||
},
|
||||
rejectedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
description: 'Label to indicate the rejection status of the proctortrack onboarding exam',
|
||||
description: 'Label to indicate the rejection status of the proctored onboarding exam',
|
||||
},
|
||||
errorProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.error',
|
||||
defaultMessage: 'Error',
|
||||
description: 'Label to indicate that there is error in proctortrack onboarding exam',
|
||||
description: 'Label to indicate that there is error in proctored onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.otherCourseApproved',
|
||||
defaultMessage: 'Approved in Another Course',
|
||||
description: 'Label to indicate that the proctortrack onboarding exam is verified based on taking onboarding exam on another course',
|
||||
description: 'Label to indicate that the proctored onboarding exam is verified based on taking onboarding exam on another course',
|
||||
},
|
||||
expiringSoonProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expiringSoon',
|
||||
defaultMessage: 'Expiring Soon',
|
||||
description: 'A label to indicate that proctortrack onboarding exam will expire soon',
|
||||
description: 'A label to indicate that proctored onboarding exam will expire soon',
|
||||
},
|
||||
expiredProctoringStatus: {
|
||||
id: 'learning.proctoringPanel.status.expired',
|
||||
defaultMessage: 'Expired',
|
||||
description: 'A label to indicate that proctortrack onboarding exam has expired',
|
||||
description: 'A label to indicate that proctored onboarding exam has expired',
|
||||
},
|
||||
proctoringCurrentStatus: {
|
||||
id: 'learning.proctoringPanel.status',
|
||||
defaultMessage: 'Current Onboarding Status:',
|
||||
description: 'The text that precede the status label of proctortrack onboarding exam',
|
||||
description: 'The text that precede the status label of proctored onboarding exam',
|
||||
},
|
||||
notStartedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.notStarted',
|
||||
defaultMessage: 'You have not started your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (not started) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (not started) label of the proctored onboarding exam',
|
||||
},
|
||||
startedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.started',
|
||||
defaultMessage: 'You have started your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (started) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (started) label of the proctored onboarding exam',
|
||||
},
|
||||
submittedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.submitted',
|
||||
defaultMessage: 'You have submitted your onboarding exam.',
|
||||
description: 'The text that explain the meaning of (submitted) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (submitted) label of the proctored onboarding exam',
|
||||
},
|
||||
verifiedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.verified',
|
||||
defaultMessage: 'Your onboarding exam has been approved in this course.',
|
||||
description: 'The text that explain the meaning of (verified) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (verified) label of the proctored onboarding exam',
|
||||
},
|
||||
rejectedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.rejected',
|
||||
defaultMessage: 'Your onboarding exam has been rejected. Please retry onboarding.',
|
||||
description: 'The text that explain the meaning of (rejected) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (rejected) label of the proctored onboarding exam',
|
||||
},
|
||||
errorProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.error',
|
||||
defaultMessage: 'An error has occurred during your onboarding exam. Please retry onboarding.',
|
||||
description: 'The text that explain the meaning of (error) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (error) label of the proctored onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.otherCourseApproved',
|
||||
defaultMessage: 'Your onboarding exam has been approved in another course.',
|
||||
description: 'The text that explain the meaning of (approved in another course) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (approved in another course) label of the proctored onboarding exam',
|
||||
},
|
||||
otherCourseApprovedProctoringDetail: {
|
||||
id: 'learning.proctoringPanel.detail.otherCourseApproved',
|
||||
defaultMessage: 'If your device has changed, we recommend that you complete this course\'s onboarding exam in order to ensure that your setup still meets the requirements for proctoring.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (approved in another course)',
|
||||
description: 'The text that recommend an action when the status of the proctored onboarding exam is (approved in another course)',
|
||||
},
|
||||
expiringSoonProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expiringSoon',
|
||||
defaultMessage: 'Your onboarding profile has been approved. However, your onboarding status is expiring soon. Please complete onboarding again to ensure that you will be able to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expiring soon)',
|
||||
description: 'The text that recommend an action when the status of the proctored onboarding exam is (expiring soon)',
|
||||
},
|
||||
expiredProctoringMessage: {
|
||||
id: 'learning.proctoringPanel.message.expired',
|
||||
defaultMessage: 'Your onboarding status has expired. Please complete onboarding again to continue taking proctored exams.',
|
||||
description: 'The text that recommend an action when the status of the proctortrack onboarding exam is (expired)',
|
||||
description: 'The text that recommend an action when the status of the proctored onboarding exam is (expired)',
|
||||
},
|
||||
proctoringPanelGeneralInfo: {
|
||||
id: 'learning.proctoringPanel.generalInfo',
|
||||
defaultMessage: 'You must complete the onboarding process prior to taking any proctored exam. ',
|
||||
description: 'It indicate key and important fact to learner about the importance of taking proctortrack onboarding exam',
|
||||
description: 'It indicate key and important fact to learner about the importance of taking proctored onboarding exam',
|
||||
},
|
||||
proctoringPanelGeneralInfoSubmitted: {
|
||||
id: 'learning.proctoringPanel.generalInfoSubmitted',
|
||||
defaultMessage: 'Your submitted profile is in review.',
|
||||
description: 'The text that explain the meaning of (in review) label of the proctortrack onboarding exam',
|
||||
description: 'The text that explain the meaning of (in review) label of the proctored onboarding exam',
|
||||
},
|
||||
proctoringPanelGeneralTime: {
|
||||
id: 'learning.proctoringPanel.generalTime',
|
||||
defaultMessage: 'Onboarding profile review can take 2+ business days.',
|
||||
description: 'This text explain for how long the (in review) status of the proctortrack onboarding exam might remain',
|
||||
description: 'This text explain for how long the (in review) status of the proctored onboarding exam might remain',
|
||||
},
|
||||
proctoringOnboardingButton: {
|
||||
id: 'learning.proctoringPanel.onboardingButton',
|
||||
defaultMessage: 'Complete Onboarding',
|
||||
description: 'Text shown on the button that starts the actual proctortrack onboarding exam when it is released',
|
||||
description: 'Text shown on the button that starts the actual proctored onboarding exam when it is released',
|
||||
},
|
||||
proctoringOnboardingPracticeButton: {
|
||||
id: 'learning.proctoringPanel.onboardingPracticeButton',
|
||||
@@ -329,17 +329,17 @@ const messages = defineMessages({
|
||||
proctoringOnboardingButtonNotOpen: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonNotOpen',
|
||||
defaultMessage: 'Onboarding Opens: {releaseDate}',
|
||||
description: 'It indicate when or from when the learner can take the proctortrack onboarding exam',
|
||||
description: 'It indicate when or from when the learner can take the proctored onboarding exam',
|
||||
},
|
||||
proctoringReviewRequirementsButton: {
|
||||
id: 'learning.proctoringPanel.reviewRequirementsButton',
|
||||
defaultMessage: 'Review instructions and system requirements',
|
||||
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctortrack onboarding exam',
|
||||
description: 'Anchor text for button which redirect leaner to doc or a detailed page about proctored onboarding exam',
|
||||
},
|
||||
proctoringOnboardingButtonPastDue: {
|
||||
id: 'learning.proctoringPanel.onboardingButtonPastDue',
|
||||
defaultMessage: 'Onboarding Past Due',
|
||||
description: 'Text that show when the deadline of proctortrack onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
description: 'Text that show when the deadline of proctored onboarding exam has passed, it appears on button that start the onboarding exam however for this case the button is disabled for obvious reason',
|
||||
},
|
||||
sequenceDueDate: {
|
||||
id: 'learning.outline.sequence-due-date-set',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import { useContextId } from '../../data/hooks';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import ProgressTabCertificateStatusSidePanelSlot from '../../plugin-slots/ProgressTabCertificateStatusSidePanelSlot';
|
||||
|
||||
import CourseCompletion from './course-completion/CourseCompletion';
|
||||
@@ -10,11 +11,17 @@ import ProgressTabCertificateStatusMainBodySlot from '../../plugin-slots/Progres
|
||||
import ProgressTabCourseGradeSlot from '../../plugin-slots/ProgressTabCourseGradeSlot';
|
||||
import ProgressTabGradeBreakdownSlot from '../../plugin-slots/ProgressTabGradeBreakdownSlot';
|
||||
import ProgressTabRelatedLinksSlot from '../../plugin-slots/ProgressTabRelatedLinksSlot';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { useGetExamsData } from './hooks';
|
||||
|
||||
const ProgressTab = () => {
|
||||
const courseId = useContextId();
|
||||
const { disableProgressGraph } = useModel('progress', courseId);
|
||||
const { disableProgressGraph, sectionScores } = useModel('progress', courseId);
|
||||
|
||||
const sequenceIds = useMemo(() => (
|
||||
sectionScores.flatMap((section) => (section.subsections)).map((subsection) => subsection.blockKey)
|
||||
), [sectionScores]);
|
||||
|
||||
useGetExamsData(courseId, sequenceIds);
|
||||
|
||||
const windowWidth = useWindowSize().width;
|
||||
if (windowWidth === undefined) {
|
||||
|
||||
@@ -661,143 +661,133 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Grade Summary when assignment policies are not populated', async () => {
|
||||
it('does not render Grade Summary when assignment type grade summary is not populated', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
section_scores: [],
|
||||
assignment_type_grade_summary: [],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
|
||||
it('shows lock icon when all subsections of assignment type are hidden', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 2,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of droppable assignments is zero', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
|
||||
});
|
||||
it('calculates weighted grades correctly', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 1,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 0.5,
|
||||
},
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 1,
|
||||
short_label: 'Ex',
|
||||
type: 'Exam',
|
||||
weight: 0.5,
|
||||
short_label: 'Final',
|
||||
type: 'Final Exam',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Final Exam',
|
||||
weight: 0.4,
|
||||
average_grade: 0.0,
|
||||
weighted_grade: 0.0,
|
||||
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
|
||||
has_hidden_contribution: 'all',
|
||||
short_label: 'Final',
|
||||
num_droppable: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
expect(screen.getByText('Grade summary')).toBeInTheDocument();
|
||||
// The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
|
||||
expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
|
||||
// Should show lock icon for grade and weighted grade
|
||||
expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
average_grade: 0.25,
|
||||
weighted_grade: 0.25,
|
||||
last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
|
||||
has_hidden_contribution: 'some',
|
||||
short_label: 'HW',
|
||||
num_droppable: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
await fetchAndRender();
|
||||
// Should show percent + hidden scores for grade and weighted grade
|
||||
const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/);
|
||||
expect(hiddenScoresCells).toHaveLength(2);
|
||||
// Only correct visible scores should be shown (from subsection2)
|
||||
// The correct visible score is 1/4 = 0.25 -> 25%
|
||||
expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores');
|
||||
expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores');
|
||||
});
|
||||
|
||||
it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => {
|
||||
setTabData({
|
||||
grading_policy: {
|
||||
assignment_policies: [
|
||||
{
|
||||
num_droppable: 0,
|
||||
num_total: 2,
|
||||
short_label: 'HW',
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
grade_range: {
|
||||
pass: 0.75,
|
||||
},
|
||||
},
|
||||
assignment_type_grade_summary: [
|
||||
{
|
||||
type: 'Homework',
|
||||
weight: 1,
|
||||
average_grade: 1,
|
||||
weighted_grade: 1,
|
||||
last_grade_publish_date: tomorrow.toISOString(),
|
||||
has_hidden_contribution: 'none',
|
||||
short_label: 'HW',
|
||||
num_droppable: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
const formattedDateTime = new Intl.DateTimeFormat('en', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
}).format(tomorrow);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
`Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders override notice', async () => {
|
||||
@@ -1500,4 +1490,287 @@ describe('Progress Tab', () => {
|
||||
expect(screen.getByText('Course progress for otherstudent')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Exam data fetching integration', () => {
|
||||
const mockSectionScores = [
|
||||
{
|
||||
display_name: 'Section 1',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
display_name: 'Midterm Exam',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.8,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Homework',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@homework1',
|
||||
display_name: 'Homework 1',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.9,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Section 2',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
display_name: 'Final Exam',
|
||||
learner_has_access: true,
|
||||
has_graded_assignment: true,
|
||||
percent_graded: 0.85,
|
||||
show_correctness: 'always',
|
||||
show_grades: true,
|
||||
url: '/mock-url',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset any existing handlers to avoid conflicts
|
||||
axiosMock.reset();
|
||||
|
||||
// Re-add the base mocks that other tests expect
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
|
||||
// Mock exam data endpoints using specific GET handlers
|
||||
axiosMock.onGet(/.*exam1.*/).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
exam_name: 'Midterm Exam',
|
||||
attempt_status: 'submitted',
|
||||
time_remaining_seconds: 0,
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock.onGet(/.*homework1.*/).reply(404);
|
||||
|
||||
axiosMock.onGet(/.*final_exam.*/).reply(200, {
|
||||
exam: {
|
||||
id: 2,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
exam_name: 'Final Exam',
|
||||
attempt_status: 'ready_to_start',
|
||||
time_remaining_seconds: 7200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch exam data for all subsections when ProgressTab renders', async () => {
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify exam API calls were made for all subsections
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
|
||||
// Verify the exam data is in the Redux store
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
|
||||
// Check the exam data structure
|
||||
expect(state.courseHome.examsData[0]).toEqual({
|
||||
id: 1,
|
||||
courseId,
|
||||
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@exam1',
|
||||
examName: 'Midterm Exam',
|
||||
attemptStatus: 'submitted',
|
||||
timeRemainingSeconds: 0,
|
||||
});
|
||||
|
||||
expect(state.courseHome.examsData[1]).toEqual({}); // 404 response for homework
|
||||
|
||||
expect(state.courseHome.examsData[2]).toEqual({
|
||||
id: 2,
|
||||
courseId,
|
||||
contentId: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
examName: 'Final Exam',
|
||||
attemptStatus: 'ready_to_start',
|
||||
timeRemainingSeconds: 7200,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty section scores gracefully', async () => {
|
||||
setTabData({ section_scores: [] });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify no exam API calls were made
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(0);
|
||||
|
||||
// Verify empty exam data in Redux store
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should re-fetch exam data when section scores change', async () => {
|
||||
// Initial render with limited section scores
|
||||
setTabData({
|
||||
section_scores: [mockSectionScores[0]], // Only first section
|
||||
});
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify initial API calls (2 subsections in first section)
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(2);
|
||||
|
||||
// Clear axios history to track new calls
|
||||
axiosMock.resetHistory();
|
||||
|
||||
// Update with full section scores and re-render
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
await executeThunk(thunks.fetchProgressTab(courseId), store.dispatch);
|
||||
|
||||
// Verify additional API calls for all subsections
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should handle exam API errors gracefully without breaking ProgressTab', async () => {
|
||||
// Clear existing mocks and setup specific error scenario
|
||||
axiosMock.reset();
|
||||
|
||||
// Re-add base mocks
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
axiosMock.onGet(progressUrl).reply(200, defaultTabData);
|
||||
axiosMock.onGet(masqueradeUrl).reply(200, { success: true });
|
||||
|
||||
// Mock first exam to return 500 error
|
||||
axiosMock.onGet(/.*exam1.*/).reply(500, { error: 'Server Error' });
|
||||
|
||||
// Mock other exams to succeed
|
||||
axiosMock.onGet(/.*homework1.*/).reply(404, { customAttributes: { httpErrorStatus: 404 } });
|
||||
axiosMock.onGet(/.*final_exam.*/).reply(200, {
|
||||
exam: {
|
||||
id: 2,
|
||||
course_id: courseId,
|
||||
content_id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@final_exam',
|
||||
exam_name: 'Final Exam',
|
||||
attempt_status: 'ready_to_start',
|
||||
time_remaining_seconds: 7200,
|
||||
},
|
||||
});
|
||||
|
||||
setTabData({ section_scores: mockSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify ProgressTab still renders successfully despite API error
|
||||
expect(screen.getByText('Grades')).toBeInTheDocument();
|
||||
|
||||
// Verify the exam data includes error placeholder for failed request
|
||||
const state = store.getState();
|
||||
expect(state.courseHome.examsData).toHaveLength(3);
|
||||
expect(state.courseHome.examsData[0]).toEqual({}); // Failed request returns empty object
|
||||
});
|
||||
|
||||
it('should use EXAMS_BASE_URL when configured for exam API calls', async () => {
|
||||
// Configure EXAMS_BASE_URL
|
||||
const originalConfig = getConfig();
|
||||
setConfig({
|
||||
...originalConfig,
|
||||
EXAMS_BASE_URL: 'http://localhost:18740',
|
||||
});
|
||||
|
||||
// Override mock to use new base URL
|
||||
const examUrlWithExamsBase = /http:\/\/localhost:18740\/api\/v1\/student\/exam\/attempt\/course_id.*/;
|
||||
axiosMock.onGet(examUrlWithExamsBase).reply(200, {
|
||||
exam: {
|
||||
id: 1,
|
||||
course_id: courseId,
|
||||
exam_name: 'Test Exam',
|
||||
attempt_status: 'created',
|
||||
},
|
||||
});
|
||||
|
||||
setTabData({ section_scores: [mockSectionScores[0]] });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify API calls use EXAMS_BASE_URL
|
||||
const examApiCalls = axiosMock.history.get.filter(req => req.url.includes('localhost:18740'));
|
||||
expect(examApiCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Restore original config
|
||||
setConfig(originalConfig);
|
||||
});
|
||||
|
||||
it('should extract sequence IDs correctly from nested section scores structure', async () => {
|
||||
const complexSectionScores = [
|
||||
{
|
||||
display_name: 'Introduction',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Lecture',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
|
||||
display_name: 'Course Introduction',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
display_name: 'Assessments',
|
||||
subsections: [
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
|
||||
display_name: 'Quiz 1',
|
||||
},
|
||||
{
|
||||
assignment_type: 'Exam',
|
||||
block_key: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
|
||||
display_name: 'Quiz 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock all the expected sequence IDs
|
||||
const expectedSequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@intro',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz1',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@quiz2',
|
||||
];
|
||||
|
||||
expectedSequenceIds.forEach((sequenceId, index) => {
|
||||
const examUrl = new RegExp(`.*/api/v1/student/exam/attempt/course_id/${encodeURIComponent(courseId)}/content_id/${encodeURIComponent(sequenceId)}.*`);
|
||||
axiosMock.onGet(examUrl).reply(index === 0 ? 404 : 200, {
|
||||
exam: {
|
||||
id: index,
|
||||
course_id: courseId,
|
||||
content_id: sequenceId,
|
||||
exam_name: `Test ${index}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
setTabData({ section_scores: complexSectionScores });
|
||||
|
||||
await fetchAndRender();
|
||||
|
||||
// Verify API calls were made for all extracted sequence IDs
|
||||
expect(axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'))).toHaveLength(3);
|
||||
|
||||
// Verify correct sequence IDs were used in API calls
|
||||
const apiCalls = axiosMock.history.get.filter(req => req.url.includes('/api/v1/student/exam/attempt/'));
|
||||
expectedSequenceIds.forEach(sequenceId => {
|
||||
expect(apiCalls.some(call => call.url.includes(encodeURIComponent(sequenceId)))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,26 +8,57 @@ import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
import GradeRangeTooltip from './GradeRangeTooltip';
|
||||
import messages from '../messages';
|
||||
import { getLatestDueDateInFuture } from '../../utils';
|
||||
|
||||
const ResponsiveText = ({
|
||||
wideScreen, children, hasLetterGrades, passingGrade,
|
||||
}) => {
|
||||
const className = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
|
||||
const iconSize = wideScreen ? 'h3' : 'h4';
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{children}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName={iconSize} passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const NoticeRow = ({
|
||||
wideScreen, icon, bgClass, message,
|
||||
}) => {
|
||||
const textClass = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
|
||||
return (
|
||||
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${bgClass}`}>
|
||||
<div className="col-auto p-0">{icon}</div>
|
||||
<div className="col-11 pl-2 px-0">
|
||||
<span className={textClass}>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseGradeFooter = ({ passingGrade }) => {
|
||||
const intl = useIntl();
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
letterGrade,
|
||||
},
|
||||
gradingPolicy: {
|
||||
gradeRange,
|
||||
},
|
||||
assignmentTypeGradeSummary,
|
||||
courseGrade: { isPassing, letterGrade },
|
||||
gradingPolicy: { gradeRange },
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const latestDueDate = getLatestDueDateInFuture(assignmentTypeGradeSummary);
|
||||
const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth;
|
||||
const hasLetterGrades = Object.keys(gradeRange).length > 1;
|
||||
|
||||
const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key
|
||||
// build footer text
|
||||
let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade });
|
||||
|
||||
if (isPassing) {
|
||||
if (hasLetterGrades) {
|
||||
const minGradeRangeCutoff = gradeRange[letterGrade] * 100;
|
||||
@@ -47,42 +78,63 @@ const CourseGradeFooter = ({ passingGrade }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const icon = isPassing ? <Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
|
||||
: <Icon src={WarningFilled} className="d-inline-flex align-bottom" />;
|
||||
const passingIcon = isPassing ? (
|
||||
<Icon src={CheckCircle} className="text-success-300 d-inline-flex align-bottom" />
|
||||
) : (
|
||||
<Icon src={WarningFilled} className="d-inline-flex align-bottom" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`row w-100 m-0 px-4 py-3 py-md-4 rounded-bottom ${isPassing ? 'bg-success-100' : 'bg-warning-100'}`}>
|
||||
<div className="col-auto p-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="col-11 pl-2 px-0">
|
||||
{!wideScreen && (
|
||||
<span className="h5 align-bottom">
|
||||
<div>
|
||||
<NoticeRow
|
||||
wideScreen={wideScreen}
|
||||
icon={passingIcon}
|
||||
bgClass={isPassing ? 'bg-success-100' : 'bg-warning-100'}
|
||||
message={(
|
||||
<ResponsiveText
|
||||
wideScreen={wideScreen}
|
||||
hasLetterGrades={hasLetterGrades}
|
||||
passingGrade={passingGrade}
|
||||
>
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h4" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</ResponsiveText>
|
||||
)}
|
||||
{wideScreen && (
|
||||
<span className="h4 m-0 align-bottom">
|
||||
{footerText}
|
||||
{hasLetterGrades && (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
|
||||
<GradeRangeTooltip iconButtonClassName="h3" passingGrade={passingGrade} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
{latestDueDate && (
|
||||
<NoticeRow
|
||||
wideScreen={wideScreen}
|
||||
icon={<Icon src={WarningFilled} className="d-inline-flex align-bottom" />}
|
||||
bgClass="bg-warning-100"
|
||||
message={intl.formatMessage(messages.courseGradeFooterDueDateNotice, {
|
||||
dueDate: intl.formatDate(latestDueDate, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
timeZoneName: 'short',
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResponsiveText.propTypes = {
|
||||
wideScreen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
hasLetterGrades: PropTypes.bool.isRequired,
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
NoticeRow.propTypes = {
|
||||
wideScreen: PropTypes.bool.isRequired,
|
||||
icon: PropTypes.element.isRequired,
|
||||
bgClass: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseGradeFooter.propTypes = {
|
||||
passingGrade: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
assignmentTypeGradeSummary,
|
||||
courseGrade: {
|
||||
isPassing,
|
||||
percent,
|
||||
@@ -25,6 +26,8 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
|
||||
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none');
|
||||
|
||||
if (isLocaleRtl) {
|
||||
currentGradeDirection = currentGrade < 50 ? '-' : '';
|
||||
}
|
||||
@@ -56,6 +59,15 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => {
|
||||
>
|
||||
{intl.formatMessage(messages.currentGradeLabel)}
|
||||
</text>
|
||||
<text
|
||||
className="x-small"
|
||||
textAnchor={currentGrade < 50 ? 'start' : 'end'}
|
||||
x={`${Math.min(...[isLocaleRtl ? 100 - currentGrade : currentGrade, 100])}%`}
|
||||
y="35px"
|
||||
style={{ transform: `translateX(${currentGradeDirection}3.4em)` }}
|
||||
>
|
||||
{hasHiddenGrades ? ` + ${intl.formatMessage(messages.hiddenScoreLabel)}` : ''}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,14 +10,12 @@ const GradeSummary = () => {
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
gradingPolicy: {
|
||||
assignmentPolicies,
|
||||
},
|
||||
assignmentTypeGradeSummary,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false);
|
||||
|
||||
if (assignmentPolicies.length === 0) {
|
||||
if (assignmentTypeGradeSummary.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { DataTable } from '@openedx/paragon';
|
||||
import { Lock } from '@openedx/paragon/icons';
|
||||
import { useContextId } from '../../../../data/hooks';
|
||||
import { useModel } from '../../../../generic/model-store';
|
||||
|
||||
@@ -16,9 +17,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
gradingPolicy: {
|
||||
assignmentPolicies,
|
||||
},
|
||||
assignmentTypeGradeSummary,
|
||||
gradesFeatureIsFullyLocked,
|
||||
sectionScores,
|
||||
} = useModel('progress', courseId);
|
||||
@@ -55,7 +54,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const gradeSummaryData = assignmentPolicies.map((assignment) => {
|
||||
const gradeSummaryData = assignmentTypeGradeSummary.map((assignment) => {
|
||||
const {
|
||||
averageGrade,
|
||||
numDroppable,
|
||||
@@ -80,13 +79,24 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType);
|
||||
const isLocaleRtl = isRtl(getLocale());
|
||||
|
||||
let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
|
||||
let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`;
|
||||
|
||||
if (assignment.hasHiddenContribution === 'all') {
|
||||
gradeDisplay = <Lock data-testid="lock-icon" />;
|
||||
weightedGradeDisplay = <Lock data-testid="lock-icon" />;
|
||||
} else if (assignment.hasHiddenContribution === 'some') {
|
||||
gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
|
||||
weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: {
|
||||
footnoteId, footnoteMarker, type: assignmentType, locked,
|
||||
},
|
||||
weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked },
|
||||
grade: { grade: gradeDisplay, locked },
|
||||
weightedGrade: { weightedGrade: weightedGradeDisplay, locked },
|
||||
};
|
||||
});
|
||||
const getAssignmentTypeCell = (value) => (
|
||||
@@ -102,6 +112,16 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="micro mb-3 pl-3 text-gray-700">
|
||||
<li>
|
||||
<b>{intl.formatMessage(messages.hiddenScoreLabel)}: </b>
|
||||
{intl.formatMessage(messages.hiddenScoreInfoText)}
|
||||
</li>
|
||||
<li>
|
||||
<b><Lock style={{ height: '15px' }} />: </b>
|
||||
{` ${intl.formatMessage(messages.hiddenScoreLockInfoText)}`}
|
||||
</li>
|
||||
</ul>
|
||||
<DataTable
|
||||
data={gradeSummaryData}
|
||||
itemCount={gradeSummaryData.length}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
DataTable,
|
||||
DataTableContext,
|
||||
Icon,
|
||||
OverlayTrigger,
|
||||
Stack,
|
||||
@@ -17,18 +14,6 @@ import messages from '../messages';
|
||||
|
||||
const GradeSummaryTableFooter = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { data } = useContext(DataTableContext);
|
||||
|
||||
const rawGrade = data.reduce(
|
||||
(grade, currentValue) => {
|
||||
const { weightedGrade } = currentValue.weightedGrade;
|
||||
const percent = weightedGrade.replace(/%/g, '').trim();
|
||||
return grade + parseFloat(percent);
|
||||
},
|
||||
0,
|
||||
).toFixed(2);
|
||||
|
||||
const courseId = useContextId();
|
||||
|
||||
const {
|
||||
@@ -36,8 +21,16 @@ const GradeSummaryTableFooter = () => {
|
||||
isPassing,
|
||||
percent,
|
||||
},
|
||||
finalGrades,
|
||||
} = useModel('progress', courseId);
|
||||
|
||||
const getGradePercent = (grade) => {
|
||||
const percentage = grade * 100;
|
||||
return Number.isInteger(percentage) ? percentage.toFixed(0) : percentage.toFixed(2);
|
||||
};
|
||||
|
||||
const rawGrade = getGradePercent(finalGrades);
|
||||
|
||||
const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100';
|
||||
const totalGrade = (percent * 100).toFixed(0);
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.',
|
||||
description: 'Alt text for the grade chart bar',
|
||||
},
|
||||
courseGradeFooterDueDateNotice: {
|
||||
id: 'progress.courseGrade.footer.dueDateNotice',
|
||||
defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.',
|
||||
description: 'This is shown when there are pending assignments with a due date in the future',
|
||||
},
|
||||
courseGradeFooterGenericPassing: {
|
||||
id: 'progress.courseGrade.footer.generic.passing',
|
||||
defaultMessage: 'You’re currently passing this course',
|
||||
@@ -148,6 +153,21 @@ const messages = defineMessages({
|
||||
+ "Your weighted grade is what's used to determine if you pass the course.",
|
||||
description: 'The content of (tip box) for the grade summary section',
|
||||
},
|
||||
hiddenScoreLabel: {
|
||||
id: 'progress.hiddenScoreLabel',
|
||||
defaultMessage: 'Hidden Scores',
|
||||
description: 'Text to indicate that some scores are hidden',
|
||||
},
|
||||
hiddenScoreInfoText: {
|
||||
id: 'progress.hiddenScoreInfoText',
|
||||
defaultMessage: 'Scores from assignments that count toward your final grade but some are not shown here.',
|
||||
description: 'Information text about hidden score label',
|
||||
},
|
||||
hiddenScoreLockInfoText: {
|
||||
id: 'progress.hiddenScoreLockInfoText',
|
||||
defaultMessage: 'Scores for an assignment type are hidden but still counted toward the course grade.',
|
||||
description: 'Information text about hidden score label when learners have limited access to grades feature',
|
||||
},
|
||||
noAccessToAssignmentType: {
|
||||
id: 'progress.noAcessToAssignmentType',
|
||||
defaultMessage: 'You do not have access to assignments of type {assignmentType}',
|
||||
|
||||
12
src/course-home/progress-tab/hooks.jsx
Normal file
12
src/course-home/progress-tab/hooks.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchExamAttemptsData } from '../data/thunks';
|
||||
|
||||
export function useGetExamsData(courseId, sequenceIds) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchExamAttemptsData(courseId, sequenceIds));
|
||||
}, [dispatch, courseId, sequenceIds]);
|
||||
}
|
||||
168
src/course-home/progress-tab/hooks.test.jsx
Normal file
168
src/course-home/progress-tab/hooks.test.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useGetExamsData } from './hooks';
|
||||
import { fetchExamAttemptsData } from '../data/thunks';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/thunks', () => ({
|
||||
fetchExamAttemptsData: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useGetExamsData hook', () => {
|
||||
const mockDispatch = jest.fn();
|
||||
const mockFetchExamAttemptsData = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useDispatch.mockReturnValue(mockDispatch);
|
||||
fetchExamAttemptsData.mockReturnValue(mockFetchExamAttemptsData);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should dispatch fetchExamAttemptsData on mount', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
];
|
||||
|
||||
renderHook(() => useGetExamsData(courseId, sequenceIds));
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should re-dispatch when courseId changes', () => {
|
||||
const initialCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const newCourseId = 'course-v1:edX+NewCourse+Demo';
|
||||
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId: initialCourseId, sequenceIds },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(initialCourseId, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with new courseId
|
||||
rerender({ courseId: newCourseId, sequenceIds });
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(newCourseId, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should re-dispatch when sequenceIds changes', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const initialSequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
const newSequenceIds = [
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345',
|
||||
'block-v1:edX+DemoX+Demo_Course+type@sequential+block@67890',
|
||||
];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId, sequenceIds: initialSequenceIds },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, initialSequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with new sequenceIds
|
||||
rerender({ courseId, sequenceIds: newSequenceIds });
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, newSequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should not re-dispatch when neither courseId nor sequenceIds changes', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId, sequenceIds },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with same props
|
||||
rerender({ courseId, sequenceIds });
|
||||
|
||||
// Should not dispatch again
|
||||
expect(fetchExamAttemptsData).not.toHaveBeenCalled();
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty sequenceIds array', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds = [];
|
||||
|
||||
renderHook(() => useGetExamsData(courseId, sequenceIds));
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, []);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should handle null/undefined courseId', () => {
|
||||
const sequenceIds = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
|
||||
renderHook(() => useGetExamsData(null, sequenceIds));
|
||||
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(null, sequenceIds);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
|
||||
it('should handle sequenceIds reference change but same content', () => {
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const sequenceIds1 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345'];
|
||||
const sequenceIds2 = ['block-v1:edX+DemoX+Demo_Course+type@sequential+block@12345']; // Same content, different reference
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ courseId: cId, sequenceIds: sIds }) => useGetExamsData(cId, sIds),
|
||||
{
|
||||
initialProps: { courseId, sequenceIds: sequenceIds1 },
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initial call
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clear mocks to isolate the re-render call
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-render with different reference but same content
|
||||
rerender({ courseId, sequenceIds: sequenceIds2 });
|
||||
|
||||
// Should dispatch again because the reference changed (useEffect dependency)
|
||||
expect(fetchExamAttemptsData).toHaveBeenCalledWith(courseId, sequenceIds2);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockFetchExamAttemptsData);
|
||||
});
|
||||
});
|
||||
@@ -5,3 +5,15 @@ export const showUngradedAssignments = () => (
|
||||
getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true'
|
||||
|| getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true
|
||||
);
|
||||
|
||||
export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => {
|
||||
let latest = null;
|
||||
assignmentTypeGradeSummary.forEach((assignment) => {
|
||||
const assignmentLastGradePublishDate = assignment.lastGradePublishDate;
|
||||
if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest))
|
||||
&& new Date(assignmentLastGradePublishDate) > new Date()) {
|
||||
latest = assignmentLastGradePublishDate;
|
||||
}
|
||||
});
|
||||
return latest;
|
||||
};
|
||||
|
||||
20
src/course-tabs/CourseTabLink.tsx
Normal file
20
src/course-tabs/CourseTabLink.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
interface CourseTabLinkProps {
|
||||
slug: string;
|
||||
activeTabSlug?: string;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const CourseTabLink = ({
|
||||
slug, activeTabSlug, url, title,
|
||||
}: CourseTabLinkProps) => (
|
||||
<a
|
||||
href={url}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
);
|
||||
25
src/course-tabs/CourseTabLinksList.tsx
Normal file
25
src/course-tabs/CourseTabLinksList.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { CourseTabLink } from '@src/course-tabs/CourseTabLink';
|
||||
import React from 'react';
|
||||
|
||||
interface CourseTabLinkListProps {
|
||||
tabs: Array<{
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}>,
|
||||
activeTabSlug?: string;
|
||||
}
|
||||
|
||||
export const CourseTabLinksList = ({ tabs, activeTabSlug }: CourseTabLinkListProps) => (
|
||||
<>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<CourseTabLink
|
||||
key={slug}
|
||||
url={url}
|
||||
slug={slug}
|
||||
title={title}
|
||||
activeTabSlug={activeTabSlug}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -1,16 +1,28 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { CourseTabLinksSlot } from '../plugin-slots/CourseTabLinksSlot';
|
||||
import { CoursewareSearch, CoursewareSearchToggle } from '../course-home/courseware-search';
|
||||
import { useCoursewareSearchState } from '../course-home/courseware-search/hooks';
|
||||
|
||||
import Tabs from '../generic/tabs/Tabs';
|
||||
import messages from './messages';
|
||||
|
||||
interface CourseTabsNavigationProps {
|
||||
activeTabSlug?: string;
|
||||
className?: string | null;
|
||||
tabs: Array<{
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const CourseTabsNavigation = ({
|
||||
activeTabSlug, className, tabs,
|
||||
}) => {
|
||||
activeTabSlug = undefined,
|
||||
className = null,
|
||||
tabs,
|
||||
}:CourseTabsNavigationProps) => {
|
||||
const intl = useIntl();
|
||||
const { show } = useCoursewareSearchState();
|
||||
|
||||
@@ -23,15 +35,7 @@ const CourseTabsNavigation = ({
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages.courseMaterial)}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
<CourseTabLinksSlot tabs={tabs} activeTabSlug={activeTabSlug} />
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="search-toggle">
|
||||
@@ -44,19 +48,4 @@ const CourseTabsNavigation = ({
|
||||
);
|
||||
};
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
className: null,
|
||||
};
|
||||
|
||||
export default CourseTabsNavigation;
|
||||
@@ -8,7 +8,7 @@ import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { AlertList } from '@src/generic/user-messages';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import Chat from './chat/Chat';
|
||||
import { LearnerToolsSlot } from '../../plugin-slots/LearnerToolsSlot';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
||||
import { NotificationsDiscussionsSidebarTriggerSlot } from '../../plugin-slots/NotificationsDiscussionsSidebarTriggerSlot';
|
||||
@@ -59,7 +59,7 @@ const Course = ({
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
|
||||
const shouldDisplayLearnerTools = windowWidth >= breakpoints.medium.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -88,17 +88,13 @@ const Course = ({
|
||||
isStaff={isStaff}
|
||||
unitId={unitId}
|
||||
/>
|
||||
{shouldDisplayChat && (
|
||||
<>
|
||||
<Chat
|
||||
enabled={course.learningAssistantEnabled}
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={course.showCalculator || course.notes.enabled}
|
||||
unitId={unitId}
|
||||
/>
|
||||
</>
|
||||
{shouldDisplayLearnerTools && (
|
||||
<LearnerToolsSlot
|
||||
enrollmentMode={course.enrollmentMode}
|
||||
isStaff={isStaff}
|
||||
courseId={courseId}
|
||||
unitId={unitId}
|
||||
/>
|
||||
)}
|
||||
<div className="w-100 d-flex align-items-center">
|
||||
<CourseOutlineMobileSidebarTriggerSlot />
|
||||
|
||||
@@ -13,17 +13,25 @@ import Course from './Course';
|
||||
import setupDiscussionSidebar from './test-utils';
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
|
||||
...jest.requireActual('@edx/frontend-lib-special-exams/dist/data/thunks.js'),
|
||||
checkExamEntry: () => jest.fn(),
|
||||
}));
|
||||
const mockChatTestId = 'fake-chat';
|
||||
jest.mock('@edx/frontend-lib-special-exams', () => {
|
||||
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
|
||||
return {
|
||||
...actual,
|
||||
__esModule: true,
|
||||
// Mock the default export (SequenceExamWrapper) to just render children
|
||||
// eslint-disable-next-line react/prop-types
|
||||
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
|
||||
};
|
||||
});
|
||||
const mockLearnerToolsTestId = 'fake-learner-tools';
|
||||
jest.mock(
|
||||
'./chat/Chat',
|
||||
// eslint-disable-next-line react/prop-types
|
||||
() => function ({ courseId }) {
|
||||
return <div className="fake-chat" data-testid={mockChatTestId}>Chat contents {courseId} </div>;
|
||||
},
|
||||
'../../plugin-slots/LearnerToolsSlot',
|
||||
() => ({
|
||||
// eslint-disable-next-line react/prop-types
|
||||
LearnerToolsSlot({ courseId }) {
|
||||
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const recordFirstSectionCelebration = jest.fn();
|
||||
@@ -183,23 +191,22 @@ describe('Course', () => {
|
||||
const { rerender } = render(<Course {...testData} />, { store: testStore });
|
||||
loadUnit();
|
||||
|
||||
waitFor(() => {
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.findByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
const sidebar = await screen.findByTestId('sidebar-DISCUSSIONS');
|
||||
expect(sidebar).toBeInTheDocument();
|
||||
expect(sidebar).not.toHaveClass('d-none');
|
||||
|
||||
rerender(null);
|
||||
});
|
||||
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
waitFor(() => {
|
||||
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
|
||||
const notificationTray = await screen.findByRole('region', { name: /notification tray/i });
|
||||
expect(notificationTray).toBeInTheDocument();
|
||||
expect(notificationTray).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('doesn\'t renders course breadcrumbs by default', async () => {
|
||||
@@ -360,28 +367,27 @@ describe('Course', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('displays chat when screen is wide enough (browser)', async () => {
|
||||
it('displays learner tools when screen is wide enough (browser)', async () => {
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
learning_assistant_enabled: true,
|
||||
enrollment: { mode: 'verified' },
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
const { courseware } = testStore.getState();
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
...mockData,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
waitFor(() => expect(chat).toBeInTheDocument());
|
||||
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||
await waitFor(() => expect(learnerTools).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not display chat when screen is too narrow (mobile)', async () => {
|
||||
it('does not display learner tools when screen is too narrow (mobile)', async () => {
|
||||
global.innerWidth = breakpoints.extraSmall.minWidth;
|
||||
const courseMetadata = Factory.build('courseMetadata', {
|
||||
learning_assistant_enabled: true,
|
||||
enrollment: { mode: 'verified' },
|
||||
});
|
||||
const testStore = await initializeTestStore({ courseMetadata }, false);
|
||||
@@ -393,7 +399,7 @@ describe('Course', () => {
|
||||
sequenceId,
|
||||
};
|
||||
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
const chat = screen.queryByTestId(mockChatTestId);
|
||||
await expect(chat).not.toBeInTheDocument();
|
||||
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
|
||||
await expect(learnerTools).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Xpert } from '@edx/frontend-lib-learning-assistant';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { ALLOW_UPSELL_MODES, VERIFIED_MODES } from '@src/constants';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
|
||||
const Chat = ({
|
||||
enabled,
|
||||
enrollmentMode,
|
||||
isStaff,
|
||||
courseId,
|
||||
contentToolsEnabled,
|
||||
unitId,
|
||||
}) => {
|
||||
const {
|
||||
activeAttempt, exam,
|
||||
} = useSelector(state => state.specialExams);
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
|
||||
// If is disabled or taking an exam, we don't show the chat.
|
||||
if (!enabled || activeAttempt?.attempt_id || exam?.id) { return null; }
|
||||
|
||||
// If is not staff and doesn't have an enrollment, we don't show the chat.
|
||||
if (!isStaff && !enrollmentMode) { return null; }
|
||||
|
||||
const verifiedMode = VERIFIED_MODES.includes(enrollmentMode); // Enrollment verified
|
||||
const auditMode = (
|
||||
!isStaff
|
||||
&& !verifiedMode
|
||||
&& ALLOW_UPSELL_MODES.includes(enrollmentMode) // Can upgrade course
|
||||
&& getConfig().ENABLE_XPERT_AUDIT
|
||||
);
|
||||
// If user has no access, we don't show the chat.
|
||||
if (!isStaff && !(verifiedMode || auditMode)) { return null; }
|
||||
|
||||
// Date validation
|
||||
const {
|
||||
accessExpiration,
|
||||
start,
|
||||
end,
|
||||
} = course;
|
||||
|
||||
const utcDate = (new Date()).toISOString();
|
||||
const expiration = accessExpiration?.expirationDate || utcDate;
|
||||
const validDate = (
|
||||
(start ? start <= utcDate : true)
|
||||
&& (end ? end >= utcDate : true)
|
||||
&& (auditMode ? expiration >= utcDate : true)
|
||||
);
|
||||
// If date is invalid, we don't show the chat.
|
||||
if (!validDate) { return null; }
|
||||
|
||||
// Use a portal to ensure that component overlay does not compete with learning MFE styles.
|
||||
return createPortal(
|
||||
<Xpert
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={contentToolsEnabled}
|
||||
unitId={unitId}
|
||||
isUpgradeEligible={auditMode}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
Chat.propTypes = {
|
||||
isStaff: PropTypes.bool.isRequired,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
enrollmentMode: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
contentToolsEnabled: PropTypes.bool.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
Chat.defaultProps = {
|
||||
enrollmentMode: null,
|
||||
};
|
||||
|
||||
export default Chat;
|
||||
@@ -1,286 +0,0 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import {
|
||||
initializeMockApp,
|
||||
initializeTestStore,
|
||||
render,
|
||||
screen,
|
||||
} from '../../../setupTest';
|
||||
|
||||
import Chat from './Chat';
|
||||
|
||||
// We do a partial mock to avoid mocking out other exported values (e.g. the reducer).
|
||||
// We mock out the Xpert component, because the Xpert component has its own rules for whether it renders
|
||||
// or not, and this includes the results of API calls it makes. We don't want to test those rules here, just
|
||||
// whether the Xpert is rendered by the Chat component in certain conditions. Instead of actually rendering
|
||||
// Xpert, we render and assert on a mocked component.
|
||||
const mockXpertTestId = 'xpert';
|
||||
|
||||
jest.mock('@edx/frontend-lib-learning-assistant', () => {
|
||||
const originalModule = jest.requireActual('@edx/frontend-lib-learning-assistant');
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
Xpert: () => (<div data-testid={mockXpertTestId}>mocked Xpert</div>),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
getConfig: jest.fn().mockReturnValue({ ENABLE_XPERT_AUDIT: false }),
|
||||
}));
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
const courseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
let testCases = [];
|
||||
let enabledTestCases = [];
|
||||
let disabledTestCases = [];
|
||||
const enabledModes = [
|
||||
'professional', 'verified', 'no-id-professional', 'credit', 'masters', 'executive-education',
|
||||
'paid-executive-education', 'paid-bootcamp',
|
||||
];
|
||||
const disabledModes = [null, undefined, 'xyz', 'audit', 'honor', 'unpaid-executive-education', 'unpaid-bootcamp'];
|
||||
|
||||
describe('Chat', () => {
|
||||
let store;
|
||||
|
||||
beforeAll(async () => {
|
||||
store = await initializeTestStore({
|
||||
specialExams: {
|
||||
activeAttempt: {
|
||||
attempt_id: null,
|
||||
},
|
||||
exam: {
|
||||
id: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Generate test cases.
|
||||
enabledTestCases = enabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: true }));
|
||||
disabledTestCases = disabledModes.map((mode) => ({ enrollmentMode: mode, isVisible: false }));
|
||||
testCases = enabledTestCases.concat(disabledTestCases);
|
||||
|
||||
testCases.forEach(test => {
|
||||
it(
|
||||
`visibility determined by ${test.enrollmentMode} enrollment mode when enabled and not isStaff`,
|
||||
async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode={test.enrollmentMode}
|
||||
isStaff={false}
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
if (test.isVisible) {
|
||||
expect(chat).toBeInTheDocument();
|
||||
} else {
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Generate test cases.
|
||||
testCases = enabledModes.concat(disabledModes).map((mode) => ({ enrollmentMode: mode, isVisible: true }));
|
||||
testCases.forEach(test => {
|
||||
it('visibility determined by isStaff when enabled and any enrollment mode', async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode={test.enrollmentMode}
|
||||
isStaff
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
if (test.isVisible) {
|
||||
expect(chat).toBeInTheDocument();
|
||||
} else {
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Generate the map function used for generating test cases by currying the map function.
|
||||
// In this test suite, visibility depends on whether the enrollment mode is a valid or invalid
|
||||
// enrollment mode for enabling the Chat when the user is not a staff member and the Chat is enabled. Instead of
|
||||
// defining two separate map functions that differ in only one case, curry the function.
|
||||
const generateMapFunction = (areEnabledModes) => (
|
||||
(mode) => (
|
||||
[
|
||||
{
|
||||
enrollmentMode: mode, isStaff: true, enabled: true, isVisible: true,
|
||||
},
|
||||
{
|
||||
enrollmentMode: mode, isStaff: true, enabled: false, isVisible: false,
|
||||
},
|
||||
{
|
||||
enrollmentMode: mode, isStaff: false, enabled: true, isVisible: areEnabledModes,
|
||||
},
|
||||
{
|
||||
enrollmentMode: mode, isStaff: false, enabled: false, isVisible: false,
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
// Generate test cases.
|
||||
enabledTestCases = enabledModes.map(generateMapFunction(true));
|
||||
disabledTestCases = disabledModes.map(generateMapFunction(false));
|
||||
testCases = enabledTestCases.concat(disabledTestCases);
|
||||
testCases = testCases.flat();
|
||||
testCases.forEach(test => {
|
||||
it(
|
||||
`visibility determined by ${test.enabled} enabled when ${test.isStaff} isStaff
|
||||
and ${test.enrollmentMode} enrollment mode`,
|
||||
async () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode={test.enrollmentMode}
|
||||
isStaff={test.isStaff}
|
||||
enabled={test.enabled}
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
if (test.isVisible) {
|
||||
expect(chat).toBeInTheDocument();
|
||||
} else {
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('if course end date has passed, component should not be visible', async () => {
|
||||
store = await initializeTestStore({
|
||||
specialExams: {
|
||||
activeAttempt: {
|
||||
attempt_id: 1,
|
||||
},
|
||||
},
|
||||
courseMetadata: Factory.build('courseMetadata', {
|
||||
start: '2014-02-03T05:00:00Z',
|
||||
end: '2014-02-05T05:00:00Z',
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="verified"
|
||||
isStaff
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('if learner has active exam attempt, component should not be visible', async () => {
|
||||
store = await initializeTestStore({
|
||||
specialExams: {
|
||||
activeAttempt: {
|
||||
attempt_id: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="verified"
|
||||
isStaff
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays component for audit learner if explicitly enabled', async () => {
|
||||
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
|
||||
|
||||
store = await initializeTestStore({
|
||||
courseMetadata: Factory.build('courseMetadata', {
|
||||
access_expiration: { expiration_date: '' },
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="audit"
|
||||
isStaff={false}
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display component for audit learner if access deadline has passed', async () => {
|
||||
getConfig.mockImplementation(() => ({ ENABLE_XPERT_AUDIT: true }));
|
||||
|
||||
store = await initializeTestStore({
|
||||
courseMetadata: Factory.build('courseMetadata', {
|
||||
access_expiration: { expiration_date: '2014-02-03T05:00:00Z' },
|
||||
}),
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<Chat
|
||||
enrollmentMode="audit"
|
||||
isStaff={false}
|
||||
enabled
|
||||
courseId={courseId}
|
||||
contentToolsEnabled={false}
|
||||
/>
|
||||
</BrowserRouter>,
|
||||
{ store },
|
||||
);
|
||||
|
||||
const chat = screen.queryByTestId(mockXpertTestId);
|
||||
expect(chat).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Chat';
|
||||
@@ -26,8 +26,8 @@ const SidebarProvider: React.FC<Props> = ({
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const windowWidth = useWindowSize().width ?? window.innerWidth;
|
||||
const shouldDisplayFullScreen = windowWidth < breakpoints.large.minWidth;
|
||||
const shouldDisplaySidebarOpen = windowWidth > breakpoints.medium.minWidth;
|
||||
const shouldDisplayFullScreen = windowWidth < breakpoints.large.minWidth!;
|
||||
const shouldDisplaySidebarOpen = windowWidth > breakpoints.medium.minWidth!;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
|
||||
const sidebarKey = `sidebar.${courseId}`;
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('NotificationsWidget', () => {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
global.innerWidth = breakpoints.large.minWidth;
|
||||
global.innerWidth = breakpoints.large.minWidth!;
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { ModalDialog } from '@openedx/paragon';
|
||||
import { ContentIFrameLoaderSlot } from '../../../../plugin-slots/ContentIFrameLoaderSlot';
|
||||
import { ContentIFrameErrorSlot } from '../../../../plugin-slots/ContentIFrameErrorSlot';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
|
||||
@@ -66,7 +66,11 @@ const ContentIFrame = ({
|
||||
return (
|
||||
<>
|
||||
{(shouldShowContent && !hasLoaded) && (
|
||||
showError ? <ErrorPage /> : <ContentIFrameLoaderSlot courseId={courseId} loadingMessage={loadingMessage} />
|
||||
showError ? (
|
||||
<ContentIFrameErrorSlot courseId={courseId} />
|
||||
) : (
|
||||
<ContentIFrameLoaderSlot courseId={courseId} loadingMessage={loadingMessage} />
|
||||
)
|
||||
)}
|
||||
{shouldShowContent && (
|
||||
<div className="unit-iframe-wrapper">
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import * as hooks from './hooks';
|
||||
import ContentIFrame, { IFRAME_FEATURE_POLICY } from './ContentIFrame';
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const IntlWrapper = ({ children }) => (
|
||||
<IntlProvider locale="en">{children}</IntlProvider>
|
||||
);
|
||||
|
||||
jest.mock('@edx/frontend-platform/react', () => ({ ErrorPage: () => <div>ErrorPage</div> }));
|
||||
|
||||
jest.mock('@src/generic/PageLoading', () => jest.fn(() => <div>PageLoading</div>));
|
||||
@@ -59,7 +65,7 @@ describe('ContentIFrame Component', () => {
|
||||
});
|
||||
describe('behavior', () => {
|
||||
beforeEach(() => {
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
});
|
||||
it('initializes iframe behavior hook', () => {
|
||||
expect(hooks.useIFrameBehavior).toHaveBeenCalledWith({
|
||||
@@ -78,12 +84,12 @@ describe('ContentIFrame Component', () => {
|
||||
describe('if not hasLoaded', () => {
|
||||
it('displays errorPage if showError', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, showError: true });
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
const errorPage = screen.getByText('ErrorPage');
|
||||
expect(errorPage).toBeInTheDocument();
|
||||
});
|
||||
it('displays PageLoading component if not showError', () => {
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
const pageLoading = screen.getByText('PageLoading');
|
||||
expect(pageLoading).toBeInTheDocument();
|
||||
});
|
||||
@@ -91,7 +97,7 @@ describe('ContentIFrame Component', () => {
|
||||
describe('hasLoaded', () => {
|
||||
it('does not display PageLoading or ErrorPage', () => {
|
||||
hooks.useIFrameBehavior.mockReturnValueOnce({ ...iframeBehavior, hasLoaded: true });
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
const pageLoading = screen.queryByText('PageLoading');
|
||||
expect(pageLoading).toBeNull();
|
||||
const errorPage = screen.queryByText('ErrorPage');
|
||||
@@ -99,7 +105,7 @@ describe('ContentIFrame Component', () => {
|
||||
});
|
||||
});
|
||||
it('display iframe with props from hooks', () => {
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
const iframe = screen.getByTitle(props.title);
|
||||
expect(iframe).toBeInTheDocument();
|
||||
expect(iframe).toHaveAttribute('id', props.elementId);
|
||||
@@ -112,14 +118,14 @@ describe('ContentIFrame Component', () => {
|
||||
});
|
||||
describe('if not shouldShowContent', () => {
|
||||
it('does not show PageLoading, ErrorPage, or unit-iframe-wrapper', () => {
|
||||
render(<ContentIFrame {...{ ...props, shouldShowContent: false }} />);
|
||||
render(<ContentIFrame {...{ ...props, shouldShowContent: false }} />, { wrapper: IntlWrapper });
|
||||
expect(screen.queryByText('PageLoading')).toBeNull();
|
||||
expect(screen.queryByText('ErrorPage')).toBeNull();
|
||||
expect(screen.queryByTitle(props.title)).toBeNull();
|
||||
});
|
||||
});
|
||||
it('does not display modal if modalOptions returns isOpen: false', () => {
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
const modal = screen.queryByRole('dialog');
|
||||
expect(modal).toBeNull();
|
||||
});
|
||||
@@ -138,7 +144,7 @@ describe('ContentIFrame Component', () => {
|
||||
...modalIFrameData,
|
||||
modalOptions: { ...modalOptions.withBody, isFullscreen: true },
|
||||
});
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
});
|
||||
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
|
||||
const dialog = screen.getByRole('dialog');
|
||||
@@ -155,7 +161,7 @@ describe('ContentIFrame Component', () => {
|
||||
...modalIFrameData,
|
||||
modalOptions: { ...modalOptions.withUrl, isFullscreen: true },
|
||||
});
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
});
|
||||
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
|
||||
const iframe = screen.getByTitle(modalOptions.withUrl.title);
|
||||
@@ -169,7 +175,7 @@ describe('ContentIFrame Component', () => {
|
||||
describe('body modal', () => {
|
||||
beforeEach(() => {
|
||||
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withBody });
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
});
|
||||
it('displays Modal with div wrapping provided body content if modal.body is provided', () => {
|
||||
const dialog = screen.getByRole('dialog');
|
||||
@@ -182,7 +188,7 @@ describe('ContentIFrame Component', () => {
|
||||
describe('url modal', () => {
|
||||
beforeEach(() => {
|
||||
hooks.useModalIFrameData.mockReturnValueOnce({ ...modalIFrameData, modalOptions: modalOptions.withUrl });
|
||||
render(<ContentIFrame {...props} />);
|
||||
render(<ContentIFrame {...props} />, { wrapper: IntlWrapper });
|
||||
});
|
||||
it('displays Modal with iframe to provided url if modal.body is not provided', () => {
|
||||
const iframe = screen.getByTitle(modalOptions.withUrl.title);
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('useIFrameBehavior hook', () => {
|
||||
});
|
||||
});
|
||||
describe('visibility tracking', () => {
|
||||
it('sets up visibility tracking after iframe has loaded', () => {
|
||||
it('sets up visibility tracking after iframe loads', () => {
|
||||
mockState({ ...defaultStateVals, hasLoaded: true });
|
||||
|
||||
renderHook(() => useIFrameBehavior(props));
|
||||
@@ -288,15 +288,9 @@ describe('useIFrameBehavior hook', () => {
|
||||
expect(global.window.addEventListener).toHaveBeenCalledTimes(2);
|
||||
expect(global.window.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(global.window.addEventListener).toHaveBeenCalledWith('resize', expect.any(Function));
|
||||
// Initial visibility update.
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'unit.visibilityStatus',
|
||||
data: {
|
||||
topPosition: 100,
|
||||
viewportHeight: 800,
|
||||
},
|
||||
},
|
||||
// Initial visibility update is handled by the `handleIFrameLoad` method.
|
||||
expect(postMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'unit.visibilityStatus' }),
|
||||
config.LMS_BASE_URL,
|
||||
);
|
||||
});
|
||||
@@ -362,6 +356,20 @@ describe('useIFrameBehavior hook', () => {
|
||||
window.onmessage(event);
|
||||
expect(dispatch).toHaveBeenCalledWith(processEvent(event.data, fetchCourse));
|
||||
});
|
||||
it('updates initial iframe visibility on load', () => {
|
||||
const { result } = renderHook(() => useIFrameBehavior(props));
|
||||
result.current.handleIFrameLoad();
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'unit.visibilityStatus',
|
||||
data: {
|
||||
topPosition: 100,
|
||||
viewportHeight: 800,
|
||||
},
|
||||
},
|
||||
config.LMS_BASE_URL,
|
||||
);
|
||||
});
|
||||
});
|
||||
it('forwards handleIframeLoad, showError, and hasLoaded from state fields', () => {
|
||||
mockState(stateVals);
|
||||
|
||||
@@ -102,6 +102,23 @@ const useIFrameBehavior = ({
|
||||
useEventListener('message', receiveMessage);
|
||||
|
||||
// Send visibility status to the iframe. It's used to mark XBlocks as viewed.
|
||||
const updateIframeVisibility = () => {
|
||||
const iframeElement = document.getElementById(elementId) as HTMLIFrameElement | null;
|
||||
const rect = iframeElement?.getBoundingClientRect();
|
||||
const visibleInfo = {
|
||||
type: 'unit.visibilityStatus',
|
||||
data: {
|
||||
topPosition: rect?.top,
|
||||
viewportHeight: window.innerHeight,
|
||||
},
|
||||
};
|
||||
iframeElement?.contentWindow?.postMessage(
|
||||
visibleInfo,
|
||||
`${getConfig().LMS_BASE_URL}`,
|
||||
);
|
||||
};
|
||||
|
||||
// Set up visibility tracking event listeners.
|
||||
React.useEffect(() => {
|
||||
if (!hasLoaded) {
|
||||
return undefined;
|
||||
@@ -112,27 +129,9 @@ const useIFrameBehavior = ({
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateIframeVisibility = () => {
|
||||
const rect = iframeElement.getBoundingClientRect();
|
||||
const visibleInfo = {
|
||||
type: 'unit.visibilityStatus',
|
||||
data: {
|
||||
topPosition: rect.top,
|
||||
viewportHeight: window.innerHeight,
|
||||
},
|
||||
};
|
||||
iframeElement?.contentWindow?.postMessage(
|
||||
visibleInfo,
|
||||
`${getConfig().LMS_BASE_URL}`,
|
||||
);
|
||||
};
|
||||
|
||||
// Throttle the update function to prevent it from sending too many messages to the iframe.
|
||||
const throttledUpdateVisibility = throttle(updateIframeVisibility, 100);
|
||||
|
||||
// Update the visibility of the iframe in case the element is already visible.
|
||||
updateIframeVisibility();
|
||||
|
||||
// Add event listeners to update the visibility of the iframe when the window is scrolled or resized.
|
||||
window.addEventListener('scroll', throttledUpdateVisibility);
|
||||
window.addEventListener('resize', throttledUpdateVisibility);
|
||||
@@ -167,6 +166,9 @@ const useIFrameBehavior = ({
|
||||
dispatch(processEvent(e.data, fetchCourse));
|
||||
}
|
||||
};
|
||||
|
||||
// Update the visibility of the iframe in case the element is already visible.
|
||||
updateIframeVisibility();
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
39
src/plugin-slots/ContentIFrameErrorSlot/README.md
Normal file
39
src/plugin-slots/ContentIFrameErrorSlot/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Content iFrame Error Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.learning.content_iframe_error.v1`
|
||||
|
||||
### Parameters: `courseId`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify the content iframe error page.
|
||||
|
||||
## Example
|
||||
|
||||
The following `env.config.jsx` will replace the error page with emojis.
|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
'org.openedx.frontend.learning.content_iframe_error.v1': {
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_error_page',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: ({courseId}) => (
|
||||
<h1>🚨🤖💥</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
17
src/plugin-slots/ContentIFrameErrorSlot/index.tsx
Normal file
17
src/plugin-slots/ContentIFrameErrorSlot/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { ErrorPage } from '@edx/frontend-platform/react';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
}
|
||||
|
||||
export const ContentIFrameErrorSlot : React.FC<Props> = ({ courseId }: Props) => (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.learning.content_iframe_error.v1"
|
||||
pluginProps={{ courseId }}
|
||||
>
|
||||
<ErrorPage />
|
||||
</PluginSlot>
|
||||
);
|
||||
51
src/plugin-slots/CourseTabLinksSlot/README.md
Normal file
51
src/plugin-slots/CourseTabLinksSlot/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Course Tab Links Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.learning.course_tab_links.v1`
|
||||
|
||||
### Props:
|
||||
* `activeTabSlug`: The slug of the currently active tab.
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the course tabs.
|
||||
|
||||
## Example
|
||||
|
||||
### Added link to Course Tabs
|
||||

|
||||
|
||||
The following `env.config.jsx` will add a new course tab call "Custom Tab".
|
||||
|
||||
```js
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import { CourseTabLink } from '@src/course-tabs/CourseTabLink';
|
||||
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
"org.openedx.frontend.learning.course_tab_links.v1": {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_tab',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: ({ activeTabSlug })=> (
|
||||
<CourseTabLink
|
||||
url="/some/path"
|
||||
slug="custom-link"
|
||||
title="Custom Tab"
|
||||
activeTabSlug={activeTabSlug}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
BIN
src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png
Normal file
BIN
src/plugin-slots/CourseTabLinksSlot/course-tabs-custom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
21
src/plugin-slots/CourseTabLinksSlot/index.tsx
Normal file
21
src/plugin-slots/CourseTabLinksSlot/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { CourseTabLinksList } from '@src/course-tabs/CourseTabLinksList';
|
||||
import React from 'react';
|
||||
|
||||
type CourseTabList = Array<{
|
||||
title: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export const CourseTabLinksSlot = ({ tabs, activeTabSlug }: {
|
||||
tabs: CourseTabList,
|
||||
activeTabSlug?: string
|
||||
}) => (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.learning.course_tab_links.v1"
|
||||
pluginProps={{ activeTabSlug }}
|
||||
>
|
||||
<CourseTabLinksList tabs={tabs} activeTabSlug={activeTabSlug} />
|
||||
</PluginSlot>
|
||||
);
|
||||
28
src/plugin-slots/LearnerToolsSlot/README.md
Normal file
28
src/plugin-slots/LearnerToolsSlot/README.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Learner Tools Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.learning.learner_tools.v1`
|
||||
|
||||
### Slot ID Aliases
|
||||
* `learner_tools_slot`
|
||||
|
||||
### Description
|
||||
This plugin slot provides a location for learner-facing tools and features to be displayed during course content navigation. The slot is rendered via a React portal to `document.body` to ensure proper positioning and stacking context.
|
||||
|
||||
### Props:
|
||||
* `courseId` - The unique identifier for the current course
|
||||
* `unitId` - The unique identifier for the current unit/vertical being viewed
|
||||
* `userId` - The authenticated user's ID (automatically retrieved from auth context)
|
||||
* `isStaff` - Boolean indicating whether the user has staff/instructor privileges
|
||||
* `enrollmentMode` - The user's enrollment mode (e.g., 'audit', 'verified', 'honor', etc.)
|
||||
|
||||
### Usage
|
||||
Plugins registered to this slot can use the provided context to:
|
||||
- Display course-specific tools based on courseId and unitId
|
||||
- Show different features based on user's enrollment mode
|
||||
- Provide staff-only functionality when isStaff is true
|
||||
- Query additional data from Redux store or backend APIs as needed
|
||||
|
||||
### Notes
|
||||
- Returns `null` if user is not authenticated
|
||||
- Plugins should manage their own feature flag checks and requirements
|
||||
- The slot uses a portal to render to `document.body` for flexible positioning
|
||||
47
src/plugin-slots/LearnerToolsSlot/index.jsx
Normal file
47
src/plugin-slots/LearnerToolsSlot/index.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
export const LearnerToolsSlot = ({
|
||||
enrollmentMode = null,
|
||||
isStaff,
|
||||
courseId,
|
||||
unitId,
|
||||
}) => {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
|
||||
// Return null if user is not authenticated to avoid destructuring errors
|
||||
if (!authenticatedUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { userId } = authenticatedUser;
|
||||
|
||||
// Provide minimal, generic context - no feature-specific flags
|
||||
const pluginContext = {
|
||||
courseId,
|
||||
unitId,
|
||||
userId,
|
||||
isStaff,
|
||||
enrollmentMode,
|
||||
};
|
||||
|
||||
// Use generic plugin slot ID (location-based, not feature-specific)
|
||||
// Plugins will query their own requirements from Redux/config
|
||||
return createPortal(
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.learning.learner_tools.v1"
|
||||
idAliases={['learner_tools_slot']}
|
||||
pluginProps={pluginContext}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
LearnerToolsSlot.propTypes = {
|
||||
isStaff: PropTypes.bool.isRequired,
|
||||
enrollmentMode: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
109
src/plugin-slots/LearnerToolsSlot/index.test.jsx
Normal file
109
src/plugin-slots/LearnerToolsSlot/index.test.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
PluginSlot: jest.fn(() => <div data-testid="plugin-slot">Plugin Slot</div>),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('LearnerToolsSlot', () => {
|
||||
let auth;
|
||||
let PluginSlot;
|
||||
let LearnerToolsSlot;
|
||||
|
||||
const defaultProps = {
|
||||
courseId: 'course-v1:edX+DemoX+Demo_Course',
|
||||
unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@unit1',
|
||||
isStaff: false,
|
||||
enrollmentMode: 'verified',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
// Mock document.body for createPortal
|
||||
document.body.innerHTML = '<div id="root"></div>';
|
||||
|
||||
auth = await import('@edx/frontend-platform/auth');
|
||||
({ PluginSlot } = await import('@openedx/frontend-plugin-framework'));
|
||||
({ LearnerToolsSlot } = await import('./index'));
|
||||
});
|
||||
|
||||
it('renders PluginSlot with correct props when user is authenticated', () => {
|
||||
const mockUser = { userId: 123, username: 'testuser' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
render(<LearnerToolsSlot {...defaultProps} />);
|
||||
|
||||
expect(PluginSlot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'org.openedx.frontend.learning.learner_tools.v1',
|
||||
idAliases: ['learner_tools_slot'],
|
||||
pluginProps: {
|
||||
courseId: defaultProps.courseId,
|
||||
unitId: defaultProps.unitId,
|
||||
userId: mockUser.userId,
|
||||
isStaff: defaultProps.isStaff,
|
||||
enrollmentMode: defaultProps.enrollmentMode,
|
||||
},
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when user is not authenticated', () => {
|
||||
auth.getAuthenticatedUser.mockReturnValue(null);
|
||||
|
||||
const { container } = render(<LearnerToolsSlot {...defaultProps} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(PluginSlot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses default null for enrollmentMode when not provided', () => {
|
||||
const mockUser = { userId: 456, username: 'testuser2' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
const { enrollmentMode, ...propsWithoutEnrollmentMode } = defaultProps;
|
||||
|
||||
render(<LearnerToolsSlot {...propsWithoutEnrollmentMode} />);
|
||||
|
||||
expect(PluginSlot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginProps: expect.objectContaining({
|
||||
enrollmentMode: null,
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('passes isStaff=true correctly', () => {
|
||||
const mockUser = { userId: 789, username: 'staffuser' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
render(<LearnerToolsSlot {...defaultProps} isStaff />);
|
||||
|
||||
expect(PluginSlot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginProps: expect.objectContaining({
|
||||
isStaff: true,
|
||||
}),
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('renders to document.body via portal', () => {
|
||||
const mockUser = { userId: 999, username: 'portaluser' };
|
||||
auth.getAuthenticatedUser.mockReturnValue(mockUser);
|
||||
|
||||
render(<LearnerToolsSlot {...defaultProps} />);
|
||||
|
||||
// The portal should render to document.body
|
||||
expect(document.body.querySelector('[data-testid="plugin-slot"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
* [`org.openedx.frontend.learning.course_outline_tab_notifications.v1`](./CourseOutlineTabNotificationsSlot/)
|
||||
* [`org.openedx.frontend.learning.course_recommendations.v1`](./CourseRecommendationsSlot/)
|
||||
* [`org.openedx.frontend.learning.gated_unit_content_message.v1`](./GatedUnitContentMessageSlot/)
|
||||
* [`org.openedx.frontend.learning.learner_tools.v1`](./LearnerToolsSlot/)
|
||||
* [`org.openedx.frontend.learning.next_unit_top_nav_trigger.v1`](./NextUnitTopNavTriggerSlot/)
|
||||
* [`org.openedx.frontend.learning.notification_tray.v1`](./NotificationTraySlot/)
|
||||
* [`org.openedx.frontend.learning.notification_widget.v1`](./NotificationWidgetSlot/)
|
||||
|
||||
@@ -26,13 +26,17 @@ import { getCourseOutlineStructure } from './courseware/data/thunks';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from './utils';
|
||||
import buildSimpleCourseAndSequenceMetadata from './courseware/data/__factories__/sequenceMetadata.factory';
|
||||
import { buildOutlineFromBlocks } from './courseware/data/__factories__/learningSequencesOutline.factory';
|
||||
import MockedPluginSlot from './tests/MockedPluginSlot';
|
||||
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
...jest.requireActual('@openedx/frontend-plugin-framework'),
|
||||
Plugin: () => 'Plugin',
|
||||
PluginSlot: MockedPluginSlot,
|
||||
}));
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => {
|
||||
// eslint-disable-next-line global-require
|
||||
const MockedPluginSlot = require('./tests/MockedPluginSlot').default;
|
||||
|
||||
return {
|
||||
...jest.requireActual('@openedx/frontend-plugin-framework'),
|
||||
Plugin: () => 'Plugin',
|
||||
PluginSlot: MockedPluginSlot,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@src/generic/plugin-store', () => ({
|
||||
...jest.requireActual('@src/generic/plugin-store'),
|
||||
|
||||
Reference in New Issue
Block a user