Skip to content

Commit

Permalink
feat: optimistic query update with the BFF inclusion (#1231)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Adam Stankiewicz <[email protected]>
  • Loading branch information
brobro10000 and adamstankiewicz authored Dec 9, 2024
1 parent d3920e9 commit 534156c
Show file tree
Hide file tree
Showing 11 changed files with 607 additions and 139 deletions.
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,
});
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

0 comments on commit 534156c

Please sign in to comment.