Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display allocated runs on the course about page #1142

Merged
merged 11 commits into from
Aug 13, 2024
30 changes: 27 additions & 3 deletions src/components/app/data/hooks/useCourseMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { useQuery } from '@tanstack/react-query';
import { useParams, useSearchParams } from 'react-router-dom';

import { queryCourseMetadata } from '../queries';
import { getAvailableCourseRuns } from '../utils';
import {
determineAllocatedCourseRunAssignmentsForCourse,
getAvailableCourseRuns,
transformCourseMetadataByAllocatedCourseRunAssignments,
} from '../utils';
import useLateEnrollmentBufferDays from './useLateEnrollmentBufferDays';
import useRedeemablePolicies from './useRedeemablePolicies';

/**
* Retrieves the course metadata for the given enterprise customer and course key.
Expand All @@ -13,9 +18,22 @@ export default function useCourseMetadata(queryOptions = {}) {
const { select, ...queryOptionsRest } = queryOptions;
const { courseKey } = useParams();
const [searchParams] = useSearchParams();
const { data: redeemableLearnerCreditPolicies } = useRedeemablePolicies();
const {
allocatedCourseRunAssignmentKeys,
hasAssignedCourseRuns,
hasMultipleAssignedCourseRuns,
} = determineAllocatedCourseRunAssignmentsForCourse({
courseKey,
redeemableLearnerCreditPolicies,
});
// `requestUrl.searchParams` uses `URLSearchParams`, which decodes `+` as a space, so we
// need to replace it with `+` again to be a valid course run key.
const courseRunKey = searchParams.get('course_run_key')?.replaceAll(' ', '+');
let courseRunKey = searchParams.get('course_run_key')?.replaceAll(' ', '+');
// only override `courseRunKey` when learner has a single allocated assignment
if (!courseRunKey && hasAssignedCourseRuns) {
courseRunKey = hasMultipleAssignedCourseRuns ? null : allocatedCourseRunAssignmentKeys[0];
}
const lateEnrollmentBufferDays = useLateEnrollmentBufferDays({
enabled: !!courseKey,
});
Expand All @@ -28,10 +46,16 @@ export default function useCourseMetadata(queryOptions = {}) {
return data;
}
const availableCourseRuns = getAvailableCourseRuns({ course: data, lateEnrollmentBufferDays });
const transformedData = {
let transformedData = {
...data,
availableCourseRuns,
};
// This logic should appropriately handle multiple course runs being assigned, and return the appropriate metadata
transformedData = transformCourseMetadataByAllocatedCourseRunAssignments({
hasMultipleAssignedCourseRuns,
courseMetadata: transformedData,
allocatedCourseRunAssignmentKeys,
});
if (select) {
return select({
original: data,
Expand Down
148 changes: 147 additions & 1 deletion src/components/app/data/hooks/useCourseMetadata.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { queryClient } from '../../../../utils/tests';
import { fetchCourseMetadata } from '../services';
import useLateEnrollmentBufferDays from './useLateEnrollmentBufferDays';
import useCourseMetadata from './useCourseMetadata';
import useRedeemablePolicies from './useRedeemablePolicies';

jest.mock('./useEnterpriseCustomer');
jest.mock('./useLateEnrollmentBufferDays');
jest.mock('./useRedeemablePolicies');

jest.mock('../services', () => ({
...jest.requireActual('../services'),
Expand All @@ -27,10 +29,33 @@ const mockCourseMetadata = {
availability: 'Current',
enrollmentStart: dayjs().add(10, 'day').toISOString(),
enrollmentEnd: dayjs().add(15, 'day').toISOString(),
key: 'course-v1:edX+DemoX+2T2020',
isEnrollable: true,
}],
};

const mockBaseRedeemablePolicies = {
redeemablePolicies: [],
expiredPolicies: [],
unexpiredPolicies: [],
learnerContentAssignments: {
assignments: [],
hasAssignments: false,
allocatedAssignments: [],
hasAllocatedAssignments: false,
acceptedAssignments: [],
hasAcceptedAssignments: false,
canceledAssignments: [],
hasCanceledAssignments: false,
expiredAssignments: [],
hasExpiredAssignments: false,
erroredAssignments: [],
hasErroredAssignments: false,
assignmentsForDisplay: [],
hasAssignmentsForDisplay: false,
},
};

describe('useCourseMetadata', () => {
const Wrapper = ({ children }) => (
<QueryClientProvider client={queryClient()}>
Expand All @@ -42,7 +67,8 @@ describe('useCourseMetadata', () => {
fetchCourseMetadata.mockResolvedValue(mockCourseMetadata);
useParams.mockReturnValue({ courseKey: 'edX+DemoX' });
useLateEnrollmentBufferDays.mockReturnValue(undefined);
useSearchParams.mockReturnValue([new URLSearchParams({ course_run_key: 'course-v1:edX+DemoX+T2024' })]);
useSearchParams.mockReturnValue([new URLSearchParams({ course_run_key: 'course-v1:edX+DemoX+2T2024' })]);
useRedeemablePolicies.mockReturnValue({ data: mockBaseRedeemablePolicies });
});
it('should handle resolved value correctly with no select function passed', async () => {
const { result, waitForNextUpdate } = renderHook(() => useCourseMetadata(), { wrapper: Wrapper });
Expand Down Expand Up @@ -107,4 +133,124 @@ describe('useCourseMetadata', () => {
}),
);
});
it('should return available course run corresponding to allocated course runs', async () => {
useParams.mockReturnValue({ courseKey: 'edX+DemoX' });
useLateEnrollmentBufferDays.mockReturnValue(undefined);
useSearchParams.mockReturnValue([new URLSearchParams({})]);

const mockCourseRuns = [
...mockCourseMetadata.courseRuns,
{
...mockCourseMetadata.courseRuns[0],
key: 'course-v1:edX+DemoX+2018',
},
];

const mockUnassignedCourseRun = {
...mockCourseMetadata.courseRuns[0],
key: 'course-v1:edX+DemoX+3T2024',
};

const mockAllocatedAssignments = [{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2T2020',
isAssignedCourseRun: true,
},
{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2018',
isAssignedCourseRun: true,
}, {
parentContentKey: null,
contentKey: 'edX+DemoX',
isAssignedCourseRun: false,
}];
const mockLearnerContentAssignments = {
allocatedAssignments: mockAllocatedAssignments,
hasAllocatedAssignments: mockAllocatedAssignments.length > 0,
};

fetchCourseMetadata.mockResolvedValue({
...mockCourseMetadata, courseRuns: [...mockCourseRuns, mockUnassignedCourseRun],
});
useRedeemablePolicies.mockReturnValue({
data: {
...mockBaseRedeemablePolicies,
learnerContentAssignments: {
...mockBaseRedeemablePolicies.learnerContentAssignments, ...mockLearnerContentAssignments,
},
},
});

const { result, waitForNextUpdate } = renderHook(() => useCourseMetadata(), { wrapper: Wrapper });
await waitForNextUpdate();

expect(result.current).toEqual(
expect.objectContaining({
data: {
...mockCourseMetadata,
courseRuns: mockCourseRuns,
availableCourseRuns: mockCourseRuns,
},
isLoading: false,
isFetching: false,
}),
);
});
it('should return available course run corresponding to course_run_key with allocated course runs', async () => {
useParams.mockReturnValue({ courseKey: 'edX+DemoX' });
useLateEnrollmentBufferDays.mockReturnValue(undefined);
useSearchParams.mockReturnValue([new URLSearchParams({ course_run_key: 'course-v1:edX+DemoX+2018' })]);

const mockCourseRun = [{
...mockCourseMetadata.courseRuns[0],
key: 'course-v1:edX+DemoX+2018',
}];

const mockAllocatedAssignments = [{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2T2020',
isAssignedCourseRun: true,
},
{
parentContentKey: 'edX+DemoX',
contentKey: 'course-v1:edX+DemoX+2018',
isAssignedCourseRun: true,
}, {
parentContentKey: null,
contentKey: 'edX+DemoX',
isAssignedCourseRun: false,
}];
const mockLearnerContentAssignments = {
allocatedAssignments: mockAllocatedAssignments,
hasAllocatedAssignments: mockAllocatedAssignments.length > 0,
};

fetchCourseMetadata.mockResolvedValue({
...mockCourseMetadata, courseRuns: mockCourseRun,
});
useRedeemablePolicies.mockReturnValue({
data: {
...mockBaseRedeemablePolicies,
learnerContentAssignments: {
...mockBaseRedeemablePolicies.learnerContentAssignments, ...mockLearnerContentAssignments,
},
},
});

const { result, waitForNextUpdate } = renderHook(() => useCourseMetadata(), { wrapper: Wrapper });
await waitForNextUpdate();

expect(result.current).toEqual(
expect.objectContaining({
data: {
...mockCourseMetadata,
courseRuns: mockCourseRun,
availableCourseRuns: mockCourseRun,
},
isLoading: false,
isFetching: false,
}),
);
});
});
47 changes: 47 additions & 0 deletions src/components/app/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -749,3 +749,50 @@ export function isEnrollmentUpgradeable(enrollment) {
const canUpgradeToVerifiedEnrollment = enrollment.mode === COURSE_MODES_MAP.AUDIT && !isEnrollByLapsed;
return canUpgradeToVerifiedEnrollment;
}

export function determineAllocatedCourseRunAssignmentsForCourse({
redeemableLearnerCreditPolicies,
courseKey,
}) {
const { learnerContentAssignments } = redeemableLearnerCreditPolicies;
// note: checking the non-happy path first, with early return so happy path code isn't nested in conditional.
if (!learnerContentAssignments.hasAllocatedAssignments) {
return {
allocatedCourseRunAssignmentKeys: [],
allocatedCourseRunAssignments: [],
hasAssignedCourseRuns: false,
hasMultipleAssignedCourseRuns: false,
};
}
const allocatedCourseRunAssignments = learnerContentAssignments.allocatedAssignments.filter((assignment) => (
assignment.isAssignedCourseRun && assignment.parentContentKey === courseKey
));
const allocatedCourseRunAssignmentKeys = allocatedCourseRunAssignments.map(assignment => assignment.contentKey);
const hasAssignedCourseRuns = allocatedCourseRunAssignmentKeys.length > 0;
const hasMultipleAssignedCourseRuns = allocatedCourseRunAssignmentKeys.length > 1;
return {
allocatedCourseRunAssignmentKeys,
allocatedCourseRunAssignments,
hasAssignedCourseRuns,
hasMultipleAssignedCourseRuns,
};
}

export function transformCourseMetadataByAllocatedCourseRunAssignments({
hasMultipleAssignedCourseRuns,
courseMetadata,
allocatedCourseRunAssignmentKeys,
}) {
if (hasMultipleAssignedCourseRuns && allocatedCourseRunAssignmentKeys.length > 1) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question/clarification] Why might we only want to mutate the below courseRuns and availableCourseRuns values when hasMultipleAssignedCourseRuns: true? Is there a particular reason we wouldn't want to go that if the user has a single allocated assignment applicable to the current course?

Copy link
Member Author

@brobro10000 brobro10000 Aug 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a user has a single allocated assignment, we alter how we retrieve course metadata.

  let courseRunKey = searchParams.get('course_run_key')?.replaceAll(' ', '+');
  if (!courseRunKey && hasAssignedCourseRuns) {
    courseRunKey = hasMultipleAssignedCourseRuns ? null : allocatedCourseRunAssignmentKeys[0];
  }

For a single allocation with no course_run_key defined, we are defaulting the courseRunKey to the allocated course run key, only returning a single course from course metadata. That way we don't have to go through the additional step of filtering by the allocated course run a second time.

For a single allocation with a course_run_key, we are returning a single course regardless, so any UI changes (such as important dates) would have to be parsed down stream to determine if the singular course run displayed (via course_run_key) is an allocated course run.

For hasMultipleAssignedCourseRuns: true state, we are opting to use the courseKey to return all course runs from course metadata. Then we transform it further with this function. This approach allows us to treat the multiple course runs for a single course as a true edge case (that could easily be ripped out) as opposed to baking it into the default logic.

return {
...courseMetadata,
courseRuns: courseMetadata.courseRuns.filter(
courseRun => allocatedCourseRunAssignmentKeys.includes(courseRun.key),
),
availableCourseRuns: courseMetadata.courseRuns.filter(
courseRun => allocatedCourseRunAssignmentKeys.includes(courseRun.key),
),
};
}
return courseMetadata;
}
2 changes: 1 addition & 1 deletion src/components/course/course-header/CourseRunCards.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import CourseRunCard from './CourseRunCard';
import DeprecatedCourseRunCard from './deprecated/CourseRunCard';
import { useUserSubsidyApplicableToCourse } from '../data';
import {
LEARNER_CREDIT_SUBSIDY_TYPE,
useCourseMetadata,
useEnterpriseCourseEnrollments,
useEnterpriseCustomerContainsContent,
useUserEntitlements,
LEARNER_CREDIT_SUBSIDY_TYPE,
} from '../../app/data';

/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/course/data/courseLoader.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe('courseLoader', () => {
expectedQueryCount = 15;
}
} else {
expectedQueryCount = 13;
expectedQueryCount = 14;
}
expect(mockQueryClient.ensureQueryData).toHaveBeenCalledTimes(expectedQueryCount);

Expand Down
Loading
Loading