Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ae17438
feat: course libraries review tab
navinkarkera Feb 11, 2025
75db420
fixup! feat: course libraries review tab
navinkarkera Feb 12, 2025
44399a9
feat: course libraries alert component
navinkarkera Feb 17, 2025
f8ce852
fixup! feat: course libraries alert component
navinkarkera Feb 17, 2025
f613383
fixup! feat: course libraries alert component
navinkarkera Feb 17, 2025
cb37d3c
feat: review tab ordering and style
navinkarkera Feb 19, 2025
9244ef8
feat: broken link indicator in course libraries
navinkarkera Feb 21, 2025
122ab3d
refactor: libraries tab in course libraries page
navinkarkera Feb 24, 2025
26d2ef7
refactor: support infinite scrolling and split code
navinkarkera Feb 26, 2025
8b12ca8
refactor: use generic entity links api for component usage
navinkarkera Feb 26, 2025
4e03824
fix: search through all results even in pagination
navinkarkera Feb 26, 2025
b2194fd
refactor: review tab design
navinkarkera Feb 26, 2025
ae1f170
feat: ignore update confirmation modal
navinkarkera Feb 26, 2025
2d8ddd5
chore: fix lint issues
navinkarkera Feb 26, 2025
f6e36d0
refactor: remove useFetchIndexDocuments api and replace with useConte…
navinkarkera Feb 28, 2025
79362ae
refactor: use search context
navinkarkera Feb 28, 2025
3511cbe
refactor: error handling and rename preview components
navinkarkera Mar 3, 2025
6ac556c
chore: fix lint issues
navinkarkera Mar 3, 2025
3c459e1
test: fix component details tests
navinkarkera Mar 4, 2025
426a82c
test: review tab
navinkarkera Mar 4, 2025
917de87
fixup! test: review tab
navinkarkera Mar 5, 2025
6776987
chore: fix lint issues
navinkarkera Mar 5, 2025
8f0379f
test: fix failing tests
navinkarkera Mar 5, 2025
1f82f0e
fix: preview old version
navinkarkera Mar 5, 2025
9e83d40
feat: alert in preview for older version
navinkarkera Mar 6, 2025
e552e35
refactor: remove default exports
navinkarkera Mar 10, 2025
1282284
refactor: useCallback for review functions
navinkarkera Mar 10, 2025
9dfad51
fix: modal overflow in preview
navinkarkera Mar 10, 2025
d374eab
fix: review tab link search load on scroll
navinkarkera Mar 10, 2025
2c02106
fix: remove unnecessary debug statement and multiple asserts in waitfor
navinkarkera Mar 11, 2025
6798d51
refactor: use different next page param
navinkarkera Mar 12, 2025
ec8c47c
test: fix failing tests
navinkarkera Mar 12, 2025
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/CourseAuthoringRoutes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import CourseLibraries from './course-libraries';
import { CourseLibraries } from './course-libraries';

