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: optimistic query update with the BFF inclusion #1231

Merged
merged 17 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/components/app/data/services/subsidies/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export async function fetchEnterpriseOffers(enterpriseId, options = {}) {
// Redeemable Policies

/**
* TODO
* Fetches the redeemable policies for the specified enterprise and user.
* @param {*} enterpriseUUID
* @param {*} userID
* @returns
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,8 @@ export const InProgressCourseCard = ({
const [isMarkCompleteModalOpen, setIsMarkCompleteModalOpen] = useState(false);
const { courseCards } = useContext(AppContext);
const { data: enterpriseCustomer } = useEnterpriseCustomer();
const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus({ enterpriseCustomer });
const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus();
const isExecutiveEducation = EXECUTIVE_EDUCATION_COURSE_MODES.includes(mode);

const coursewareOrUpgradeLink = useLinkToCourse({
linkToCourse,
subsidyForCourse,
Expand Down Expand Up @@ -196,7 +195,6 @@ export const InProgressCourseCard = ({
updateCourseEnrollmentStatus({
courseRunId: response.courseRunId,
newStatus: response.courseRunStatus,
savedForLater: response.savedForLater,
Copy link
Member

@adamstankiewicz adamstankiewicz Dec 5, 2024

Choose a reason for hiding this comment

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

Good catch to figure out this line was not really needed (i.e., no savedForLater field in the serialized enrollment).

});
navigate('.', {
replace: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ const SavedForLaterCourseCard = (props) => {

const navigate = useNavigate();
const { data: enterpriseCustomer } = useEnterpriseCustomer();
const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus({ enterpriseCustomer });

const updateCourseEnrollmentStatus = useUpdateCourseEnrollmentStatus();
const [isModalOpen, setIsModalOpen] = useState(false);

const handleMoveToInProgressOnClose = () => {
Expand All @@ -63,7 +62,6 @@ const SavedForLaterCourseCard = (props) => {
updateCourseEnrollmentStatus({
courseRunId: response.courseRunId,
newStatus: response.courseRunStatus,
savedForLater: response.savedForLater,
});
navigate('.', {
replace: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import React, { useContext, useState } from 'react';
import { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useQueryClient } from '@tanstack/react-query';
import {
AlertModal, Alert, StatefulButton, Button, ActionRow,
ActionRow, Alert, AlertModal, Button, StatefulButton,
} from '@openedx/paragon';
import { logError } from '@edx/frontend-platform/logging';
import { logError, logInfo } from '@edx/frontend-platform/logging';

import { ToastsContext } from '../../../../../Toasts';
import { unenrollFromCourse } from './data';
import { queryEnterpriseCourseEnrollments, useEnterpriseCustomer } from '../../../../../app/data';
import {
isBFFEnabledForEnterpriseCustomer,
queryEnterpriseCourseEnrollments,
queryEnterpriseLearnerDashboardBFF,
useEnterpriseCustomer,
} from '../../../../../app/data';

const btnLabels = {
default: 'Unenroll',
Expand All @@ -33,6 +38,42 @@ const UnenrollModal = ({
onClose();
};

const updateQueriesAfterUnenrollment = () => {
const enrollmentForCourseFilter = (enrollment) => enrollment.courseRunId !== courseRunId;

const isBFFEnabled = isBFFEnabledForEnterpriseCustomer(enterpriseCustomer.uuid);
if (isBFFEnabled) {
// Determine which BFF queries need to be updated after unenrolling.
const dashboardBFFQueryKey = queryEnterpriseLearnerDashboardBFF({
enterpriseSlug: enterpriseCustomer.slug,
}).queryKey;
const bffQueryKeysToUpdate = [dashboardBFFQueryKey];
// Update the enterpriseCourseEnrollments data in the cache for each BFF query.
bffQueryKeysToUpdate.forEach((queryKey) => {
const existingBFFData = queryClient.getQueryData(queryKey);
if (!existingBFFData) {
logInfo(`Skipping optimistic cache update of ${JSON.stringify(queryKey)} as no cached query data exists yet.`);
return;
}
const updatedBFFData = {
...existingBFFData,
enterpriseCourseEnrollments: existingBFFData.enterpriseCourseEnrollments.filter(enrollmentForCourseFilter),
};
queryClient.setQueryData(queryKey, updatedBFFData);
});
}

// Update the legacy queryEnterpriseCourseEnrollments cache as well.
const enterpriseCourseEnrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey;
const existingCourseEnrollmentsData = queryClient.getQueryData(enterpriseCourseEnrollmentsQueryKey);
if (!existingCourseEnrollmentsData) {
logInfo(`Skipping optimistic cache update of ${JSON.stringify(enterpriseCourseEnrollmentsQueryKey)} as no cached query data exists yet.`);
return;
}
const updatedCourseEnrollmentsData = existingCourseEnrollmentsData.filter(enrollmentForCourseFilter);
queryClient.setQueryData(enterpriseCourseEnrollmentsQueryKey, updatedCourseEnrollmentsData);
};

const handleUnenrollButtonClick = async () => {
setBtnState('pending');
try {
Expand All @@ -43,14 +84,7 @@ const UnenrollModal = ({
setBtnState('default');
return;
}
const enrollmentsQueryKey = queryEnterpriseCourseEnrollments(enterpriseCustomer.uuid).queryKey;
const existingEnrollments = queryClient.getQueryData(enrollmentsQueryKey);
// Optimistically remove the unenrolled course from the list of enrollments in
// the cache for the `queryEnterpriseCourseEnrollments` query.
queryClient.setQueryData(
enrollmentsQueryKey,
existingEnrollments.filter((enrollment) => enrollment.courseRunId !== courseRunId),
);
updateQueriesAfterUnenrollment();
addToast('You have been unenrolled from the course.');
onSuccess();
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClientProvider } from '@tanstack/react-query';
import '@testing-library/jest-dom/extend-expect';

import { logInfo } from '@edx/frontend-platform/logging';
import { COURSE_STATUSES } from '../../../../../../constants';
import { unenrollFromCourse } from './data';
import UnenrollModal from './UnenrollModal';
import { ToastsContext } from '../../../../../Toasts';
import { queryEnterpriseCourseEnrollments, useEnterpriseCustomer } from '../../../../../app/data';
import {
isBFFEnabledForEnterpriseCustomer,
learnerDashboardBFFResponse,
queryEnterpriseCourseEnrollments,
queryEnterpriseLearnerDashboardBFF,
useEnterpriseCustomer,
} from '../../../../../app/data';
import { queryClient } from '../../../../../../utils/tests';
import { enterpriseCourseEnrollmentFactory, enterpriseCustomerFactory } from '../../../../../app/data/services/data/__factories__';
import {
enterpriseCourseEnrollmentFactory,
enterpriseCustomerFactory,
} from '../../../../../app/data/services/data/__factories__';

jest.mock('./data', () => ({
unenrollFromCourse: jest.fn(),
Expand All @@ -22,10 +31,21 @@ jest.mock('@edx/frontend-platform/logging', () => ({
jest.mock('../../../../../app/data', () => ({
...jest.requireActual('../../../../../app/data'),
useEnterpriseCustomer: jest.fn(),
isBFFEnabledForEnterpriseCustomer: jest.fn(),
fetchEnterpriseLearnerDashboard: jest.fn(),
}));

jest.mock('@edx/frontend-platform/logging', () => ({
logInfo: jest.fn(),
}));

const mockEnterpriseCustomer = enterpriseCustomerFactory();
const mockEnterpriseCourseEnrollment = enterpriseCourseEnrollmentFactory();
const mockEnterpriseCourseEnrollments = [mockEnterpriseCourseEnrollment];
const mockBFFDashboardDataWithEnrollments = {
...learnerDashboardBFFResponse,
enterpriseCourseEnrollments: mockEnterpriseCourseEnrollments,
};

const mockOnClose = jest.fn();
const mockOnSuccess = jest.fn();
Expand All @@ -41,12 +61,24 @@ const baseUnenrollModalProps = {
const mockAddToast = jest.fn();

let mockQueryClient;
const UnenrollModalWrapper = ({ ...props }) => {
const UnenrollModalWrapper = ({
existingEnrollmentsQueryData = mockEnterpriseCourseEnrollments,
existingBFFDashboardQueryData = mockBFFDashboardDataWithEnrollments,
...props
}) => {
mockQueryClient = queryClient();
mockQueryClient.setQueryData(
queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey,
[mockEnterpriseCourseEnrollment],
);
if (existingEnrollmentsQueryData) {
mockQueryClient.setQueryData(
queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey,
existingEnrollmentsQueryData,
);
}
if (existingBFFDashboardQueryData) {
mockQueryClient.setQueryData(
queryEnterpriseLearnerDashboardBFF({ enterpriseSlug: mockEnterpriseCustomer.slug }).queryKey,
existingBFFDashboardQueryData,
);
}
return (
<QueryClientProvider client={mockQueryClient}>
<ToastsContext.Provider value={{ addToast: mockAddToast }}>
Expand All @@ -60,6 +92,7 @@ describe('<UnenrollModal />', () => {
beforeEach(() => {
jest.clearAllMocks();
useEnterpriseCustomer.mockReturnValue({ data: mockEnterpriseCustomer });
isBFFEnabledForEnterpriseCustomer.mockReturnValue(false);
});

test('should remain closed when `isOpen` is false', () => {
Expand Down Expand Up @@ -92,20 +125,104 @@ describe('<UnenrollModal />', () => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});

test('should handle unenroll click', async () => {
test.each([
// BFF enabled
{
isBFFEnabled: true,
existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments,
existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments,
},
{
isBFFEnabled: true,
existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments,
existingEnrollmentsQueryData: null,
},
{
isBFFEnabled: true,
existingBFFDashboardQueryData: null,
existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments,
},
{
isBFFEnabled: true,
existingBFFDashboardQueryData: null,
existingEnrollmentsQueryData: null,
},
// BFF disabled
{
isBFFEnabled: false,
existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments,
existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments,
},
{
isBFFEnabled: false,
existingBFFDashboardQueryData: mockBFFDashboardDataWithEnrollments,
existingEnrollmentsQueryData: null,
},
{
isBFFEnabled: false,
existingBFFDashboardQueryData: null,
existingEnrollmentsQueryData: mockEnterpriseCourseEnrollments,
},
{
isBFFEnabled: false,
existingBFFDashboardQueryData: null,
existingEnrollmentsQueryData: null,
},
])('should handle unenroll click (%s)', async ({
isBFFEnabled,
existingBFFDashboardQueryData,
existingEnrollmentsQueryData,
}) => {
isBFFEnabledForEnterpriseCustomer.mockReturnValue(isBFFEnabled);
unenrollFromCourse.mockResolvedValueOnce();
const props = {
...baseUnenrollModalProps,
isOpen: true,
existingBFFDashboardQueryData,
existingEnrollmentsQueryData,
};
render(<UnenrollModalWrapper {...props} />);
userEvent.click(screen.getByText('Unenroll'));

await waitFor(() => {
const updatedEnrollments = mockQueryClient.getQueryData(
const bffDashboardData = mockQueryClient.getQueryData(
queryEnterpriseLearnerDashboardBFF({ enterpriseSlug: mockEnterpriseCustomer.slug }).queryKey,
);
let expectedLogInfoCalls = 0;
if (isBFFEnabled) {
// Only verify the BFF queryEnterpriseCourseEnrollments cache is updated if BFF feature is enabled.
let expectedBFFDashboardData;
if (existingBFFDashboardQueryData) {
expectedBFFDashboardData = learnerDashboardBFFResponse;
} else {
expectedLogInfoCalls += 1;
}
expect(bffDashboardData).toEqual(expectedBFFDashboardData);
} else {
let expectedBFFDashboardData;
if (existingBFFDashboardQueryData) {
expectedBFFDashboardData = existingBFFDashboardQueryData;
}
// Without BFF feature enabled, the original query cache data should remain, if any.
expect(bffDashboardData).toEqual(expectedBFFDashboardData);
}

// Always verify the legacy queryEnterpriseCourseEnrollments cache is updated.
const legacyEnrollmentsData = mockQueryClient.getQueryData(
queryEnterpriseCourseEnrollments(mockEnterpriseCustomer.uuid).queryKey,
);
expect(updatedEnrollments).toEqual([]);
let expectedLegacyEnrollmentsData;
if (existingEnrollmentsQueryData) {
expectedLegacyEnrollmentsData = [];
} else {
expectedLogInfoCalls += 1;
}
expect(legacyEnrollmentsData).toEqual(expectedLegacyEnrollmentsData);

// Verify logInfo calls
expect(logInfo).toHaveBeenCalledTimes(expectedLogInfoCalls);

// Verify side effects
expect(mockOnSuccess).toHaveBeenCalledTimes(1);
expect(mockAddToast).toHaveBeenCalledTimes(1);
expect(mockAddToast).toHaveBeenCalledWith('You have been unenrolled from the course.');
Expand Down
Loading
Loading