/**
* As of this writing, these routes are mounted at a path prefixed with the following:
Expand Down
229 changes: 161 additions & 68 deletions src/course-libraries/CourseLibraries.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';

import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { QueryClient } from '@tanstack/react-query';
import {
initializeMocks,
render,
screen,
waitFor,
within,
} from '../testUtils';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import mockInfoResult from './__mocks__/courseBlocksInfo.json';
import CourseLibraries from './CourseLibraries';
import { mockGetEntityLinksByDownstreamContext } from './data/api.mocks';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
mockGetEntityLinksSummaryByDownstreamContext,
mockFetchIndexDocuments,
mockUseLibBlockMetadata,
} from './data/api.mocks';
import { libraryBlockChangesUrl } from '../course-unit/data/api';
import { type ToastActionData } from '../generic/toast-context';

mockContentSearchConfig.applyMock();
mockGetEntityLinksByDownstreamContext.applyMock();
mockGetEntityLinks.applyMock();
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
mockUseLibBlockMetadata.applyMock();

const searchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search';
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;

jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
Expand All @@ -26,54 +39,46 @@ jest.mock('../studio-home/hooks', () => ({
}),
}));

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useSearchParams: () => [{
get: searchParamsGetMock,
getAll: () => [],
}],
}));

describe('<CourseLibraries />', () => {
beforeEach(() => {
initializeMocks();
fetchMock.mockReset();

// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const filter = requestData?.filter[1];
const mockInfoResultCopy = cloneDeep(mockInfoResult);
const resp = mockInfoResultCopy.filter((o: { filter: string }) => o.filter === filter)[0] || {
result: {
hits: [],
query: '',
processingTimeMs: 0,
limit: 4,
offset: 0,
estimatedTotalHits: 0,
},
};
const { result } = resp;
return result;
});
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
});

const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinksByDownstreamContext.courseKey;
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};

it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyLoading);
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});

it('shows empty state wheen no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyEmpty);
it('shows empty state when no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKeyEmpty);
const emptyMsg = await screen.findByText('This course does not use any content from libraries.');
expect(emptyMsg).toBeInTheDocument();
});

it('shows alert when out of sync components are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'1 library components are out of sync. Review updates to accept or ignore changes',
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
expect(allTab).toHaveAttribute('aria-selected', 'true');
Expand All @@ -82,7 +87,7 @@ describe('<CourseLibraries />', () => {
userEvent.click(reviewBtn);

expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();

// go back to all tab
Expand All @@ -94,14 +99,14 @@ describe('<CourseLibraries />', () => {
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
});

it('hide alert on dismiss', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'1 library components are out of sync. Review updates to accept or ignore changes',
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
userEvent.click(dismissBtn);
Expand All @@ -110,39 +115,127 @@ describe('<CourseLibraries />', () => {

expect(alert).not.toBeInTheDocument();
});
});

describe('<CourseLibraries ReviewTab />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
});

const renderCourseLibrariesReviewPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};

it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});

it('shows empty state when no readyToSync links are present', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate);
const emptyMsg = await screen.findByText('All components are up to date');
expect(emptyMsg).toBeInTheDocument();
});

it('shows links split by library', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
const msg = await screen.findByText('This course contains content from these libraries.');
expect(msg).toBeInTheDocument();
const allButtons = await screen.findAllByRole('button');
// total 3 components used from lib 1
const expectedLib1Blocks = 3;
// total 4 components used from lib 1
const expectedLib2Blocks = 4;
// 1 component has updates.
const expectedLib2ToUpdate = 1;

const libraryCards = allButtons.filter((el) => el.classList.contains('collapsible-trigger'));
expect(libraryCards.length).toEqual(2);
expect(await within(libraryCards[0]).findByText('CS problems 2')).toBeInTheDocument();
expect(await within(libraryCards[0]).findByText(`${expectedLib1Blocks} components applied`)).toBeInTheDocument();
expect(await within(libraryCards[0]).findByText('All components up to date')).toBeInTheDocument();

const libParent1 = libraryCards[0].parentElement;
expect(libParent1).not.toBeNull();
userEvent.click(libraryCards[0]);
const xblockCards1 = libParent1!.querySelectorAll('div.card');
expect(xblockCards1.length).toEqual(expectedLib1Blocks);

expect(await within(libraryCards[1]).findByText('CS problems 3')).toBeInTheDocument();
expect(await within(libraryCards[1]).findByText(`${expectedLib2Blocks} components applied`)).toBeInTheDocument();
expect(await within(libraryCards[1]).findByText(`${expectedLib2ToUpdate} component out of sync`)).toBeInTheDocument();

const libParent2 = libraryCards[1].parentElement;
expect(libParent2).not.toBeNull();
userEvent.click(libraryCards[1]);
const xblockCards2 = libParent2!.querySelectorAll('div.card');
expect(xblockCards2.length).toEqual(expectedLib2Blocks);
it('shows all readyToSync links', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
});

it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
userEvent.click(updateBtns[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});

it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const dialog = await screen.findByRole('dialog');
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});

it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
// Show confirmation modal on clicking ignore.
userEvent.click(ignoreBtns[0]);
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});

it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const previewDialog = await screen.findByRole('dialog');
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
userEvent.click(ignoreBtn);
// Show confirmation modal on clicking ignore.
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
});
Loading