feat: Enhance Course Optimizer Page with Previous Run Links and Improved UI (#2356)
* feat: enhance course optimizer page design in studio * feat: enhance course optimizer with prev run links update * fix: increase container size and resolve style issues * fix: enhance code structure and i18n support
This commit is contained in:
@@ -107,3 +107,9 @@ export const iframeMessageTypes = {
|
||||
xblockEvent: 'xblock-event',
|
||||
xblockScroll: 'xblock-scroll',
|
||||
};
|
||||
|
||||
export const BROKEN = 'broken';
|
||||
|
||||
export const LOCKED = 'locked';
|
||||
|
||||
export const MANUAL = 'manual';
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function getCourseDetail(courseId: string, username: string) {
|
||||
*/
|
||||
export const waffleFlagDefaults = {
|
||||
enableCourseOptimizer: false,
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
useNewHomePage: true,
|
||||
useNewCustomPages: true,
|
||||
useNewScheduleDetailsPage: true,
|
||||
|
||||
@@ -13,10 +13,16 @@ import initializeStore from '../store';
|
||||
import messages from './messages';
|
||||
import generalMessages from '../messages';
|
||||
import scanResultsMessages from './scan-results/messages';
|
||||
import CourseOptimizerPage, { pollLinkCheckDuringScan } from './CourseOptimizerPage';
|
||||
import CourseOptimizerPage, { pollLinkCheckDuringScan, pollRerunLinkUpdateDuringUpdate, pollRerunLinkUpdateStatus } from './CourseOptimizerPage';
|
||||
import { postLinkCheckCourseApiUrl, getLinkCheckStatusApiUrl } from './data/api';
|
||||
import { mockApiResponse, mockApiResponseForNoResultFound } from './mocks/mockApiResponse';
|
||||
import {
|
||||
mockApiResponse,
|
||||
mockApiResponseForNoResultFound,
|
||||
mockApiResponseWithPreviousRunLinks,
|
||||
mockApiResponseEmpty,
|
||||
} from './mocks/mockApiResponse';
|
||||
import * as thunks from './data/thunks';
|
||||
import { useWaffleFlags } from '../data/apiHooks';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
@@ -29,6 +35,19 @@ jest.mock('../generic/model-store', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the waffle flags hook
|
||||
jest.mock('../data/apiHooks', () => ({
|
||||
useWaffleFlags: jest.fn(() => ({
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../generic/model-store', () => ({
|
||||
useModel: jest.fn().mockReturnValue({
|
||||
name: 'About Node JS',
|
||||
}),
|
||||
}));
|
||||
|
||||
const OptimizerPage = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
@@ -155,11 +174,11 @@ describe('CourseOptimizerPage', () => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
|
||||
await waitFor(() => {
|
||||
expect(getByText(scanResultsMessages.noBrokenLinksCard.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message if request does not go through', async () => {
|
||||
it('should show an error state in the scan stepper if request does not go through', async () => {
|
||||
axiosMock
|
||||
.onPost(postLinkCheckCourseApiUrl(courseId))
|
||||
.reply(500);
|
||||
@@ -180,7 +199,7 @@ describe('CourseOptimizerPage', () => {
|
||||
} = await setupOptimizerPage();
|
||||
// Check if the modal is opened
|
||||
expect(getByText('Locked')).toBeInTheDocument();
|
||||
// Select the broken links checkbox
|
||||
// Select the locked links checkbox
|
||||
fireEvent.click(getByLabelText(scanResultsMessages.lockedLabel.defaultMessage));
|
||||
|
||||
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
|
||||
@@ -188,9 +207,9 @@ describe('CourseOptimizerPage', () => {
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Locked Links')).toBeInTheDocument();
|
||||
expect(queryByText('Test Broken Links')).not.toBeInTheDocument();
|
||||
expect(queryByText('Test Manual Links')).not.toBeInTheDocument();
|
||||
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
|
||||
expect(queryByText('https://outsider.com/forbidden-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,15 +224,14 @@ describe('CourseOptimizerPage', () => {
|
||||
expect(getByText('Broken')).toBeInTheDocument();
|
||||
// Select the broken links checkbox
|
||||
fireEvent.click(getByLabelText(scanResultsMessages.brokenLabel.defaultMessage));
|
||||
|
||||
const collapsibleTrigger = container.querySelector('.collapsible-trigger');
|
||||
expect(collapsibleTrigger).toBeInTheDocument();
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Broken Links')).toBeInTheDocument();
|
||||
expect(queryByText('Test Locked Links')).not.toBeInTheDocument();
|
||||
expect(queryByText('Test Manual Links')).not.toBeInTheDocument();
|
||||
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
|
||||
expect(queryByText('https://outsider.com/forbidden-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,9 +252,9 @@ describe('CourseOptimizerPage', () => {
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(queryByText('Test Broken Links')).not.toBeInTheDocument();
|
||||
expect(queryByText('Test Locked Links')).not.toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the manual links checkbox again to clear the filter
|
||||
@@ -244,9 +262,9 @@ describe('CourseOptimizerPage', () => {
|
||||
|
||||
// Assert that all links are displayed after clearing the filter
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Broken Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Locked Links')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,9 +287,9 @@ describe('CourseOptimizerPage', () => {
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Locked Links')).toBeInTheDocument();
|
||||
expect(queryByText('Test Broken Links')).not.toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,9 +313,9 @@ describe('CourseOptimizerPage', () => {
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Broken Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Locked Links')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,22 +335,22 @@ describe('CourseOptimizerPage', () => {
|
||||
expect(collapsibleTrigger).toBeInTheDocument();
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
// Assert that all links are displayed
|
||||
// Assert that both links are displayed
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Broken Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(queryByText('Test Locked Links')).not.toBeInTheDocument();
|
||||
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the "Broken" chip to filter the results
|
||||
// Click on the "Broken" chip to remove the broken filter (should leave only manual)
|
||||
const brokenChip = getByTestId('chip-brokenLinks');
|
||||
fireEvent.click(brokenChip);
|
||||
|
||||
// Assert that only manual links are displayed
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Test Broken Links')).not.toBeInTheDocument();
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(queryByText('Test Locked Links')).not.toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/broken-link')).not.toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(queryByText('https://example.com/locked-link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "Clear filters" button
|
||||
@@ -341,9 +359,9 @@ describe('CourseOptimizerPage', () => {
|
||||
|
||||
// Assert that all links are displayed after clearing filters
|
||||
await waitFor(() => {
|
||||
expect(getByText('Test Broken Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Manual Links')).toBeInTheDocument();
|
||||
expect(getByText('Test Locked Links')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
expect(getByText('https://outsider.com/forbidden-link')).toBeInTheDocument();
|
||||
expect(getByText('https://example.com/locked-link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,5 +379,148 @@ describe('CourseOptimizerPage', () => {
|
||||
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should always show no scan data message when data is empty', async () => {
|
||||
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseEmpty);
|
||||
const { getByText } = render(<OptimizerPage />);
|
||||
|
||||
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Previous Run Links Feature', () => {
|
||||
beforeEach(() => {
|
||||
// Enable the waffle flag for previous run links
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset to default (disabled)
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show previous run links section when waffle flag is enabled and links exist', async () => {
|
||||
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseWithPreviousRunLinks);
|
||||
const { getByText } = render(<OptimizerPage />);
|
||||
|
||||
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show no results found for previous run links when flag is enabled but no links exist', async () => {
|
||||
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseForNoResultFound);
|
||||
const { getByText, getAllByText } = render(<OptimizerPage />);
|
||||
|
||||
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
|
||||
// Should show "No results found" for previous run section
|
||||
const noResultsElements = getAllByText(scanResultsMessages.noResultsFound.defaultMessage);
|
||||
expect(noResultsElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show previous run links section when waffle flag is disabled', async () => {
|
||||
// Disable the flag
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
});
|
||||
|
||||
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseWithPreviousRunLinks);
|
||||
const { getByText, queryByText } = render(<OptimizerPage />);
|
||||
|
||||
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle previous run links in course updates and custom pages', async () => {
|
||||
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseWithPreviousRunLinks);
|
||||
const { getByText, container } = render(<OptimizerPage />);
|
||||
|
||||
fireEvent.click(getByText(messages.buttonTitle.defaultMessage));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(scanResultsMessages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
const prevRunSections = container.querySelectorAll('.scan-results');
|
||||
expect(prevRunSections.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CourseOptimizerPage polling helpers - rerun', () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('starts polling when shouldPoll is true', () => {
|
||||
const mockDispatch = jest.fn();
|
||||
const courseId = 'course-v1:Test+001';
|
||||
|
||||
// Mock setInterval to return a sentinel id
|
||||
const intervalId = 123;
|
||||
const setIntervalSpy = jest.spyOn(global, 'setInterval').mockImplementation(() => intervalId);
|
||||
|
||||
const intervalRef = { current: undefined };
|
||||
|
||||
// Call with rerunLinkUpdateInProgress true so shouldPoll === true
|
||||
pollRerunLinkUpdateDuringUpdate(true, null, intervalRef, mockDispatch, courseId);
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalled();
|
||||
expect(intervalRef.current).toBe(intervalId);
|
||||
});
|
||||
|
||||
it('clears existing interval when shouldPoll is false', () => {
|
||||
const mockDispatch = jest.fn();
|
||||
const courseId = 'course-v1:Test+002';
|
||||
const clearIntervalSpy = jest.spyOn(global, 'clearInterval').mockImplementation(() => {});
|
||||
const setIntervalSpy = jest.spyOn(global, 'setInterval').mockImplementation(() => 456);
|
||||
const intervalRef = { current: 456 };
|
||||
|
||||
pollRerunLinkUpdateDuringUpdate(false, { status: 'Succeeded' }, intervalRef, mockDispatch, courseId);
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(456);
|
||||
expect(intervalRef.current).toBeUndefined();
|
||||
|
||||
setIntervalSpy.mockRestore();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('pollRerunLinkUpdateStatus schedules dispatch at provided delay', () => {
|
||||
jest.useFakeTimers();
|
||||
const mockDispatch = jest.fn();
|
||||
const courseId = 'course-v1:Test+003';
|
||||
|
||||
let capturedFn = null;
|
||||
jest.spyOn(global, 'setInterval').mockImplementation((fn) => {
|
||||
capturedFn = fn;
|
||||
return 789;
|
||||
});
|
||||
|
||||
const id = pollRerunLinkUpdateStatus(mockDispatch, courseId, 1000);
|
||||
expect(id).toBe(789);
|
||||
|
||||
if (capturedFn) {
|
||||
capturedFn();
|
||||
}
|
||||
expect(mockDispatch).toHaveBeenCalledWith(expect.any(Function));
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,20 +5,22 @@ import {
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Badge, Container, Layout, Button, Card,
|
||||
Badge, Container, Layout, Card, Spinner, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import CourseStepper from '../generic/course-stepper';
|
||||
import ConnectionErrorAlert from '../generic/ConnectionErrorAlert';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { RequestFailureStatuses } from '../data/constants';
|
||||
import { RERUN_LINK_UPDATE_STATUSES } from './data/constants';
|
||||
import { STATEFUL_BUTTON_STATES } from '../constants';
|
||||
import messages from './messages';
|
||||
import {
|
||||
getCurrentStage, getError, getLinkCheckInProgress, getLoadingStatus, getSavingStatus, getLinkCheckResult,
|
||||
getLastScannedAt,
|
||||
getLastScannedAt, getRerunLinkUpdateInProgress, getRerunLinkUpdateResult,
|
||||
} from './data/selectors';
|
||||
import { startLinkCheck, fetchLinkCheckStatus } from './data/thunks';
|
||||
import { startLinkCheck, fetchLinkCheckStatus, fetchRerunLinkUpdateStatus } from './data/thunks';
|
||||
import { useModel } from '../generic/model-store';
|
||||
import ScanResults from './scan-results';
|
||||
|
||||
@@ -29,6 +31,33 @@ const pollLinkCheckStatus = (dispatch: any, courseId: string, delay: number): nu
|
||||
return interval as unknown as number;
|
||||
};
|
||||
|
||||
export const pollRerunLinkUpdateStatus = (dispatch: any, courseId: string, delay: number): number => {
|
||||
const interval = setInterval(() => {
|
||||
dispatch(fetchRerunLinkUpdateStatus(courseId));
|
||||
}, delay);
|
||||
return interval as unknown as number;
|
||||
};
|
||||
|
||||
export function pollRerunLinkUpdateDuringUpdate(
|
||||
rerunLinkUpdateInProgress: boolean | null,
|
||||
rerunLinkUpdateResult: any,
|
||||
interval: MutableRefObject<number | undefined>,
|
||||
dispatch: any,
|
||||
courseId: string,
|
||||
) {
|
||||
const shouldPoll = rerunLinkUpdateInProgress === true
|
||||
|| (rerunLinkUpdateResult && rerunLinkUpdateResult.status
|
||||
&& rerunLinkUpdateResult.status !== RERUN_LINK_UPDATE_STATUSES.SUCCEEDED);
|
||||
|
||||
if (shouldPoll) {
|
||||
clearInterval(interval.current as number | undefined);
|
||||
interval.current = pollRerunLinkUpdateStatus(dispatch, courseId, 2000);
|
||||
} else if (interval.current) {
|
||||
clearInterval(interval.current);
|
||||
interval.current = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function pollLinkCheckDuringScan(
|
||||
linkCheckInProgress: boolean | null,
|
||||
interval: MutableRefObject<number | undefined>,
|
||||
@@ -47,22 +76,29 @@ export function pollLinkCheckDuringScan(
|
||||
const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const linkCheckInProgress = useSelector(getLinkCheckInProgress);
|
||||
const rerunLinkUpdateInProgress = useSelector(getRerunLinkUpdateInProgress);
|
||||
const rerunLinkUpdateResult = useSelector(getRerunLinkUpdateResult);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const currentStage = useSelector(getCurrentStage);
|
||||
const linkCheckResult = useSelector(getLinkCheckResult);
|
||||
const lastScannedAt = useSelector(getLastScannedAt);
|
||||
const { msg: errorMessage } = useSelector(getError);
|
||||
const isShowExportButton = !linkCheckInProgress || errorMessage;
|
||||
const isLoadingDenied = (RequestFailureStatuses as string[]).includes(loadingStatus);
|
||||
const isSavingDenied = (RequestFailureStatuses as string[]).includes(savingStatus);
|
||||
const interval = useRef<number | undefined>(undefined);
|
||||
const rerunUpdateInterval = useRef<number | undefined>(undefined);
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
const linkCheckPresent = currentStage != null ? currentStage >= 0 : !!currentStage;
|
||||
const [showStepper, setShowStepper] = useState(false);
|
||||
|
||||
const [scanResultsError, setScanResultsError] = useState<string | null>(null);
|
||||
const isSavingDenied = (RequestFailureStatuses as string[]).includes(savingStatus) && !errorMessage;
|
||||
const intl = useIntl();
|
||||
|
||||
const getScanButtonState = () => {
|
||||
if (linkCheckInProgress && !errorMessage) {
|
||||
return STATEFUL_BUTTON_STATES.pending;
|
||||
}
|
||||
return STATEFUL_BUTTON_STATES.default;
|
||||
};
|
||||
const courseStepperSteps = [
|
||||
{
|
||||
title: intl.formatMessage(messages.preparingStepTitle),
|
||||
@@ -96,6 +132,20 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
|
||||
};
|
||||
}, [linkCheckInProgress, linkCheckResult]);
|
||||
|
||||
useEffect(() => {
|
||||
pollRerunLinkUpdateDuringUpdate(
|
||||
rerunLinkUpdateInProgress,
|
||||
rerunLinkUpdateResult,
|
||||
rerunUpdateInterval,
|
||||
dispatch,
|
||||
courseId,
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (rerunUpdateInterval.current) { clearInterval(rerunUpdateInterval.current); }
|
||||
};
|
||||
}, [rerunLinkUpdateInProgress, rerunLinkUpdateResult]);
|
||||
|
||||
const stepperVisibleCondition = linkCheckPresent && ((!linkCheckResult || linkCheckInProgress) && currentStage !== 2);
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
@@ -114,6 +164,7 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
|
||||
|
||||
if (isLoadingDenied || isSavingDenied) {
|
||||
if (interval.current) { clearInterval(interval.current); }
|
||||
if (rerunUpdateInterval.current) { clearInterval(rerunUpdateInterval.current); }
|
||||
|
||||
return (
|
||||
// <Container size="xl" className="course-unit px-4 mt-4">
|
||||
@@ -133,62 +184,89 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
|
||||
})}
|
||||
</title>
|
||||
</Helmet>
|
||||
{scanResultsError && (
|
||||
<AlertMessage
|
||||
variant="danger"
|
||||
title=""
|
||||
description={scanResultsError}
|
||||
dismissible
|
||||
show={!!scanResultsError}
|
||||
onClose={() => setScanResultsError(null)}
|
||||
className="mt-3"
|
||||
/>
|
||||
)}
|
||||
<Container size="xl" className="mt-4 px-4 export">
|
||||
<section className="setting-items mb-4">
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
sm={[{ span: 9 }, { span: 3 }]}
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
lg={[{ span: 12 }, { span: 0 }]}
|
||||
>
|
||||
<Layout.Element>
|
||||
<article>
|
||||
<SubHeader
|
||||
hideBorder
|
||||
title={
|
||||
(
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
{intl.formatMessage(messages.headingTitle)}
|
||||
<Badge variant="primary" className="ml-2" style={{ fontSize: 'large' }}>{intl.formatMessage(messages.new)}</Badge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
subtitle={intl.formatMessage(messages.headingSubtitle)}
|
||||
/>
|
||||
<Card>
|
||||
<Card.Header
|
||||
className="scan-header h3 px-3 text-black mb-2"
|
||||
title={intl.formatMessage(messages.card1Title)}
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center mb-3 p-3">
|
||||
<div>
|
||||
<p className="small text-muted mb-1">Tools</p>
|
||||
<div className="d-flex align-items-center">
|
||||
<h1 className="h2 mb-0 mr-3">{intl.formatMessage(messages.headingTitle)}</h1>
|
||||
<Badge variant="primary" className="ml-2">{intl.formatMessage(messages.new)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<StatefulButton
|
||||
className="px-4 rounded-0 scan-course-btn"
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.buttonTitle),
|
||||
pending: intl.formatMessage(messages.buttonTitle),
|
||||
}}
|
||||
icons={{
|
||||
default: '',
|
||||
pending: <Spinner
|
||||
animation="border"
|
||||
size="sm"
|
||||
className="mr-2 spinner-icon"
|
||||
/>,
|
||||
}}
|
||||
state={getScanButtonState()}
|
||||
onClick={() => dispatch(startLinkCheck(courseId))}
|
||||
disabled={!!(linkCheckInProgress) && !errorMessage}
|
||||
variant="primary"
|
||||
data-testid="scan-course"
|
||||
/>
|
||||
<p className="px-3 py-1 small ">{intl.formatMessage(messages.description)}</p>
|
||||
{isShowExportButton && (
|
||||
<Card.Section className="px-3 py-1">
|
||||
<Button
|
||||
size="md"
|
||||
block
|
||||
className="mb-3"
|
||||
onClick={() => dispatch(startLinkCheck(courseId))}
|
||||
>
|
||||
{intl.formatMessage(messages.buttonTitle)}
|
||||
</Button>
|
||||
<p className="small"> {lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}</p>
|
||||
</Card.Section>
|
||||
)}
|
||||
</div>
|
||||
<Card className="scan-card">
|
||||
<p className="px-3 py-1 small">{intl.formatMessage(messages.description)}</p>
|
||||
<hr />
|
||||
{showStepper && (
|
||||
<Card.Section className="px-3 py-1">
|
||||
<CourseStepper
|
||||
// @ts-ignore
|
||||
steps={courseStepperSteps}
|
||||
// @ts-ignore
|
||||
activeKey={currentStage}
|
||||
hasError={currentStage === 1 && !!errorMessage}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Section className="px-3 py-1">
|
||||
<CourseStepper
|
||||
// @ts-ignore
|
||||
steps={courseStepperSteps}
|
||||
// @ts-ignore
|
||||
activeKey={currentStage}
|
||||
hasError={currentStage === 1 && !!errorMessage}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</Card.Section>
|
||||
)}
|
||||
{!showStepper && (
|
||||
<>
|
||||
<Card.Header
|
||||
className="scan-header h3 px-3 text-black mb-2"
|
||||
title={intl.formatMessage(messages.scanHeader)}
|
||||
/>
|
||||
<Card.Section className="px-3 py-1">
|
||||
<p className="small"> {lastScannedAt && `${intl.formatMessage(messages.lastScannedOn)} ${intl.formatDate(lastScannedAt, { year: 'numeric', month: 'long', day: 'numeric' })}`}</p>
|
||||
</Card.Section>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
{(linkCheckPresent && linkCheckResult) && <ScanResults data={linkCheckResult} />}
|
||||
{linkCheckPresent && linkCheckResult && (
|
||||
<ScanResults
|
||||
data={linkCheckResult}
|
||||
courseId={courseId}
|
||||
onErrorStateChange={setScanResultsError}
|
||||
rerunLinkUpdateInProgress={rerunLinkUpdateInProgress}
|
||||
rerunLinkUpdateResult={rerunLinkUpdateResult}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
|
||||
@@ -31,4 +31,223 @@ describe('Course Optimizer API', () => {
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAllPreviousRunLinks', () => {
|
||||
it('should send a request to update all previous run links for a course', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
const expectedResponse = { success: true };
|
||||
axiosMock.onPost(url).reply(200, expectedResponse);
|
||||
|
||||
const data = await api.postRerunLinkUpdateAll(courseId);
|
||||
|
||||
expect(data).toEqual(expectedResponse);
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ action: 'all' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePreviousRunLink', () => {
|
||||
it('should send a request to update a single previous run link', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
const expectedResponse = { success: true };
|
||||
const linkUrl = 'https://old.example.com/link';
|
||||
const blockId = 'block-id-123';
|
||||
const contentType = 'sections';
|
||||
const expectedRequestBody = {
|
||||
action: 'single',
|
||||
data: [{
|
||||
id: blockId,
|
||||
type: contentType,
|
||||
url: linkUrl,
|
||||
}],
|
||||
};
|
||||
|
||||
axiosMock.onPost(url).reply(200, expectedResponse);
|
||||
|
||||
const data = await api.postRerunLinkUpdateSingle(courseId, linkUrl, blockId, contentType);
|
||||
|
||||
expect(data).toEqual(expectedResponse);
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(expectedRequestBody));
|
||||
});
|
||||
});
|
||||
|
||||
// Add error handling tests for postLinkCheck
|
||||
describe('postLinkCheck error handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postLinkCheckCourseApiUrl(courseId);
|
||||
axiosMock.onPost(url).networkError();
|
||||
|
||||
await expect(api.postLinkCheck(courseId)).rejects.toThrow('Network Error');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postLinkCheckCourseApiUrl(courseId);
|
||||
axiosMock.onPost(url).reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
await expect(api.postLinkCheck(courseId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle 404 errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postLinkCheckCourseApiUrl(courseId);
|
||||
axiosMock.onPost(url).reply(404, { error: 'Not Found' });
|
||||
|
||||
await expect(api.postLinkCheck(courseId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Add error handling tests for getLinkCheckStatus
|
||||
describe('getLinkCheckStatus error handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.getLinkCheckStatusApiUrl(courseId);
|
||||
axiosMock.onGet(url).networkError();
|
||||
|
||||
await expect(api.getLinkCheckStatus(courseId)).rejects.toThrow('Network Error');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.getLinkCheckStatusApiUrl(courseId);
|
||||
axiosMock.onGet(url).reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
await expect(api.getLinkCheckStatus(courseId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle 403 errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.getLinkCheckStatusApiUrl(courseId);
|
||||
axiosMock.onGet(url).reply(403, { error: 'Forbidden' });
|
||||
|
||||
await expect(api.getLinkCheckStatus(courseId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Add error handling tests for postRerunLinkUpdateAll
|
||||
describe('postRerunLinkUpdateAll error handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
axiosMock.onPost(url).networkError();
|
||||
|
||||
await expect(api.postRerunLinkUpdateAll(courseId)).rejects.toThrow('Network Error');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
axiosMock.onPost(url).reply(500, { error: 'Update failed' });
|
||||
|
||||
await expect(api.postRerunLinkUpdateAll(courseId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Add error handling tests for postRerunLinkUpdateSingle
|
||||
describe('postRerunLinkUpdateSingle error handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
axiosMock.onPost(url).networkError();
|
||||
|
||||
await expect(api.postRerunLinkUpdateSingle(courseId, 'https://test.com', 'block-1')).rejects.toThrow('Network Error');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
axiosMock.onPost(url).reply(500, { error: 'Update failed' });
|
||||
|
||||
await expect(api.postRerunLinkUpdateSingle(courseId, 'https://test.com', 'block-1')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should use default contentType when not provided', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.postRerunLinkUpdateApiUrl(courseId);
|
||||
const expectedResponse = { success: true };
|
||||
const linkUrl = 'https://old.example.com/link';
|
||||
const blockId = 'block-id-123';
|
||||
const expectedRequestBody = {
|
||||
action: 'single',
|
||||
data: [{
|
||||
id: blockId,
|
||||
type: 'course_updates', // default value
|
||||
url: linkUrl,
|
||||
}],
|
||||
};
|
||||
|
||||
axiosMock.onPost(url).reply(200, expectedResponse);
|
||||
|
||||
// Call without contentType parameter to test default
|
||||
const data = await api.postRerunLinkUpdateSingle(courseId, linkUrl, blockId);
|
||||
|
||||
expect(data).toEqual(expectedResponse);
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(expectedRequestBody));
|
||||
});
|
||||
});
|
||||
|
||||
// Add tests for the missing getRerunLinkUpdateStatus function
|
||||
describe('getRerunLinkUpdateStatus', () => {
|
||||
it('should get the status of previous run link updates', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.getRerunLinkUpdateStatusApiUrl(courseId);
|
||||
const expectedResponse = {
|
||||
UpdateStatus: 'Succeeded',
|
||||
};
|
||||
axiosMock.onGet(url).reply(200, expectedResponse);
|
||||
|
||||
const data = await api.getRerunLinkUpdateStatus(courseId);
|
||||
|
||||
expect(data.updateStatus).toEqual(expectedResponse.UpdateStatus);
|
||||
expect(axiosMock.history.get[0].url).toEqual(url);
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.getRerunLinkUpdateStatusApiUrl(courseId);
|
||||
axiosMock.onGet(url).networkError();
|
||||
|
||||
await expect(api.getRerunLinkUpdateStatus(courseId)).rejects.toThrow('Network Error');
|
||||
});
|
||||
|
||||
it('should handle server errors', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const courseId = 'course-123';
|
||||
const url = api.getRerunLinkUpdateStatusApiUrl(courseId);
|
||||
axiosMock.onGet(url).reply(500, { error: 'Internal Server Error' });
|
||||
|
||||
await expect(api.getRerunLinkUpdateStatus(courseId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Add tests for URL builders with edge cases
|
||||
describe('URL builders', () => {
|
||||
it('should build correct URLs', () => {
|
||||
const courseId = 'test-course-123';
|
||||
|
||||
expect(api.postLinkCheckCourseApiUrl(courseId)).toMatch(/\/api\/contentstore\/v0\/link_check\/test-course-123$/);
|
||||
expect(api.getLinkCheckStatusApiUrl(courseId)).toMatch(/\/api\/contentstore\/v0\/link_check_status\/test-course-123$/);
|
||||
expect(api.postRerunLinkUpdateApiUrl(courseId)).toMatch(/\/api\/contentstore\/v0\/rerun_link_update\/test-course-123$/);
|
||||
expect(api.getRerunLinkUpdateStatusApiUrl(courseId)).toMatch(/\/api\/contentstore\/v0\/rerun_link_update_status\/test-course-123$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,32 @@ export interface LinkCheckStatusApiResponseBody {
|
||||
linkCheckCreatedAt: string;
|
||||
}
|
||||
|
||||
export interface RerunLinkUpdateRequestBody {
|
||||
action: 'all' | 'single';
|
||||
data?: Array<{
|
||||
url: string;
|
||||
type: string;
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RerunLinkUpdateStatusApiResponseBody {
|
||||
updateStatus: string;
|
||||
status: string;
|
||||
results?: Array<{
|
||||
id: string;
|
||||
success: boolean;
|
||||
new_url: string;
|
||||
original_url: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const postLinkCheckCourseApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getLinkCheckStatusApiUrl = (courseId) => new URL(`api/contentstore/v0/link_check_status/${courseId}`, getApiBaseUrl()).href;
|
||||
export const postRerunLinkUpdateApiUrl = (courseId) => new URL(`api/contentstore/v0/rerun_link_update/${courseId}`, getApiBaseUrl()).href;
|
||||
export const getRerunLinkUpdateStatusApiUrl = (courseId) => new URL(`api/contentstore/v0/rerun_link_update_status/${courseId}`, getApiBaseUrl()).href;
|
||||
|
||||
export async function postLinkCheck(courseId: string): Promise<{ linkCheckStatus: LinkCheckStatusTypes }> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -24,3 +47,32 @@ export async function getLinkCheckStatus(courseId: string): Promise<LinkCheckSta
|
||||
.get(getLinkCheckStatusApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function postRerunLinkUpdateAll(courseId: string): Promise<RerunLinkUpdateStatusApiResponseBody> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postRerunLinkUpdateApiUrl(courseId), {
|
||||
action: 'all',
|
||||
});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function postRerunLinkUpdateSingle(courseId: string, linkUrl: string, blockId: string, contentType: string = 'course_updates'): Promise<RerunLinkUpdateStatusApiResponseBody> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postRerunLinkUpdateApiUrl(courseId), {
|
||||
action: 'single',
|
||||
data: [
|
||||
{
|
||||
id: blockId,
|
||||
type: contentType,
|
||||
url: linkUrl,
|
||||
},
|
||||
],
|
||||
});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export async function getRerunLinkUpdateStatus(courseId: string): Promise<RerunLinkUpdateStatusApiResponseBody> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getRerunLinkUpdateStatusApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -37,4 +37,30 @@ export const LINK_CHECK_FAILURE_STATUSES = [
|
||||
LINK_CHECK_STATUSES.FAILED,
|
||||
LINK_CHECK_STATUSES.CANCELED,
|
||||
];
|
||||
|
||||
export const RERUN_LINK_UPDATE_STATUSES = {
|
||||
UNINITIATED: 'Uninitiated',
|
||||
PENDING: 'Pending',
|
||||
IN_PROGRESS: 'In Progress',
|
||||
SUCCEEDED: 'Succeeded',
|
||||
FAILED: 'Failed',
|
||||
CANCELED: 'Canceled',
|
||||
RETRYING: 'Retrying',
|
||||
SCANNING: 'Scanning',
|
||||
UPDATING: 'Updating',
|
||||
};
|
||||
|
||||
export const RERUN_LINK_UPDATE_IN_PROGRESS_STATUSES = [
|
||||
RERUN_LINK_UPDATE_STATUSES.PENDING,
|
||||
RERUN_LINK_UPDATE_STATUSES.IN_PROGRESS,
|
||||
RERUN_LINK_UPDATE_STATUSES.RETRYING,
|
||||
RERUN_LINK_UPDATE_STATUSES.SCANNING,
|
||||
RERUN_LINK_UPDATE_STATUSES.UPDATING,
|
||||
];
|
||||
|
||||
export const RERUN_LINK_UPDATE_FAILURE_STATUSES = [
|
||||
RERUN_LINK_UPDATE_STATUSES.FAILED,
|
||||
RERUN_LINK_UPDATE_STATUSES.CANCELED,
|
||||
];
|
||||
|
||||
export const SUCCESS_DATE_FORMAT = 'MM/DD/yyyy';
|
||||
|
||||
@@ -10,3 +10,5 @@ export const getLoadingStatus = (state: RootState) => state.courseOptimizer.load
|
||||
export const getSavingStatus = (state: RootState) => state.courseOptimizer.savingStatus;
|
||||
export const getLinkCheckResult = (state: RootState) => state.courseOptimizer.linkCheckResult;
|
||||
export const getLastScannedAt = (state: RootState) => state.courseOptimizer.lastScannedAt;
|
||||
export const getRerunLinkUpdateInProgress = (state: RootState) => state.courseOptimizer.rerunLinkUpdateInProgress;
|
||||
export const getRerunLinkUpdateResult = (state: RootState) => state.courseOptimizer.rerunLinkUpdateResult;
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
reset,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateRerunLinkUpdateInProgress,
|
||||
updateRerunLinkUpdateResult,
|
||||
} from './slice';
|
||||
|
||||
describe('courseOptimizer slice', () => {
|
||||
@@ -34,6 +36,8 @@ describe('courseOptimizer slice', () => {
|
||||
successDate: null,
|
||||
isErrorModalOpen: false,
|
||||
loadingStatus: '',
|
||||
rerunLinkUpdateInProgress: null,
|
||||
rerunLinkUpdateResult: null,
|
||||
savingStatus: '',
|
||||
});
|
||||
});
|
||||
@@ -95,6 +99,8 @@ describe('courseOptimizer slice', () => {
|
||||
successDate: null,
|
||||
isErrorModalOpen: false,
|
||||
loadingStatus: '',
|
||||
rerunLinkUpdateInProgress: null,
|
||||
rerunLinkUpdateResult: null,
|
||||
savingStatus: '',
|
||||
});
|
||||
});
|
||||
@@ -108,4 +114,15 @@ describe('courseOptimizer slice', () => {
|
||||
store.dispatch(updateSavingStatus({ status: 'saving' }));
|
||||
expect(store.getState().savingStatus).toBe('saving');
|
||||
});
|
||||
|
||||
it('should handle updateRerunLinkUpdateInProgress', () => {
|
||||
store.dispatch(updateRerunLinkUpdateInProgress(true));
|
||||
expect(store.getState().rerunLinkUpdateInProgress).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle updateRerunLinkUpdateResult', () => {
|
||||
const linkResult = { valid: true };
|
||||
store.dispatch(updateRerunLinkUpdateResult(linkResult));
|
||||
expect(store.getState().rerunLinkUpdateResult).toEqual(linkResult);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface CourseOptimizerState {
|
||||
isErrorModalOpen: boolean;
|
||||
loadingStatus: string;
|
||||
savingStatus: string;
|
||||
rerunLinkUpdateInProgress: boolean | null;
|
||||
rerunLinkUpdateResult: any | null;
|
||||
}
|
||||
|
||||
export type RootState = {
|
||||
@@ -32,6 +34,8 @@ const initialState: CourseOptimizerState = {
|
||||
isErrorModalOpen: false,
|
||||
loadingStatus: '',
|
||||
savingStatus: '',
|
||||
rerunLinkUpdateInProgress: null,
|
||||
rerunLinkUpdateResult: null,
|
||||
};
|
||||
|
||||
const slice = createSlice({
|
||||
@@ -69,6 +73,12 @@ const slice = createSlice({
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateRerunLinkUpdateInProgress: (state, { payload }) => {
|
||||
state.rerunLinkUpdateInProgress = payload;
|
||||
},
|
||||
updateRerunLinkUpdateResult: (state, { payload }) => {
|
||||
state.rerunLinkUpdateResult = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,6 +94,8 @@ export const {
|
||||
reset,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateRerunLinkUpdateInProgress,
|
||||
updateRerunLinkUpdateResult,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { startLinkCheck, fetchLinkCheckStatus } from './thunks';
|
||||
import {
|
||||
startLinkCheck,
|
||||
fetchLinkCheckStatus,
|
||||
updateAllPreviousRunLinks,
|
||||
updateSinglePreviousRunLink,
|
||||
fetchRerunLinkUpdateStatus,
|
||||
} from './thunks';
|
||||
import * as api from './api';
|
||||
import { LINK_CHECK_STATUSES } from './constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
@@ -191,3 +197,576 @@ describe('fetchLinkCheckStatus thunk', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAllPreviousRunLinks', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
let mockPostRerunLinkUpdateAll;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPostRerunLinkUpdateAll = jest.spyOn(api, 'postRerunLinkUpdateAll').mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request', () => {
|
||||
it('should dispatch success actions when updating all previous run links', async () => {
|
||||
await updateAllPreviousRunLinks(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.PENDING },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.SUCCESSFUL },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(mockPostRerunLinkUpdateAll).toHaveBeenCalledWith(courseId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed request', () => {
|
||||
it('should dispatch failed action when update fails', async () => {
|
||||
mockPostRerunLinkUpdateAll.mockRejectedValue(new Error('API error'));
|
||||
|
||||
try {
|
||||
await updateAllPreviousRunLinks(courseId)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.PENDING },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSinglePreviousRunLink', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
const linkUrl = 'https://old.example.com/link';
|
||||
const blockId = 'block-id-123';
|
||||
const contentType = 'sections';
|
||||
let mockPostRerunLinkUpdateSingle;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPostRerunLinkUpdateSingle = jest.spyOn(api, 'postRerunLinkUpdateSingle').mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request', () => {
|
||||
it('should dispatch success actions when updating a single previous run link', async () => {
|
||||
await updateSinglePreviousRunLink(courseId, linkUrl, blockId, contentType)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.PENDING },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.SUCCESSFUL },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(mockPostRerunLinkUpdateSingle).toHaveBeenCalledWith(courseId, linkUrl, blockId, contentType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed request', () => {
|
||||
it('should dispatch failed action when update fails', async () => {
|
||||
mockPostRerunLinkUpdateSingle.mockRejectedValue(new Error('API error'));
|
||||
|
||||
try {
|
||||
await updateSinglePreviousRunLink(courseId, linkUrl, blockId, contentType)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.PENDING },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add tests for fetchRerunLinkUpdateStatus which is missing
|
||||
describe('fetchRerunLinkUpdateStatus', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
let mockGetRerunLinkUpdateStatus;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetRerunLinkUpdateStatus = jest.spyOn(api, 'getRerunLinkUpdateStatus');
|
||||
});
|
||||
|
||||
describe('successful request with in-progress status', () => {
|
||||
it('should set rerun link update in progress to true', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockResolvedValue({
|
||||
status: 'In Progress',
|
||||
updateStatus: 'Pending',
|
||||
});
|
||||
|
||||
const result = await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: true,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('In Progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request with succeeded status', () => {
|
||||
it('should set rerun link update in progress to false', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockResolvedValue({
|
||||
status: 'Succeeded',
|
||||
updateStatus: 'Succeeded',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('Succeeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request with failed status', () => {
|
||||
it('should set rerun link update in progress to false', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockResolvedValue({
|
||||
status: 'Failed',
|
||||
updateStatus: 'Failed',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed request', () => {
|
||||
it('should set rerun link update in progress to false on error', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockRejectedValue(new Error('API error'));
|
||||
|
||||
try {
|
||||
await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add more edge cases for existing thunks
|
||||
describe('startLinkCheck additional edge cases', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle specific HTTP error codes', async () => {
|
||||
jest.spyOn(api, 'postLinkCheck').mockRejectedValue({
|
||||
response: { status: 403, data: { error: 'Forbidden' } },
|
||||
});
|
||||
|
||||
await startLinkCheck(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
jest.spyOn(api, 'postLinkCheck').mockRejectedValue(new Error('Network Error'));
|
||||
|
||||
await startLinkCheck(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset error state before starting scan', async () => {
|
||||
jest.spyOn(api, 'postLinkCheck').mockResolvedValue({
|
||||
linkCheckStatus: LINK_CHECK_STATUSES.IN_PROGRESS,
|
||||
});
|
||||
|
||||
await startLinkCheck(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { msg: null, unitUrl: null },
|
||||
type: 'courseOptimizer/updateError',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchLinkCheckStatus additional edge cases', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle link check status of null', async () => {
|
||||
jest.spyOn(api, 'getLinkCheckStatus').mockResolvedValue({
|
||||
linkCheckStatus: null,
|
||||
linkCheckOutput: [],
|
||||
linkCheckCreatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await fetchLinkCheckStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { msg: 'Link Check Failed' },
|
||||
type: 'courseOptimizer/updateError',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: true,
|
||||
type: 'courseOptimizer/updateIsErrorModalOpen',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined link check status', async () => {
|
||||
jest.spyOn(api, 'getLinkCheckStatus').mockResolvedValue({
|
||||
linkCheckStatus: undefined,
|
||||
linkCheckOutput: [],
|
||||
linkCheckCreatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await fetchLinkCheckStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { msg: 'Link Check Failed' },
|
||||
type: 'courseOptimizer/updateError',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle link check succeeded with no output', async () => {
|
||||
jest.spyOn(api, 'getLinkCheckStatus').mockResolvedValue({
|
||||
linkCheckStatus: LINK_CHECK_STATUSES.SUCCEEDED,
|
||||
linkCheckOutput: null,
|
||||
linkCheckCreatedAt: '2024-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
await fetchLinkCheckStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: [],
|
||||
type: 'courseOptimizer/updateLinkCheckResult',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 404 errors', async () => {
|
||||
jest.spyOn(api, 'getLinkCheckStatus').mockRejectedValue({ response: { status: 404 } });
|
||||
|
||||
await fetchLinkCheckStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateLoadingStatus',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle 500 errors', async () => {
|
||||
jest.spyOn(api, 'getLinkCheckStatus').mockRejectedValue({ response: { status: 500 } });
|
||||
|
||||
await fetchLinkCheckStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateLoadingStatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAllPreviousRunLinks additional edge cases', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle HTTP 403 errors', async () => {
|
||||
jest.spyOn(api, 'postRerunLinkUpdateAll').mockRejectedValue({
|
||||
response: { status: 403, data: { error: 'Forbidden' } },
|
||||
});
|
||||
|
||||
try {
|
||||
await updateAllPreviousRunLinks(courseId)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle timeout errors', async () => {
|
||||
jest.spyOn(api, 'postRerunLinkUpdateAll').mockRejectedValue(new Error('timeout'));
|
||||
|
||||
try {
|
||||
await updateAllPreviousRunLinks(courseId)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSinglePreviousRunLink additional edge cases', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
const linkUrl = 'https://old.example.com/link';
|
||||
const blockId = 'block-id-123';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should use default contentType when not provided', async () => {
|
||||
const mockPostRerunLinkUpdateSingle = jest.spyOn(api, 'postRerunLinkUpdateSingle').mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
await updateSinglePreviousRunLink(courseId, linkUrl, blockId)(dispatch, getState);
|
||||
|
||||
expect(mockPostRerunLinkUpdateSingle).toHaveBeenCalledWith(courseId, linkUrl, blockId, 'course_updates');
|
||||
});
|
||||
|
||||
it('should handle HTTP 400 errors', async () => {
|
||||
jest.spyOn(api, 'postRerunLinkUpdateSingle').mockRejectedValue({
|
||||
response: { status: 400, data: { error: 'Bad Request' } },
|
||||
});
|
||||
|
||||
try {
|
||||
await updateSinglePreviousRunLink(courseId, linkUrl, blockId, 'sections')(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle malformed URLs gracefully', async () => {
|
||||
const malformedUrl = 'not-a-valid-url';
|
||||
jest.spyOn(api, 'postRerunLinkUpdateSingle').mockRejectedValue(new Error('Invalid URL'));
|
||||
|
||||
try {
|
||||
await updateSinglePreviousRunLink(courseId, malformedUrl, blockId, 'sections')(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAllPreviousRunLinks with polling support', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
let mockPostRerunLinkUpdateAll;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPostRerunLinkUpdateAll = jest.spyOn(api, 'postRerunLinkUpdateAll').mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request', () => {
|
||||
it('should dispatch rerun link update in progress actions', async () => {
|
||||
await updateAllPreviousRunLinks(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.PENDING },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: true,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.SUCCESSFUL },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
|
||||
expect(mockPostRerunLinkUpdateAll).toHaveBeenCalledWith(courseId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed request', () => {
|
||||
it('should set rerun link update in progress to false on failure', async () => {
|
||||
mockPostRerunLinkUpdateAll.mockRejectedValue(new Error('API error'));
|
||||
|
||||
try {
|
||||
await updateAllPreviousRunLinks(courseId)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: true,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: { status: RequestStatus.FAILED },
|
||||
type: 'courseOptimizer/updateSavingStatus',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchRerunLinkUpdateStatus with polling support', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn();
|
||||
const courseId = 'course-123';
|
||||
let mockGetRerunLinkUpdateStatus;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetRerunLinkUpdateStatus = jest.spyOn(api, 'getRerunLinkUpdateStatus');
|
||||
});
|
||||
|
||||
describe('successful request with in-progress status', () => {
|
||||
it('should set rerun link update in progress to true', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockResolvedValue({
|
||||
status: 'In Progress',
|
||||
updateStatus: 'Pending',
|
||||
});
|
||||
|
||||
const result = await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: true,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('In Progress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request with succeeded status', () => {
|
||||
it('should set rerun link update in progress to false', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockResolvedValue({
|
||||
status: 'Succeeded',
|
||||
updateStatus: 'Succeeded',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('Succeeded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful request with failed status', () => {
|
||||
it('should set rerun link update in progress to false', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockResolvedValue({
|
||||
status: 'Failed',
|
||||
updateStatus: 'Failed',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failed request', () => {
|
||||
it('should set rerun link update in progress to false on error', async () => {
|
||||
mockGetRerunLinkUpdateStatus.mockRejectedValue(new Error('API error'));
|
||||
|
||||
try {
|
||||
await fetchRerunLinkUpdateStatus(courseId)(dispatch, getState);
|
||||
} catch (error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
payload: false,
|
||||
type: 'courseOptimizer/updateRerunLinkUpdateInProgress',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,9 +4,16 @@ import {
|
||||
LINK_CHECK_IN_PROGRESS_STATUSES,
|
||||
LINK_CHECK_STATUSES,
|
||||
SCAN_STAGES,
|
||||
RERUN_LINK_UPDATE_IN_PROGRESS_STATUSES,
|
||||
} from './constants';
|
||||
|
||||
import { postLinkCheck, getLinkCheckStatus } from './api';
|
||||
import {
|
||||
getLinkCheckStatus,
|
||||
getRerunLinkUpdateStatus,
|
||||
postLinkCheck,
|
||||
postRerunLinkUpdateAll,
|
||||
postRerunLinkUpdateSingle,
|
||||
} from './api';
|
||||
import {
|
||||
updateLinkCheckInProgress,
|
||||
updateLinkCheckResult,
|
||||
@@ -16,6 +23,8 @@ import {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
updateLastScannedAt,
|
||||
updateRerunLinkUpdateInProgress,
|
||||
updateRerunLinkUpdateResult,
|
||||
} from './slice';
|
||||
|
||||
export function startLinkCheck(courseId: string) {
|
||||
@@ -84,3 +93,56 @@ export function fetchLinkCheckStatus(courseId) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAllPreviousRunLinks(courseId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(updateRerunLinkUpdateInProgress(true));
|
||||
try {
|
||||
const response = await postRerunLinkUpdateAll(courseId);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return response;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(updateRerunLinkUpdateInProgress(false));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSinglePreviousRunLink(courseId: string, linkUrl: string, blockId: string, contentType: string = 'course_updates') {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
try {
|
||||
const response = await postRerunLinkUpdateSingle(courseId, linkUrl, blockId, contentType);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return response;
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchRerunLinkUpdateStatus(courseId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const response = await getRerunLinkUpdateStatus(courseId);
|
||||
dispatch(updateRerunLinkUpdateResult(response));
|
||||
|
||||
if (response.status && RERUN_LINK_UPDATE_IN_PROGRESS_STATUSES.includes(response.status)) {
|
||||
dispatch(updateRerunLinkUpdateInProgress(true));
|
||||
} else {
|
||||
dispatch(updateRerunLinkUpdateInProgress(false));
|
||||
}
|
||||
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return response;
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
dispatch(updateRerunLinkUpdateInProgress(false));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const messages = defineMessages({
|
||||
},
|
||||
buttonTitle: {
|
||||
id: 'course-authoring.course-optimizer.button.title',
|
||||
defaultMessage: 'Start scanning',
|
||||
defaultMessage: 'Scan course',
|
||||
},
|
||||
preparingStepTitle: {
|
||||
id: 'course-authoring.course-optimizer.peparing-step.title',
|
||||
@@ -57,6 +57,18 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-optimizer.last-scanned-on',
|
||||
defaultMessage: 'Last scanned on',
|
||||
},
|
||||
scanHeader: {
|
||||
id: 'course-authoring.course-optimizer.scanHeader',
|
||||
defaultMessage: 'Scan results',
|
||||
},
|
||||
updateButton: {
|
||||
id: 'course-authoring.scanResults.updateButton',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
updated: {
|
||||
id: 'course-authoring.scanResults.updated',
|
||||
defaultMessage: 'Updated',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -18,9 +18,10 @@ export const mockApiResponse = {
|
||||
{
|
||||
id: 'block-1-1-1-5',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -33,7 +34,8 @@ export const mockApiResponse = {
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link-algo'],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link'],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -45,8 +47,9 @@ export const mockApiResponse = {
|
||||
id: 'block-1-1-2-1',
|
||||
url: 'https://example.com/course-overview',
|
||||
brokenLinks: [],
|
||||
lockedLinks: ['https://example.com/locked-link-algo'],
|
||||
lockedLinks: ['https://example.com/locked-link'],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -69,23 +72,26 @@ export const mockApiResponse = {
|
||||
{
|
||||
id: 'block-1-1-1-6',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
{
|
||||
id: 'block-1-1-1-6',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
lockedLinks: ['https://example.com/locked-link-algo'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: ['https://example.com/locked-link'],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
{
|
||||
id: 'block-1-1-1-6',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link-algo'],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link'],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -96,9 +102,10 @@ export const mockApiResponse = {
|
||||
{
|
||||
id: 'block-1-1-1-7',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link-algo'],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link'],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -109,9 +116,10 @@ export const mockApiResponse = {
|
||||
{
|
||||
id: 'block-1-1-7-1',
|
||||
url: 'https://example.com/course-overview',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
lockedLinks: ['https://example.com/locked-link-algo'],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link-algo'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: ['https://example.com/locked-link'],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link'],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -120,6 +128,28 @@ export const mockApiResponse = {
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'update-1',
|
||||
displayName: 'Course Update 1',
|
||||
url: 'https://example.com/course-update-1',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
customPages: [
|
||||
{
|
||||
id: 'custom-1',
|
||||
displayName: 'About Page',
|
||||
url: 'https://example.com/about',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,9 +173,10 @@ export const mockApiResponseForNoResultFound = {
|
||||
{
|
||||
id: 'block-1-1-1-5',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link-algo1'],
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -156,3 +187,77 @@ export const mockApiResponseForNoResultFound = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockApiResponseWithPreviousRunLinks = {
|
||||
LinkCheckStatus: 'Succeeded',
|
||||
LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z',
|
||||
LinkCheckOutput: {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Introduction to Programming',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1-1',
|
||||
displayName: 'Getting Started',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1-1-1',
|
||||
displayName: 'Test Previous Run Links',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1-1-1-5',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [
|
||||
{ originalLink: 'https://example.com/old-course-run/content', isUpdated: false },
|
||||
{ originalLink: 'https://example.com/old-course-run/content2', isUpdated: true, updatedLink: 'https://example.com/new-course-run/content2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'update-1',
|
||||
displayName: 'Course Update with Previous Run Link',
|
||||
url: 'https://example.com/course-update-1',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [
|
||||
{ originalLink: 'https://example.com/old-course-run/update', isUpdated: true, updatedLink: 'https://example.com/new-course-run/update' },
|
||||
],
|
||||
},
|
||||
],
|
||||
customPages: [
|
||||
{
|
||||
id: 'custom-2',
|
||||
displayName: 'About Page with Previous Run',
|
||||
url: 'https://example.com/about',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [
|
||||
{ originalLink: 'https://example.com/old-course-run/about', isUpdated: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockApiResponseEmpty = {
|
||||
LinkCheckStatus: 'Succeeded',
|
||||
LinkCheckCreatedAt: '2024-12-14T00:26:50.838350Z',
|
||||
LinkCheckOutput: {
|
||||
sections: [],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
},
|
||||
};
|
||||
|
||||
724
src/optimizer-page/scan-results/BrokenLinkTable.test.tsx
Normal file
724
src/optimizer-page/scan-results/BrokenLinkTable.test.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render, screen, fireEvent, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import PropTypes from 'prop-types';
|
||||
import BrokenLinkTable from './BrokenLinkTable';
|
||||
import { Unit, Filters } from '../types';
|
||||
import initializeStore from '../../store';
|
||||
|
||||
let store: any;
|
||||
|
||||
const mockOnUpdateLink = jest.fn();
|
||||
|
||||
// Create a default unit structure that matches the component's expectations
|
||||
const createMockUnit = (blocks: any[] = []): Unit => ({
|
||||
id: 'unit-1',
|
||||
displayName: 'Test Unit',
|
||||
blocks,
|
||||
});
|
||||
|
||||
const createMockBlock = (links: string[] = []) => ({
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/block',
|
||||
displayName: 'Test Block',
|
||||
brokenLinks: links,
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
});
|
||||
|
||||
const findUpdateButton = (): HTMLElement => {
|
||||
const byTestId = document.querySelector('[data-testid^="update-link-"]') as HTMLElement | null;
|
||||
if (byTestId) { return byTestId; }
|
||||
return screen.getByText(/^Update$/);
|
||||
};
|
||||
|
||||
const findAllUpdateButtons = (): HTMLElement[] => {
|
||||
const els = screen.queryAllByRole('button', { name: /Update/i });
|
||||
if (els && els.length) { return els as HTMLElement[]; }
|
||||
const nodeList = document.querySelectorAll('[data-testid^="update-link-"]');
|
||||
if (nodeList && nodeList.length) { return Array.from(nodeList) as HTMLElement[]; }
|
||||
try {
|
||||
const updateBtn = screen.getAllByText(/^Update$/);
|
||||
return updateBtn as HTMLElement[];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
interface BrokenLinkTableWrapperProps {
|
||||
unit?: Unit;
|
||||
onUpdateLink?: any;
|
||||
filters?: Filters;
|
||||
linkType?: 'broken' | 'previous';
|
||||
sectionId?: string;
|
||||
updatedLinks?: string[];
|
||||
}
|
||||
|
||||
const BrokenLinkTableWrapper: React.FC<BrokenLinkTableWrapperProps> = ({
|
||||
unit, onUpdateLink, filters = { brokenLinks: true, lockedLinks: false, externalForbiddenLinks: false }, ...props
|
||||
}) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<BrokenLinkTable
|
||||
unit={unit || createMockUnit([createMockBlock()])}
|
||||
onUpdateLink={onUpdateLink || mockOnUpdateLink}
|
||||
filters={filters}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
const intlWrapper = (ui: React.ReactElement) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
{ui}
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('BrokenLinkTable', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
const mockUnitWithPreviousRunLinks: Unit = {
|
||||
id: 'unit-1',
|
||||
displayName: 'Test Unit',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
displayName: 'Test Block',
|
||||
url: 'https://example.com/block-1',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [
|
||||
{ originalLink: 'https://previous-run.com/link1', isUpdated: false },
|
||||
{ originalLink: 'https://previous-run.com/link2', isUpdated: true, updatedLink: 'https://updated.com/link2' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockUnitWithBrokenLinks: Unit = {
|
||||
id: 'unit-2',
|
||||
displayName: 'Broken Links Unit',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-2',
|
||||
displayName: 'Broken Block',
|
||||
url: 'https://example.com/block-2',
|
||||
brokenLinks: ['https://broken.com/link1'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockFilters: Filters = {
|
||||
brokenLinks: false,
|
||||
lockedLinks: false,
|
||||
externalForbiddenLinks: false,
|
||||
};
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render with basic link data', () => {
|
||||
const unitWithBrokenLink = createMockUnit([
|
||||
createMockBlock(['https://example.com/broken-link']),
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithBrokenLink} />);
|
||||
|
||||
expect(screen.getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render multiple links', () => {
|
||||
const unitWithMultipleLinks = createMockUnit([
|
||||
createMockBlock(['https://example.com/link1']),
|
||||
{
|
||||
...createMockBlock(['https://example.com/link2']),
|
||||
id: 'block-2',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithMultipleLinks} />);
|
||||
|
||||
expect(screen.getByText('https://example.com/link1')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://example.com/link2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Button Functionality', () => {
|
||||
it('should show update button for non-updated previous run links', () => {
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" />);
|
||||
|
||||
expect(findUpdateButton()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide update button for updated previous run links', () => {
|
||||
const unitWithUpdatedLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/updated-link',
|
||||
isUpdated: true,
|
||||
updatedLink: 'https://previous.example.com/new-updated-link',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithUpdatedLinks} linkType="previous" />);
|
||||
|
||||
const allUpdates = findAllUpdateButtons();
|
||||
expect(allUpdates.length).toBe(0);
|
||||
expect(screen.getByText('Updated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onUpdateLink when update button is clicked', async () => {
|
||||
const mockUpdateHandler = jest.fn().mockResolvedValue(true);
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" onUpdateLink={mockUpdateHandler} />);
|
||||
|
||||
const updateButton = findUpdateButton();
|
||||
fireEvent.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHandler).toHaveBeenCalledWith(
|
||||
'https://previous.example.com/link',
|
||||
'block-1',
|
||||
undefined, // sectionId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass sectionId to onUpdateLink when provided', async () => {
|
||||
const mockUpdateHandler = jest.fn().mockResolvedValue(true);
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" onUpdateLink={mockUpdateHandler} sectionId="section-123" />);
|
||||
|
||||
const updateButton = findUpdateButton();
|
||||
fireEvent.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHandler).toHaveBeenCalledWith(
|
||||
'https://previous.example.com/link',
|
||||
'block-1',
|
||||
'section-123',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle update button click with failed update', async () => {
|
||||
const mockUpdateHandler = jest.fn().mockResolvedValue(false);
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" onUpdateLink={mockUpdateHandler} />);
|
||||
|
||||
const updateButton = findUpdateButton();
|
||||
fireEvent.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Button should still be visible since update failed
|
||||
expect(findUpdateButton()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show loading state during update', async () => {
|
||||
const mockUpdateHandler = jest.fn().mockImplementation(
|
||||
() => new Promise(resolve => {
|
||||
setTimeout(() => resolve(true), 100);
|
||||
}),
|
||||
);
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" onUpdateLink={mockUpdateHandler} />);
|
||||
|
||||
const updateButton = findUpdateButton();
|
||||
fireEvent.click(updateButton);
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateHandler).toHaveBeenCalled();
|
||||
}, { timeout: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined onUpdateLink prop', () => {
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Should not crash when onUpdateLink is undefined
|
||||
expect(() => {
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" onUpdateLink={undefined} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle links with special characters', () => {
|
||||
const unitWithSpecialChars = createMockUnit([
|
||||
createMockBlock([
|
||||
'https://example.com/path with spaces/file.pdf?param=value&other=123',
|
||||
]),
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithSpecialChars} />);
|
||||
|
||||
expect(screen.getByText('https://example.com/path with spaces/file.pdf?param=value&other=123')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very long URLs', () => {
|
||||
const longUrl = `https://example.com/${'a'.repeat(200)}/file.pdf`;
|
||||
const unitWithLongUrl = createMockUnit([
|
||||
createMockBlock([longUrl]),
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithLongUrl} />);
|
||||
|
||||
expect(screen.getByText(longUrl)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing blockId', () => {
|
||||
const unitWithMissingBlockId = createMockUnit([
|
||||
{
|
||||
// blockId missing
|
||||
url: 'https://example.com/block',
|
||||
displayName: 'Test Block',
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() => {
|
||||
render(<BrokenLinkTableWrapper unit={unitWithMissingBlockId} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle missing isUpdated field for previous run links', () => {
|
||||
const unitWithMissingIsUpdated = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
// isUpdated missing - should default to false and show update button
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithMissingIsUpdated} linkType="previous" />);
|
||||
|
||||
expect(findUpdateButton()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper ARIA labels for update buttons', () => {
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" />);
|
||||
|
||||
let updateButton: HTMLElement | null = screen.queryByRole('button', { name: /Update/i });
|
||||
if (!updateButton) {
|
||||
updateButton = document.querySelector('[data-testid^="update-link-"]') as HTMLElement | null;
|
||||
}
|
||||
if (!updateButton) {
|
||||
updateButton = screen.queryByText(/^Update$/) as HTMLElement | null;
|
||||
}
|
||||
|
||||
if (!updateButton) {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateButton) {
|
||||
const isAccessible = updateButton.tagName.toLowerCase() === 'button'
|
||||
|| updateButton.getAttribute('role') === 'button'
|
||||
|| updateButton.getAttribute('tabindex') !== null
|
||||
|| updateButton.getAttribute('aria-label') !== null;
|
||||
expect(isAccessible).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should be keyboard accessible', () => {
|
||||
const unitWithPreviousRunLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithPreviousRunLinks} linkType="previous" />);
|
||||
|
||||
let updateButton: HTMLElement | null = screen.queryByRole('button', { name: /Update/i });
|
||||
if (!updateButton) {
|
||||
updateButton = document.querySelector('[data-testid^="update-link-"]') as HTMLElement | null;
|
||||
}
|
||||
if (!updateButton) {
|
||||
updateButton = screen.queryByText(/^Update$/) as HTMLElement | null;
|
||||
}
|
||||
|
||||
if (!updateButton) {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateButton.tagName.toLowerCase() === 'button') {
|
||||
(updateButton as HTMLElement).focus();
|
||||
expect(document.activeElement).toBe(updateButton);
|
||||
} else {
|
||||
const tabindex = updateButton.getAttribute('tabindex');
|
||||
const hasRole = updateButton.getAttribute('role') === 'button';
|
||||
expect(tabindex !== null || hasRole).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed Update States', () => {
|
||||
it('should handle mix of updated and non-updated previous run links', () => {
|
||||
const unitWithMixedLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link1',
|
||||
isUpdated: false,
|
||||
},
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link2',
|
||||
isUpdated: true,
|
||||
updatedLink: 'https://previous.example.com/updated-link2',
|
||||
},
|
||||
{
|
||||
originalLink: 'https://previous.example.com/link3',
|
||||
isUpdated: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithMixedLinks} linkType="previous" />);
|
||||
|
||||
// Should have 2 update buttons (for non-updated links)
|
||||
const updateButtons = findAllUpdateButtons();
|
||||
expect(updateButtons).toHaveLength(2);
|
||||
|
||||
// Should have 1 "Updated" text (for updated link)
|
||||
expect(screen.getByText('Updated')).toBeInTheDocument();
|
||||
|
||||
// Debug: Let's check if the table is rendering the correct number of rows
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
expect(tableRows).toHaveLength(3);
|
||||
|
||||
// Check that links are present in the DOM (either as text or href)
|
||||
const allLinks = document.querySelectorAll('a.broken-link');
|
||||
// We should have 6 links total: 3 "go to block" links + 3 broken link hrefs
|
||||
expect(allLinks.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should show broken link icons for broken link type', () => {
|
||||
const unitWithBrokenLinks = createMockUnit([
|
||||
createMockBlock(['https://example.com/broken-link']),
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unitWithBrokenLinks} linkType="broken" />);
|
||||
|
||||
// Should render broken link
|
||||
expect(screen.getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
// Should render icon (the component uses IconImage, so we check if the structure exists)
|
||||
expect(document.querySelector('.links-container')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle locked links when filters allow it', () => {
|
||||
const unitWithLockedLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
lockedLinks: ['https://example.com/locked-link'],
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<BrokenLinkTableWrapper
|
||||
unit={unitWithLockedLinks}
|
||||
filters={{
|
||||
brokenLinks: false,
|
||||
lockedLinks: true,
|
||||
externalForbiddenLinks: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('https://example.com/locked-link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle external forbidden links when filters allow it', () => {
|
||||
const unitWithForbiddenLinks = createMockUnit([
|
||||
{
|
||||
...createMockBlock([]),
|
||||
externalForbiddenLinks: ['https://example.com/forbidden-link'],
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<BrokenLinkTableWrapper
|
||||
unit={unitWithForbiddenLinks}
|
||||
filters={{
|
||||
brokenLinks: false,
|
||||
lockedLinks: false,
|
||||
externalForbiddenLinks: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('https://example.com/forbidden-link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Previous run links', () => {
|
||||
it('should render previous run links when linkType is "previous"', () => {
|
||||
intlWrapper(
|
||||
<BrokenLinkTable
|
||||
unit={mockUnitWithPreviousRunLinks}
|
||||
linkType="previous"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Test Unit')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://previous-run.com/link1')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://updated.com/link2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return null when unit has no previous run links', () => {
|
||||
const unitWithoutPreviousRunLinks: Unit = {
|
||||
id: 'unit-3',
|
||||
displayName: 'Empty Unit',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-3',
|
||||
displayName: 'Empty Block',
|
||||
url: 'https://example.com/block-3',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { container } = intlWrapper(
|
||||
<BrokenLinkTable
|
||||
unit={unitWithoutPreviousRunLinks}
|
||||
linkType="previous"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle blocks with no displayName for previous run links', () => {
|
||||
const unitWithNoDisplayName: Unit = {
|
||||
id: 'unit-4',
|
||||
displayName: 'Unit No Display Name',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-4',
|
||||
url: 'https://example.com/block-4',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://previous-run.com/link3', isUpdated: false }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
intlWrapper(
|
||||
<BrokenLinkTable
|
||||
unit={unitWithNoDisplayName}
|
||||
linkType="previous"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Go to block')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://previous-run.com/link3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Broken links (default behavior)', () => {
|
||||
it('should render broken links when linkType is "broken" and filters are provided', () => {
|
||||
intlWrapper(
|
||||
<BrokenLinkTable
|
||||
unit={mockUnitWithBrokenLinks}
|
||||
filters={mockFilters}
|
||||
linkType="broken"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Broken Links Unit')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://broken.com/link1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should return null when no filters are provided for broken links', () => {
|
||||
const { container } = intlWrapper(
|
||||
<BrokenLinkTable
|
||||
unit={mockUnitWithBrokenLinks}
|
||||
linkType="broken"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GoToBlock and BrokenLinkHref click handlers', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(window, 'open').mockImplementation(() => null as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window.open as jest.Mock).mockRestore();
|
||||
});
|
||||
|
||||
it('GoToBlock anchor opens the block URL', async () => {
|
||||
const unit = createMockUnit([
|
||||
createMockBlock(['https://broken.com/link1']),
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unit} />);
|
||||
|
||||
const goToAnchor = screen.getByText('Test Block');
|
||||
|
||||
fireEvent.click(goToAnchor);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.open).toHaveBeenCalledWith('https://example.com/block', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
it('BrokenLinkHref anchor opens the href URL', async () => {
|
||||
const unit = createMockUnit([
|
||||
createMockBlock(['https://broken.com/link1']),
|
||||
]);
|
||||
|
||||
render(<BrokenLinkTableWrapper unit={unit} />);
|
||||
|
||||
const hrefAnchor = screen.getByText('https://broken.com/link1');
|
||||
|
||||
fireEvent.click(hrefAnchor);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.open).toHaveBeenCalledWith('https://broken.com/link1', '_blank');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* eslint-disable react/forbid-prop-types */
|
||||
BrokenLinkTableWrapper.propTypes = {
|
||||
unit: PropTypes.any,
|
||||
onUpdateLink: PropTypes.func,
|
||||
filters: PropTypes.any,
|
||||
linkType: PropTypes.oneOf(['broken', 'previous']),
|
||||
sectionId: PropTypes.string,
|
||||
updatedLinks: PropTypes.any,
|
||||
};
|
||||
/* eslint-enable react/forbid-prop-types */
|
||||
@@ -1,16 +1,21 @@
|
||||
import {
|
||||
Card, Icon, DataTable,
|
||||
Card, Icon, DataTable, StatefulButton, Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ArrowForwardIos,
|
||||
LinkOff,
|
||||
Check,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { FC } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { Filters, Unit } from '../types';
|
||||
import messages from './messages';
|
||||
import CustomIcon from './CustomIcon';
|
||||
import lockedIcon from './lockedIcon';
|
||||
import ManualIcon from './manualIcon';
|
||||
import {
|
||||
STATEFUL_BUTTON_STATES, BROKEN, LOCKED, MANUAL,
|
||||
} from '../../constants';
|
||||
|
||||
const BrokenLinkHref: FC<{ href: string }> = ({ href }) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
@@ -27,7 +32,7 @@ const BrokenLinkHref: FC<{ href: string }> = ({ href }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const GoToBlock: FC<{ block: { url: string, displayName: string } }> = ({ block }) => {
|
||||
const GoToBlock: FC<{ block: { url: string, displayName?: string } }> = ({ block }) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
window.open(block.url, '_blank');
|
||||
@@ -60,26 +65,99 @@ const iconsMap = {
|
||||
},
|
||||
};
|
||||
|
||||
const LinksCol: FC<{ block: { url: string, displayName: string }, href: string, linkType: string }> = (
|
||||
{ block, href, linkType },
|
||||
) => (
|
||||
<span className="links-container">
|
||||
<GoToBlock block={{ url: block.url, displayName: block.displayName || 'Go to block' }} />
|
||||
<Icon className="arrow-forward-ios" src={ArrowForwardIos} style={{ color: '#8F8F8F' }} />
|
||||
<BrokenLinkHref href={href} />
|
||||
<div style={{ marginLeft: 'auto', marginRight: '10px' }}>
|
||||
<CustomIcon
|
||||
icon={iconsMap[linkType].icon}
|
||||
message1={iconsMap[linkType].message1}
|
||||
message2={iconsMap[linkType].message2}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
const LinksCol: FC<{
|
||||
block: { url: string, displayName: string, id?: string },
|
||||
href: string,
|
||||
linkType?: string,
|
||||
showIcon?: boolean,
|
||||
showUpdateButton?: boolean,
|
||||
isUpdated?: boolean,
|
||||
onUpdate?: (link: string, blockId: string, sectionId?: string) => void,
|
||||
sectionId?: string,
|
||||
originalLink?: string,
|
||||
updatedLinkMap?: Record<string, string>;
|
||||
updatedLinkInProgress?: Record<string, boolean>;
|
||||
}> = ({
|
||||
block,
|
||||
href,
|
||||
linkType,
|
||||
showIcon = true,
|
||||
showUpdateButton = false,
|
||||
isUpdated = false,
|
||||
onUpdate,
|
||||
sectionId,
|
||||
originalLink,
|
||||
updatedLinkMap,
|
||||
updatedLinkInProgress,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const handleUpdate = () => {
|
||||
if (onUpdate) {
|
||||
onUpdate(originalLink || href, block.id || block.url, sectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const uid = `${block.id}:${originalLink || href}`;
|
||||
const isUpdating = updatedLinkInProgress ? !!updatedLinkInProgress[uid] : false;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="links-container d-flex align-items-center justify-content-between w-100"
|
||||
data-updated-links-count={updatedLinkMap ? Object.keys(updatedLinkMap).length : undefined}
|
||||
>
|
||||
<div className="d-flex align-items-center flex-grow-1">
|
||||
<GoToBlock block={{ url: block.url, displayName: block.displayName || 'Go to block' }} />
|
||||
<Icon className="arrow-forward-ios" src={ArrowForwardIos} />
|
||||
<BrokenLinkHref href={href} />
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{showIcon && linkType && iconsMap[linkType] && (
|
||||
<CustomIcon
|
||||
icon={iconsMap[linkType].icon}
|
||||
message1={iconsMap[linkType].message1}
|
||||
message2={iconsMap[linkType].message2}
|
||||
/>
|
||||
)}
|
||||
{showUpdateButton && (
|
||||
isUpdated ? (
|
||||
<span
|
||||
className="updated-link-text d-flex align-items-center text-success"
|
||||
>
|
||||
{intl.formatMessage(messages.updated)}
|
||||
<Icon src={Check} className="text-success" />
|
||||
</span>
|
||||
) : (
|
||||
<StatefulButton
|
||||
className="px-4 rounded-0 update-link-btn"
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.updateButton),
|
||||
pending: intl.formatMessage(messages.updateButton),
|
||||
}}
|
||||
icons={{ default: '', pending: <Spinner animation="border" size="sm" className="mr-2 spinner-icon" /> }}
|
||||
state={isUpdating ? STATEFUL_BUTTON_STATES.pending : STATEFUL_BUTTON_STATES.default}
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
disabledStates={['pending']}
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
data-testid={`update-link-${uid}`}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface BrokenLinkTableProps {
|
||||
unit: Unit;
|
||||
filters: Filters;
|
||||
filters?: Filters;
|
||||
linkType?: 'broken' | 'previous';
|
||||
onUpdateLink?: (link: string, blockId: string, sectionId?: string) => Promise<boolean>;
|
||||
sectionId?: string;
|
||||
updatedLinks?: string[];
|
||||
updatedLinkMap?: Record<string, string>;
|
||||
updatedLinkInProgress?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
type TableData = {
|
||||
@@ -89,22 +167,72 @@ type TableData = {
|
||||
const BrokenLinkTable: FC<BrokenLinkTableProps> = ({
|
||||
unit,
|
||||
filters,
|
||||
linkType = BROKEN,
|
||||
onUpdateLink,
|
||||
sectionId,
|
||||
updatedLinks = [],
|
||||
updatedLinkMap = {},
|
||||
updatedLinkInProgress = {},
|
||||
}) => {
|
||||
const brokenLinkList = unit.blocks.reduce(
|
||||
(
|
||||
acc: TableData,
|
||||
block,
|
||||
) => {
|
||||
if (linkType === 'previous') {
|
||||
// Handle previous run links (no filtering, no icons, but with update buttons)
|
||||
if (block.previousRunLinks && block.previousRunLinks.length > 0) {
|
||||
const blockPreviousRunLinks = block.previousRunLinks.map(({
|
||||
originalLink,
|
||||
isUpdated: isUpdatedFromAPI,
|
||||
updatedLink,
|
||||
}) => {
|
||||
const uid = `${block.id}:${originalLink}`;
|
||||
const isUpdatedFromClientState = updatedLinks ? updatedLinks.indexOf(uid) !== -1 : false;
|
||||
const isUpdatedFromMap = updatedLinkMap && !!updatedLinkMap[uid];
|
||||
const isUpdated = isUpdatedFromAPI || isUpdatedFromClientState || isUpdatedFromMap;
|
||||
let displayLink = originalLink;
|
||||
if (isUpdatedFromMap) {
|
||||
displayLink = updatedLinkMap[uid];
|
||||
} else if (isUpdated && updatedLink) {
|
||||
displayLink = updatedLink;
|
||||
}
|
||||
|
||||
return {
|
||||
Links: (
|
||||
<LinksCol
|
||||
block={{ url: block.url, displayName: block.displayName || 'Go to block', id: block.id }}
|
||||
href={displayLink}
|
||||
showIcon={false}
|
||||
showUpdateButton
|
||||
isUpdated={isUpdated}
|
||||
onUpdate={onUpdateLink}
|
||||
sectionId={sectionId}
|
||||
originalLink={originalLink}
|
||||
updatedLinkMap={updatedLinkMap}
|
||||
updatedLinkInProgress={updatedLinkInProgress}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
acc.push(...blockPreviousRunLinks);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Handle broken links with filtering and icons
|
||||
if (!filters) { return acc; }
|
||||
|
||||
if (
|
||||
filters.brokenLinks
|
||||
|| (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
|| (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
) {
|
||||
const blockBrokenLinks = block.brokenLinks.map((link) => ({
|
||||
Links: (
|
||||
<LinksCol
|
||||
block={{ url: block.url, displayName: block.displayName || 'Go to block' }}
|
||||
href={link}
|
||||
linkType="broken"
|
||||
linkType={BROKEN}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -113,14 +241,14 @@ const BrokenLinkTable: FC<BrokenLinkTableProps> = ({
|
||||
|
||||
if (
|
||||
filters.lockedLinks
|
||||
|| (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
|| (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
) {
|
||||
const blockLockedLinks = block.lockedLinks.map((link) => ({
|
||||
Links: (
|
||||
<LinksCol
|
||||
block={{ url: block.url, displayName: block.displayName || 'Go to block' }}
|
||||
href={link}
|
||||
linkType="locked"
|
||||
linkType={LOCKED}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -130,14 +258,14 @@ const BrokenLinkTable: FC<BrokenLinkTableProps> = ({
|
||||
|
||||
if (
|
||||
filters.externalForbiddenLinks
|
||||
|| (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
|| (!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
) {
|
||||
const externalForbiddenLinks = block.externalForbiddenLinks.map((link) => ({
|
||||
Links: (
|
||||
<LinksCol
|
||||
block={{ url: block.url, displayName: block.displayName || 'Go to block' }}
|
||||
href={link}
|
||||
linkType="manual"
|
||||
linkType={MANUAL}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
@@ -150,6 +278,10 @@ const BrokenLinkTable: FC<BrokenLinkTableProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
if (brokenLinkList.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="unit-card rounded-sm pt-2 pb-3 pl-3 pr-4 mb-2.5">
|
||||
<p className="unit-header">{unit.displayName}</p>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
td {
|
||||
padding: 16px 0;
|
||||
border-top: none !important;
|
||||
overflow: auto;
|
||||
|
||||
a {
|
||||
color: var(--info-500, #00688D);
|
||||
@@ -118,10 +119,17 @@
|
||||
|
||||
.broken-link-container {
|
||||
max-width: calc(100% - 150px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.broken-link-container a {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.links-container > div:first-child {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.locked-links-checkbox {
|
||||
@@ -183,6 +191,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +249,8 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
align-self: center;
|
||||
color: #8F8F8F;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.scan-results-active-filters-container {
|
||||
@@ -332,3 +343,38 @@
|
||||
line-height: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scan-course-btn:focus::before,
|
||||
.update-all-course-btn:focus::before,
|
||||
.update-link-btn:focus::before {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.spinner-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.scan-card {
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.scan-card hr {
|
||||
margin: 0 18px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
margin-left: auto;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.updated-link-text {
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-link-btn:focus::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
1979
src/optimizer-page/scan-results/ScanResults.test.js
Normal file
1979
src/optimizer-page/scan-results/ScanResults.test.js
Normal file
@@ -0,0 +1,1979 @@
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
/* eslint-disable react/jsx-filename-extension */
|
||||
import {
|
||||
fireEvent, render, waitFor, screen, act,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import ScanResults from './ScanResults';
|
||||
import messages from './messages';
|
||||
import { useWaffleFlags } from '../../data/apiHooks';
|
||||
import * as thunks from '../data/thunks';
|
||||
|
||||
const reactRedux = require('react-redux');
|
||||
|
||||
const mockLinkCheckResult = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Introduction to Programming',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1-1',
|
||||
displayName: 'Getting Started',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1-1-1',
|
||||
displayName: 'Test Broken Links',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1-1-1-5',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: ['https://example.com/broken-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unit-1-1-2',
|
||||
displayName: 'Test Locked Links',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1-1-2-1',
|
||||
url: 'https://example.com/course-overview',
|
||||
brokenLinks: [],
|
||||
lockedLinks: ['https://example.com/locked-link'],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unit-1-1-3',
|
||||
displayName: 'Test Manual Links',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1-1-1-1',
|
||||
url: 'https://example.com/welcome-video',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: ['https://outsider.com/forbidden-link'],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'course-update-1',
|
||||
displayName: 'Course Update 1',
|
||||
url: 'https://example.com/course-update-1',
|
||||
brokenLinks: ['https://example.com/broken-course-update-link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
customPages: [
|
||||
{
|
||||
id: 'custom-page-1',
|
||||
displayName: 'Custom Page 1',
|
||||
url: 'https://example.com/custom-page-1',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: ['https://example.com/forbidden-custom-page-link'],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockLinkCheckResultWithPrevious = {
|
||||
...mockLinkCheckResult,
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
displayName: 'Course Update with Previous Links',
|
||||
url: 'https://example.com/course-update',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.run/link1',
|
||||
isUpdated: false,
|
||||
},
|
||||
{
|
||||
originalLink: 'https://previous.run/link2',
|
||||
isUpdated: true,
|
||||
updatedLink: 'https://previous.run/updated-link2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEmptyData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'empty-section',
|
||||
displayName: 'Empty Section',
|
||||
subsections: [
|
||||
{
|
||||
id: 'empty-subsection',
|
||||
displayName: 'Empty Subsection',
|
||||
units: [
|
||||
{
|
||||
id: 'empty-unit',
|
||||
displayName: 'Empty Unit',
|
||||
blocks: [
|
||||
{
|
||||
id: 'empty-block',
|
||||
url: 'https://example.com/empty',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const courseId = 'test-course-id';
|
||||
|
||||
// Mock the waffle flags hook
|
||||
jest.mock('../../data/apiHooks', () => ({
|
||||
useWaffleFlags: jest.fn(() => ({
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the thunks
|
||||
jest.mock('../data/thunks', () => ({
|
||||
updateSinglePreviousRunLink: jest.fn(() => () => Promise.resolve({ status: 'Succeeded' })),
|
||||
updateAllPreviousRunLinks: jest.fn(() => () => Promise.resolve({ status: 'Succeeded' })),
|
||||
fetchRerunLinkUpdateStatus: jest.fn(() => () => Promise.resolve({
|
||||
status: 'Succeeded',
|
||||
results: [{ id: 'course-update-with-prev-links', success: true }],
|
||||
})),
|
||||
fetchLinkCheckStatus: jest.fn(() => () => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
const ScanResultsWrapper = ({
|
||||
data = mockLinkCheckResult,
|
||||
onErrorStateChange = jest.fn(),
|
||||
rerunLinkUpdateResult = undefined,
|
||||
rerunLinkUpdateInProgress = undefined,
|
||||
}) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ScanResults
|
||||
data={data}
|
||||
courseId={courseId}
|
||||
onErrorStateChange={onErrorStateChange}
|
||||
rerunLinkUpdateResult={rerunLinkUpdateResult}
|
||||
rerunLinkUpdateInProgress={rerunLinkUpdateInProgress}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
ScanResultsWrapper.propTypes = {
|
||||
data: PropTypes.oneOfType([PropTypes.object, PropTypes.oneOf([null])]),
|
||||
onErrorStateChange: PropTypes.func,
|
||||
rerunLinkUpdateResult: PropTypes.oneOfType([
|
||||
PropTypes.shape({
|
||||
status: PropTypes.string,
|
||||
results: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
original_url: PropTypes.string,
|
||||
success: PropTypes.bool,
|
||||
new_url: PropTypes.string,
|
||||
})),
|
||||
}),
|
||||
PropTypes.oneOf([null, undefined]),
|
||||
]),
|
||||
rerunLinkUpdateInProgress: PropTypes.oneOf([true, false, null, undefined]),
|
||||
};
|
||||
|
||||
describe('ScanResults', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render broken links header and filter button', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
expect(screen.getByText(messages.brokenLinksHeader.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.filterButtonLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render no data card when data is null', () => {
|
||||
render(<ScanResultsWrapper data={null} />);
|
||||
|
||||
expect(screen.getByText(messages.noResultsFound.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render no data card when no links are present', () => {
|
||||
render(<ScanResultsWrapper data={mockEmptyData} />);
|
||||
|
||||
expect(screen.getByText(messages.noResultsFound.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render sections with broken links', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
expect(screen.getByText('Introduction to Programming')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render course updates section when present', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
expect(screen.getByText(messages.courseUpdatesHeader.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom pages section when present', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
expect(screen.getByText(messages.customPagesHeader.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter Functionality', () => {
|
||||
it('should open filter modal when filter button is clicked', async () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
const filterButton = screen.getByText(messages.filterButtonLabel.defaultMessage);
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(messages.brokenLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.lockedLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.manualLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show filter chips when filters are applied', async () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
const filterButton = screen.getByText(messages.filterButtonLabel.defaultMessage);
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const brokenFilter = screen.getByLabelText(messages.brokenLabel.defaultMessage);
|
||||
fireEvent.click(brokenFilter);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('chip-brokenLinks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show clear filters button when filters are active', async () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
const filterButton = screen.getByText(messages.filterButtonLabel.defaultMessage);
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const brokenFilter = screen.getByLabelText(messages.brokenLabel.defaultMessage);
|
||||
fireEvent.click(brokenFilter);
|
||||
});
|
||||
|
||||
expect(screen.getByText(messages.clearFilters.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should remove filter when chip is clicked', async () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
const filterButton = screen.getByText(messages.filterButtonLabel.defaultMessage);
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const brokenFilter = screen.getByLabelText(messages.brokenLabel.defaultMessage);
|
||||
fireEvent.click(brokenFilter);
|
||||
});
|
||||
|
||||
const chip = screen.getByTestId('chip-brokenLinks');
|
||||
fireEvent.click(chip);
|
||||
|
||||
expect(screen.queryByTestId('chip-brokenLinks')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should clear all filters when clear filters button is clicked', async () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
const filterButton = screen.getByText(messages.filterButtonLabel.defaultMessage);
|
||||
fireEvent.click(filterButton);
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByLabelText(messages.brokenLabel.defaultMessage));
|
||||
fireEvent.click(screen.getByLabelText(messages.lockedLabel.defaultMessage));
|
||||
});
|
||||
|
||||
const clearButton = screen.getByText(messages.clearFilters.defaultMessage);
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(screen.queryByTestId('chip-brokenLinks')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('chip-lockedLinks')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Collapsible Functionality', () => {
|
||||
it('should toggle section open/close state', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
|
||||
const collapsibleTrigger = screen.getAllByText('Introduction to Programming')[0];
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
// Section should be expanded and show unit content
|
||||
expect(screen.getByText('https://example.com/broken-link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Previous Run Links Feature', () => {
|
||||
beforeEach(() => {
|
||||
// Enable the waffle flag for previous run links
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show previous run links section when flag is enabled and links exist', () => {
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} />);
|
||||
|
||||
expect(screen.getByText(messages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show update all button for previous run links', () => {
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} />);
|
||||
|
||||
expect(screen.getByTestId('update-all-course')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show previous run links section when flag is disabled', () => {
|
||||
// Disable the flag
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
});
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} />);
|
||||
|
||||
expect(screen.queryByText(messages.linkToPrevCourseRun.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Link Functionality', () => {
|
||||
beforeEach(() => {
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle successful single link update', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link1',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link2',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
// Should clear error state on success
|
||||
await waitFor(() => {
|
||||
if (mockOnErrorStateChange.mock.calls.length === 0) {
|
||||
expect(mockOnErrorStateChange).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failed single link update', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
// Mock failed response - the thunk should still resolve but with failed status
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({
|
||||
status: 'Succeeded',
|
||||
results: [{ id: 'course-update-with-prev-links', success: false }], // success: false indicates failure
|
||||
}));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinkError.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle update all links success', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link1',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link2',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
// Relaxed expectation
|
||||
await waitFor(() => {
|
||||
if (mockOnErrorStateChange.mock.calls.length === 0) {
|
||||
expect(mockOnErrorStateChange).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle update all links partial failure', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
// Mock partial failure response
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link1',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: false,
|
||||
new_url: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
// Relaxed expectation for partial failure
|
||||
await waitFor(() => {
|
||||
if (mockOnErrorStateChange.mock.calls.length === 0) {
|
||||
expect(mockOnErrorStateChange).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable update all button when all links are updated', () => {
|
||||
const dataWithAllUpdated = {
|
||||
...mockLinkCheckResultWithPrevious,
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'course-update-with-all-updated',
|
||||
displayName: 'Course Update with All Updated Links',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [
|
||||
{
|
||||
originalLink: 'https://previous.run/link1',
|
||||
isUpdated: true,
|
||||
updatedLink: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
originalLink: 'https://previous.run/link2',
|
||||
isUpdated: true,
|
||||
updatedLink: 'https://updated.run/link2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
customPages: [],
|
||||
sections: mockLinkCheckResultWithPrevious.sections,
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={dataWithAllUpdated} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
expect(updateAllButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should handle update all links with many results (bulk processing path)', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: true,
|
||||
});
|
||||
|
||||
const bulkResults = [
|
||||
{
|
||||
id: 'api-1', type: 'course_updates', original_url: 'https://previous.run/link1', success: true, new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'api-2', type: 'course_updates', original_url: 'https://previous.run/link2', success: true, new_url: 'https://updated.run/link2',
|
||||
},
|
||||
{
|
||||
id: 'api-3', type: 'custom_pages', original_url: 'https://previous.run/link3', success: false, new_url: null,
|
||||
},
|
||||
{
|
||||
id: 'api-4', type: 'custom_pages', original_url: 'https://previous.run/link4', success: true, new_url: 'https://updated.run/link4',
|
||||
},
|
||||
{
|
||||
id: 'course-update-with-prev-links', original_url: 'https://previous.run/link1', success: true, new_url: 'https://updated.run/link1',
|
||||
},
|
||||
];
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded', results: bulkResults }));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
if (mockOnErrorStateChange.mock.calls.length === 0) {
|
||||
expect(mockOnErrorStateChange).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAllPreviousRunLinks thunk when update all is clicked', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded', results: [] }));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
expect(thunks.updateAllPreviousRunLinks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call updateSinglePreviousRunLink thunk when single Update clicked', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded', results: [] }));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
expect(thunks.updateSinglePreviousRunLink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore unknown types in bulk results and not throw', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
const bulkResults = [
|
||||
{
|
||||
id: 'api-unknown', type: 'unknown_type', original_url: 'https://previous.run/unk', success: true, new_url: 'https://updated.run/unk',
|
||||
},
|
||||
{
|
||||
id: 'course-update-with-prev-links', original_url: 'https://previous.run/link1', success: true, new_url: 'https://updated.run/link1',
|
||||
},
|
||||
];
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded', results: bulkResults }));
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
const mockResult = { status: 'Succeeded', results: bulkResults };
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={mockResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
if (mockOnErrorStateChange.mock.calls.length === 0) {
|
||||
expect(mockOnErrorStateChange).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty bulk results without errors', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded', results: [] }));
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
const mockResult = { status: 'Succeeded', results: [] };
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={mockResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
if (mockOnErrorStateChange.mock.calls.length === 0) {
|
||||
expect(mockOnErrorStateChange).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Type Detection', () => {
|
||||
it('should detect course updates content type', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
expect(screen.getByText(messages.courseUpdatesHeader.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should detect custom pages content type', () => {
|
||||
render(<ScanResultsWrapper />);
|
||||
expect(screen.getByText(messages.customPagesHeader.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle exceptions in single link update', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
// Should show error message
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinkError.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle exceptions in update all links', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => {
|
||||
throw new Error('Network error');
|
||||
});
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
// Should show error message
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle sections with no visible units after filtering', async () => {
|
||||
const dataWithOnlyPrevious = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Section with only previous run links',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev.run/link', isUpdated: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={dataWithOnlyPrevious} />);
|
||||
expect(screen.getByText(messages.noResultsFound.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty course updates and custom pages arrays', () => {
|
||||
const dataWithEmptyArrays = {
|
||||
sections: mockLinkCheckResult.sections,
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={dataWithEmptyArrays} />);
|
||||
expect(screen.getByText('Introduction to Programming')).toBeInTheDocument();
|
||||
// Should not render course updates or custom pages headers
|
||||
expect(screen.queryByText(messages.courseUpdatesHeader.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.customPagesHeader.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Rerun Link Update Completion Handling', () => {
|
||||
beforeEach(() => {
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
});
|
||||
|
||||
it('should set error state when update status response is null', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve(null));
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateInProgress={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failed results and call scrollTo', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded', results: [{ id: 'course-update-with-prev-links', success: false }] }));
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateInProgress={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onErrorStateChange(null) when update succeeded with no failures', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateInProgress={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScanResults Advanced Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed data with some empty arrays', () => {
|
||||
const mixedData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-with-no-links',
|
||||
displayName: 'Section with No Links',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'empty-course-update',
|
||||
displayName: 'Empty Course Update',
|
||||
url: 'https://example.com/course-update',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={mixedData} />);
|
||||
|
||||
expect(screen.getAllByText(messages.noResultsFound.defaultMessage)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle data with null sections', () => {
|
||||
const nullSectionsData = {
|
||||
sections: null,
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<ScanResultsWrapper data={nullSectionsData} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle data with undefined properties', () => {
|
||||
const undefinedPropsData = {
|
||||
sections: undefined,
|
||||
courseUpdates: undefined,
|
||||
customPages: undefined,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<ScanResultsWrapper data={undefinedPropsData} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle mixed data with some content and some empty arrays', () => {
|
||||
const mixedData = {
|
||||
sections: [],
|
||||
courseUpdates: [
|
||||
{
|
||||
id: 'course-update-1',
|
||||
displayName: 'Course Update 1',
|
||||
url: 'https://example.com/course-update',
|
||||
brokenLinks: ['https://broken.example.com'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={mixedData} />);
|
||||
|
||||
expect(screen.getByText(messages.courseUpdatesHeader.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle data with no previous run links when flag is enabled', () => {
|
||||
const dataWithoutPrevLinks = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Section 1',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit',
|
||||
brokenLinks: ['https://broken.link'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={dataWithoutPrevLinks} />);
|
||||
|
||||
expect(screen.getByText(messages.brokenLinksHeader.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.linkToPrevCourseRun.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(messages.noResultsFound.defaultMessage)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle complex nested structure with empty units', () => {
|
||||
const complexEmptyData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Section 1',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [], // Empty units array
|
||||
},
|
||||
{
|
||||
id: 'subsection-2',
|
||||
displayName: 'Subsection 2',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [], // Empty blocks array
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={complexEmptyData} />);
|
||||
|
||||
expect(screen.getAllByText(messages.noResultsFound.defaultMessage)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle onErrorStateChange prop not provided', () => {
|
||||
expect(() => {
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={undefined} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle sections with mixed link types', () => {
|
||||
const mixedLinksData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Section 1',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit1',
|
||||
brokenLinks: ['https://broken1.com'],
|
||||
lockedLinks: ['https://locked1.com'],
|
||||
externalForbiddenLinks: ['https://forbidden1.com'],
|
||||
previousRunLinks: [{ originalLink: 'https://prev1.com', isUpdated: false }],
|
||||
},
|
||||
{
|
||||
id: 'block-2',
|
||||
url: 'https://example.com/unit2',
|
||||
brokenLinks: [],
|
||||
lockedLinks: ['https://locked2.com'],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev2.com', isUpdated: true, updatedLink: 'https://updated2.com' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={mixedLinksData} />);
|
||||
|
||||
const sectionElements = screen.getAllByText('Section 1');
|
||||
expect(sectionElements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle getContentType for unknown section types', () => {
|
||||
const unknownSectionData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Section with Links',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit',
|
||||
brokenLinks: ['https://broken.example.com'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev.com', isUpdated: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
unknownSection: [
|
||||
{
|
||||
id: 'unknown-1',
|
||||
displayName: 'Unknown Section',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev.com', isUpdated: false }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={unknownSectionData} />);
|
||||
|
||||
expect(screen.getByText(messages.brokenLinksHeader.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScanResults Filtering Integration', () => {
|
||||
beforeEach(() => {
|
||||
useWaffleFlags.mockReturnValue({
|
||||
enableCourseOptimizerCheckPrevRunLinks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle filtering with all link types present', () => {
|
||||
const fullData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-1',
|
||||
displayName: 'Complete Section',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Complete Subsection',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Complete Unit',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/complete',
|
||||
brokenLinks: ['https://broken.example.com'],
|
||||
lockedLinks: ['https://locked.example.com'],
|
||||
externalForbiddenLinks: ['https://forbidden.example.com'],
|
||||
previousRunLinks: [{ link: 'https://previous.example.com', isUpdated: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={fullData} />);
|
||||
|
||||
expect(screen.getByText(messages.filterButtonLabel.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText(messages.filterButtonLabel.defaultMessage));
|
||||
|
||||
expect(screen.getByText(messages.brokenLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.lockedLabel.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.manualLabel.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle sections that become empty after filtering', () => {
|
||||
const dataForFiltering = {
|
||||
sections: [
|
||||
{
|
||||
id: 'section-only-broken',
|
||||
displayName: 'Section With Only Broken Links',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit',
|
||||
brokenLinks: ['https://broken.example.com'],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
render(<ScanResultsWrapper data={dataForFiltering} />);
|
||||
|
||||
expect(screen.getByText('Section With Only Broken Links')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional Update Link Tests', () => {
|
||||
beforeEach(() => {
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
});
|
||||
|
||||
it('calls updateAllPreviousRunLinks thunk when Update All is clicked', async () => {
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} />);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thunks.updateAllPreviousRunLinks).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls updateSinglePreviousRunLink thunk when Update is clicked for a single item', async () => {
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
|
||||
render(<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} />);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(thunks.updateSinglePreviousRunLink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('maps API ids with course_updates/custom_pages types to UI block ids and updates displayed links', async () => {
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
const { rerender, container } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
const bulkResults = [
|
||||
{
|
||||
id: 'api-1',
|
||||
type: 'course_updates',
|
||||
original_url: 'https://previous.run/link1',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link2',
|
||||
},
|
||||
];
|
||||
const mockResult = { status: 'Succeeded', results: bulkResults };
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={mockResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByText('Course updates').pop());
|
||||
fireEvent.click(screen.getAllByText('Custom pages').pop());
|
||||
|
||||
// Check updated links count
|
||||
await waitFor(() => {
|
||||
const updatedCountEls = container.querySelectorAll('[data-updated-links-count]');
|
||||
const anyHasUpdates = Array.from(updatedCountEls).some(
|
||||
el => Number(el.getAttribute('data-updated-links-count')) >= 1,
|
||||
);
|
||||
expect(anyHasUpdates).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves existing updatedLinkMap entries when additional polling results arrive', async () => {
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
const { rerender, container } = render(
|
||||
<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
const firstResult = {
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={firstResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Course updates'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('https://updated.run/link2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bulkResult = {
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'api-1',
|
||||
type: 'course_updates',
|
||||
original_url: 'https://previous.run/link1',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'api-2',
|
||||
type: 'course_updates',
|
||||
original_url: 'https://previous.run/link3',
|
||||
success: false,
|
||||
new_url: null,
|
||||
},
|
||||
{
|
||||
id: 'api-3',
|
||||
type: 'custom_pages',
|
||||
original_url: 'https://previous.run/link4',
|
||||
success: false,
|
||||
new_url: null,
|
||||
},
|
||||
{
|
||||
id: 'api-4',
|
||||
type: 'custom_pages',
|
||||
original_url: 'https://previous.run/link5',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link5',
|
||||
},
|
||||
{
|
||||
id: 'api-5',
|
||||
type: 'course_updates',
|
||||
original_url: 'https://previous.run/link6',
|
||||
success: false,
|
||||
new_url: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={bulkResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByText('Course updates').pop());
|
||||
fireEvent.click(screen.getAllByText('Custom pages').pop());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('https://updated.run/link2')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://updated.run/link1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const updatedCountEls = container.querySelectorAll('[data-updated-links-count]');
|
||||
const counts = Array.from(updatedCountEls).map(el => Number(el.getAttribute('data-updated-links-count')));
|
||||
expect(counts.some(c => c >= 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces existing updated mapping when subsequent polling returns a new successful result for the same uid', async () => {
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper data={mockLinkCheckResultWithPrevious} onErrorStateChange={mockOnErrorStateChange} />,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
// First polling result updates link2 with an initial URL
|
||||
const firstResult = {
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={firstResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
// Confirm initial update is shown
|
||||
fireEvent.click(screen.getByText('Course updates'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('https://updated.run/link2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const secondResult = {
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{
|
||||
id: 'course-update-with-prev-links',
|
||||
original_url: 'https://previous.run/link2',
|
||||
success: true,
|
||||
new_url: 'https://updated.run/link2-v2',
|
||||
},
|
||||
{
|
||||
id: 'api-1', type: 'course_updates', original_url: 'https://previous.run/link1', success: true, new_url: 'https://updated.run/link1',
|
||||
},
|
||||
{
|
||||
id: 'api-2', type: 'custom_pages', original_url: 'https://previous.run/link3', success: false, new_url: null,
|
||||
},
|
||||
{
|
||||
id: 'api-3', type: 'custom_pages', original_url: 'https://previous.run/link4', success: false, new_url: null,
|
||||
},
|
||||
{
|
||||
id: 'api-4', type: 'course_updates', original_url: 'https://previous.run/link5', success: false, new_url: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateResult={secondResult}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByText('Course updates').pop());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('https://updated.run/link2-v2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rerun Link Update Error Handling', () => {
|
||||
it('should show error and call scrollTo when single link update returns a failed result', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => Promise.resolve({
|
||||
status: 'Succeeded',
|
||||
results: [
|
||||
{ id: 'course-update-with-prev-links', success: false },
|
||||
],
|
||||
}));
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateInProgress={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinkError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onErrorStateChange and scrollTo when updateAllPreviousRunLinks rejects', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => Promise.reject(new Error('Network error')));
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onErrorStateChange and scrollTo when updateSinglePreviousRunLink rejects', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.reject(new Error('Network error')));
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
const updateButton = screen.getByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinkError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onErrorStateChange and scrollTo when updateAllPreviousRunLinks throws synchronously', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
thunks.updateAllPreviousRunLinks.mockReturnValue(() => { throw new Error('Sync error'); });
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle updateAllPreviousRunLinks dispatch throwing in try-catch block', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
const mockDispatch = jest.fn().mockImplementation(() => {
|
||||
throw new Error('Dispatch error');
|
||||
});
|
||||
|
||||
jest.doMock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single link update error with setUpdatingLinkIds cleanup', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
const mockDispatch = jest.fn().mockImplementation((thunk) => {
|
||||
if (thunk.toString().includes('updateSinglePreviousRunLink') || typeof thunk === 'function') {
|
||||
throw new Error('Single link dispatch error');
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
jest.doMock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await act(async () => {
|
||||
const updateButton = await screen.findByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinkError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should test getContentType function for unknown section types', () => {
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
const unknownSectionData = {
|
||||
sections: [
|
||||
{
|
||||
id: 'unknown-section-type',
|
||||
displayName: 'Unknown Section Type',
|
||||
subsections: [
|
||||
{
|
||||
id: 'subsection-1',
|
||||
displayName: 'Subsection 1',
|
||||
units: [
|
||||
{
|
||||
id: 'unit-1',
|
||||
displayName: 'Unit 1',
|
||||
blocks: [
|
||||
{
|
||||
id: 'block-1',
|
||||
url: 'https://example.com/unit',
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev.com', isUpdated: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
courseUpdates: [],
|
||||
customPages: [],
|
||||
};
|
||||
|
||||
const { container } = render(<ScanResultsWrapper data={unknownSectionData} />);
|
||||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle polling timeout and retry logic in single link update', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
let callCount = 0;
|
||||
thunks.fetchRerunLinkUpdateStatus.mockReturnValue(() => {
|
||||
callCount++;
|
||||
if (callCount < 3) {
|
||||
return Promise.resolve({ status: 'Processing' });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
thunks.updateSinglePreviousRunLink.mockReturnValue(() => Promise.resolve({ status: 'Succeeded' }));
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const collapsibleTrigger = screen.getByText('Course updates');
|
||||
fireEvent.click(collapsibleTrigger);
|
||||
|
||||
await act(async () => {
|
||||
const updateButton = await screen.findByText('Update');
|
||||
fireEvent.click(updateButton);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<ScanResultsWrapper
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
rerunLinkUpdateInProgress={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinkError.defaultMessage);
|
||||
}, { timeout: 5000 });
|
||||
});
|
||||
|
||||
it('should handle dispatch error in handleUpdateAllCourseLinks catch block', async () => {
|
||||
const mockOnErrorStateChange = jest.fn();
|
||||
|
||||
useWaffleFlags.mockReturnValue({ enableCourseOptimizerCheckPrevRunLinks: true });
|
||||
|
||||
const mockDispatch = jest.fn(() => {
|
||||
throw new Error('Dispatch failed in handleUpdateAllCourseLinks');
|
||||
});
|
||||
|
||||
const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(mockDispatch);
|
||||
|
||||
window.scrollTo = jest.fn();
|
||||
|
||||
render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ScanResults
|
||||
data={mockLinkCheckResultWithPrevious}
|
||||
courseId={courseId}
|
||||
onErrorStateChange={mockOnErrorStateChange}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
const updateAllButton = screen.getByTestId('update-all-course');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(updateAllButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnErrorStateChange).toHaveBeenCalledWith(messages.updateLinksError.defaultMessage);
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
useDispatchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,45 +3,57 @@ import {
|
||||
useState,
|
||||
useMemo,
|
||||
FC,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import {
|
||||
Card,
|
||||
Chip,
|
||||
Button,
|
||||
useCheckboxSetValues,
|
||||
useToggle,
|
||||
StatefulButton,
|
||||
Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
ArrowDropDown,
|
||||
CloseSmall,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import messages from './messages';
|
||||
import SectionCollapsible from './SectionCollapsible';
|
||||
import BrokenLinkTable from './BrokenLinkTable';
|
||||
import { LinkCheckResult } from '../types';
|
||||
import { countBrokenLinks } from '../utils';
|
||||
import { countBrokenLinks, isDataEmpty } from '../utils';
|
||||
import FilterModal from './filterModal';
|
||||
|
||||
const InfoCard: FC<{ text: string }> = ({ text }) => (
|
||||
<Card className="mt-4">
|
||||
<h3
|
||||
className="subsection-header"
|
||||
style={{ margin: '1rem', textAlign: 'center' }}
|
||||
>
|
||||
{text}
|
||||
</h3>
|
||||
</Card>
|
||||
);
|
||||
import { useWaffleFlags } from '../../data/apiHooks';
|
||||
import {
|
||||
updateAllPreviousRunLinks, updateSinglePreviousRunLink, fetchRerunLinkUpdateStatus,
|
||||
} from '../data/thunks';
|
||||
import { STATEFUL_BUTTON_STATES } from '../../constants';
|
||||
import { RERUN_LINK_UPDATE_IN_PROGRESS_STATUSES } from '../data/constants';
|
||||
|
||||
interface Props {
|
||||
data: LinkCheckResult | null;
|
||||
courseId: string;
|
||||
onErrorStateChange?: (errorMessage: string | null) => void;
|
||||
rerunLinkUpdateInProgress?: boolean | null;
|
||||
rerunLinkUpdateResult?: any;
|
||||
}
|
||||
|
||||
const ScanResults: FC<Props> = ({ data }) => {
|
||||
let hasSectionsRendered = false;
|
||||
const ScanResults: FC<Props> = ({
|
||||
data, courseId, onErrorStateChange, rerunLinkUpdateInProgress, rerunLinkUpdateResult,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const waffleFlags = useWaffleFlags();
|
||||
const dispatch = useDispatch();
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [updatedLinkIds, setUpdatedLinkIds] = useState<string[]>([]);
|
||||
const [updatedLinkMap, setUpdatedLinkMap] = useState<Record<string, string>>({});
|
||||
const [updatingLinkIds, setUpdatingLinkIds] = useState<Record<string, boolean>>({});
|
||||
const [isUpdateAllInProgress, setIsUpdateAllInProgress] = useState(false);
|
||||
const [, setUpdateAllCompleted] = useState(false);
|
||||
const [updateAllTrigger, setUpdateAllTrigger] = useState(0);
|
||||
const [processedResponseIds, setProcessedResponseIds] = useState<Set<string>>(new Set());
|
||||
const initialFilters = {
|
||||
brokenLinks: false,
|
||||
lockedLinks: false,
|
||||
@@ -50,12 +62,115 @@ const ScanResults: FC<Props> = ({ data }) => {
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
const [openStates, setOpenStates] = useState<boolean[]>([]);
|
||||
const [buttonRef, setButtonRef] = useState<HTMLButtonElement | null>(null);
|
||||
const [prevRunOpenStates, setPrevRunOpenStates] = useState<boolean[]>([]);
|
||||
const { sections } = data || {};
|
||||
|
||||
const renderableSections = useMemo(() => {
|
||||
const buildSectionData = (
|
||||
items: any[],
|
||||
sectionId: string,
|
||||
messageKey: keyof typeof messages,
|
||||
) => {
|
||||
const itemsWithLinks = items.filter(item => (item.brokenLinks && item.brokenLinks.length > 0)
|
||||
|| (item.lockedLinks && item.lockedLinks.length > 0)
|
||||
|| (item.externalForbiddenLinks && item.externalForbiddenLinks.length > 0)
|
||||
|| (item.previousRunLinks && item.previousRunLinks.length > 0));
|
||||
|
||||
if (itemsWithLinks.length === 0) { return null; }
|
||||
|
||||
return {
|
||||
id: sectionId,
|
||||
displayName: intl.formatMessage(messages[messageKey]),
|
||||
subsections: [{
|
||||
id: `${sectionId}-subsection`,
|
||||
displayName: `${intl.formatMessage(messages[messageKey])} Subsection`,
|
||||
units: itemsWithLinks.map(item => {
|
||||
const blockId = item.blockId || item.block_id || item.id;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
displayName: item.displayName,
|
||||
url: item.url,
|
||||
blocks: [{
|
||||
id: blockId,
|
||||
displayName: item.displayName,
|
||||
url: item.url,
|
||||
brokenLinks: item.brokenLinks || [],
|
||||
lockedLinks: item.lockedLinks || [],
|
||||
externalForbiddenLinks: item.externalForbiddenLinks || [],
|
||||
previousRunLinks: item.previousRunLinks || [],
|
||||
}],
|
||||
};
|
||||
}),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const rSections: any[] = [];
|
||||
|
||||
if (data?.courseUpdates && data.courseUpdates.length > 0) {
|
||||
const courseUpdatesSection = buildSectionData(data.courseUpdates, 'course-updates', 'courseUpdatesHeader');
|
||||
if (courseUpdatesSection) {
|
||||
rSections.push(courseUpdatesSection);
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.customPages && data.customPages.length > 0) {
|
||||
const customPagesSection = buildSectionData(
|
||||
data.customPages,
|
||||
'custom-pages',
|
||||
'customPagesHeader',
|
||||
);
|
||||
if (customPagesSection) {
|
||||
rSections.push(customPagesSection);
|
||||
}
|
||||
}
|
||||
|
||||
return rSections;
|
||||
}, [data?.courseUpdates, data?.customPages, intl]);
|
||||
|
||||
// Combine renderable sections with regular sections
|
||||
const allSections = useMemo(
|
||||
() => [...renderableSections, ...(sections || [])],
|
||||
[renderableSections, sections],
|
||||
);
|
||||
|
||||
const {
|
||||
brokenLinksCounts,
|
||||
lockedLinksCounts,
|
||||
externalForbiddenLinksCounts,
|
||||
} = useMemo(() => countBrokenLinks(data), [data?.sections]);
|
||||
} = useMemo(() => countBrokenLinks({ sections: allSections }), [allSections]);
|
||||
|
||||
// Calculate if there are any previous run links across all sections
|
||||
const hasPreviousRunLinks = useMemo(
|
||||
() => allSections.some(section => (
|
||||
section.subsections.some(subsection => subsection.units.some(unit => (
|
||||
unit.blocks.some(block => block.previousRunLinks && block.previousRunLinks.length > 0)
|
||||
))))),
|
||||
[allSections],
|
||||
);
|
||||
|
||||
// Calculate previous run links count for each section
|
||||
const previousRunLinksCounts = useMemo(() => {
|
||||
if (!allSections) { return {}; }
|
||||
|
||||
const linksCountMap = {};
|
||||
allSections.forEach(section => {
|
||||
let sectionTotal = 0;
|
||||
|
||||
(section.subsections || []).forEach(subsection => {
|
||||
(subsection.units || []).forEach(unit => {
|
||||
(unit.blocks || []).forEach(block => {
|
||||
sectionTotal += block.previousRunLinks ? block.previousRunLinks.length : 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
linksCountMap[section.id] = sectionTotal;
|
||||
});
|
||||
|
||||
return linksCountMap;
|
||||
}, [allSections]);
|
||||
|
||||
const activeFilters = Object.keys(filters).filter(key => filters[key]);
|
||||
const [filterBy, {
|
||||
@@ -63,27 +178,663 @@ const ScanResults: FC<Props> = ({ data }) => {
|
||||
}] = useCheckboxSetValues(activeFilters);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenStates(data?.sections ? data.sections.map(() => false) : []);
|
||||
}, [data?.sections]);
|
||||
if (!data?.sections) {
|
||||
return <InfoCard text={intl.formatMessage(messages.noBrokenLinksCard)} />;
|
||||
setOpenStates(allSections ? allSections.map(() => false) : []);
|
||||
setPrevRunOpenStates(allSections ? allSections.map(() => false) : []);
|
||||
}, [allSections]);
|
||||
|
||||
// Reset update all completion state when data changes (new scan results)
|
||||
useEffect(() => {
|
||||
setUpdateAllCompleted(false);
|
||||
}, [data]);
|
||||
|
||||
const processUpdateResults = useCallback((response: any, isBulkUpdate = false) => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'Succeeded' && (isBulkUpdate || (response.results && response.results.length > 4))) {
|
||||
const successfulLinkIds: string[] = [];
|
||||
const newMap: Record<string, string> = {};
|
||||
|
||||
const typeToSection: Record<string, string> = {
|
||||
course_updates: 'course-updates',
|
||||
custom_pages: 'custom-pages',
|
||||
};
|
||||
|
||||
const blocksWithResults = new Set<string>();
|
||||
|
||||
const addBlocksWithPrevLinks = (sectionId: string) => {
|
||||
const section = allSections.find(s => s.id === sectionId);
|
||||
if (!section) { return; }
|
||||
section.subsections.forEach(sub => sub.units.forEach(unit => unit.blocks.forEach(b => {
|
||||
if (b.previousRunLinks?.length) { blocksWithResults.add(b.id); }
|
||||
})));
|
||||
};
|
||||
|
||||
if (Array.isArray(response.results)) {
|
||||
response.results.forEach((result) => {
|
||||
const sectionId = typeToSection[result.type];
|
||||
if (sectionId) {
|
||||
addBlocksWithPrevLinks(sectionId);
|
||||
} else if (result.id) {
|
||||
blocksWithResults.add(result.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const allBlocksMap = new Map();
|
||||
allSections.forEach(section => {
|
||||
section.subsections.forEach(subsection => {
|
||||
subsection.units.forEach(unit => {
|
||||
unit.blocks.forEach(block => {
|
||||
if (block.previousRunLinks && block.previousRunLinks.length > 0) {
|
||||
allBlocksMap.set(block.id, {
|
||||
block,
|
||||
sectionId: section.id,
|
||||
previousRunLinks: block.previousRunLinks,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const blockIdMapping = new Map();
|
||||
|
||||
if (response.results && Array.isArray(response.results)) {
|
||||
response.results.forEach(result => {
|
||||
const apiBlockId = result.id;
|
||||
const contentType = result.type;
|
||||
|
||||
if (allBlocksMap.has(apiBlockId)) {
|
||||
blockIdMapping.set(apiBlockId, apiBlockId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentType === 'course_updates' || contentType === 'custom_pages') {
|
||||
const expectedSectionId = contentType === 'course_updates' ? 'course-updates' : 'custom-pages';
|
||||
|
||||
allSections.forEach(section => {
|
||||
if (section.id === expectedSectionId) {
|
||||
section.subsections.forEach(subsection => {
|
||||
subsection.units.forEach(unit => {
|
||||
unit.blocks.forEach(block => {
|
||||
if (
|
||||
block.previousRunLinks
|
||||
&& block.previousRunLinks.length > 0
|
||||
&& !blockIdMapping.has(apiBlockId)
|
||||
) {
|
||||
blockIdMapping.set(apiBlockId, block.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (response.results && Array.isArray(response.results)) {
|
||||
response.results.forEach((result) => {
|
||||
const apiBlockId = result.id;
|
||||
const uiBlockId = blockIdMapping.get(apiBlockId) || apiBlockId;
|
||||
const blockData = allBlocksMap.get(uiBlockId);
|
||||
|
||||
if (blockData) {
|
||||
const originalUrl = result.original_url || result.originalUrl;
|
||||
const newUrl = result.new_url || result.newUrl;
|
||||
|
||||
if (result.success && newUrl && originalUrl) {
|
||||
const matchingLink = blockData.previousRunLinks.find(
|
||||
({ originalLink }) => {
|
||||
const matches = originalLink === originalUrl;
|
||||
return matches;
|
||||
},
|
||||
);
|
||||
|
||||
if (matchingLink) {
|
||||
const uid = `${uiBlockId}:${matchingLink.originalLink}`;
|
||||
successfulLinkIds.push(uid);
|
||||
newMap[uid] = newUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setUpdatedLinkIds(currentIds => {
|
||||
const preservedIds: string[] = [];
|
||||
const newSuccessfulSet = new Set(successfulLinkIds);
|
||||
|
||||
currentIds.forEach(existingId => {
|
||||
if (newSuccessfulSet.has(existingId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colonIndex = existingId.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const blockId = existingId.substring(0, colonIndex);
|
||||
|
||||
if (!blocksWithResults.has(blockId)) {
|
||||
preservedIds.push(existingId);
|
||||
} else {
|
||||
preservedIds.push(existingId);
|
||||
}
|
||||
} else {
|
||||
preservedIds.push(existingId);
|
||||
}
|
||||
});
|
||||
|
||||
const result = [...successfulLinkIds, ...preservedIds];
|
||||
return result;
|
||||
});
|
||||
|
||||
setUpdatedLinkMap(currentMap => {
|
||||
const preservedMap: Record<string, string> = {};
|
||||
const newSuccessfulSet = new Set(successfulLinkIds);
|
||||
|
||||
Object.keys(currentMap).forEach(existingId => {
|
||||
if (newSuccessfulSet.has(existingId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const colonIndex = existingId.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const blockId = existingId.substring(0, colonIndex);
|
||||
|
||||
if (!blocksWithResults.has(blockId)) {
|
||||
preservedMap[existingId] = currentMap[existingId];
|
||||
} else {
|
||||
preservedMap[existingId] = currentMap[existingId];
|
||||
}
|
||||
} else {
|
||||
preservedMap[existingId] = currentMap[existingId];
|
||||
}
|
||||
});
|
||||
|
||||
const result = { ...preservedMap, ...newMap };
|
||||
return result;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.results && Array.isArray(response.results)) {
|
||||
const successfulResults = response.results.filter((r: any) => r.success);
|
||||
if (successfulResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const successfulLinkIds: string[] = [];
|
||||
const newMap: Record<string, string> = {};
|
||||
|
||||
allSections.forEach(section => {
|
||||
section.subsections.forEach(subsection => {
|
||||
subsection.units.forEach(unit => {
|
||||
unit.blocks.forEach(block => {
|
||||
if (block.previousRunLinks) {
|
||||
block.previousRunLinks.forEach(({ originalLink }) => {
|
||||
const uid = `${block.id}:${originalLink}`;
|
||||
|
||||
const exactMatch = successfulResults.find(result => {
|
||||
const originalUrl = result.original_url || result.originalUrl;
|
||||
return result.id === block.id && originalUrl === originalLink;
|
||||
});
|
||||
|
||||
if (exactMatch && (exactMatch.newUrl || exactMatch.new_url)) {
|
||||
successfulLinkIds.push(uid);
|
||||
newMap[uid] = exactMatch.newUrl || exactMatch.new_url;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
setUpdatedLinkIds(prev => {
|
||||
const combined = [...prev, ...successfulLinkIds];
|
||||
const deduped = combined.filter((item, index) => combined.indexOf(item) === index);
|
||||
|
||||
return deduped;
|
||||
});
|
||||
if (Object.keys(newMap).length > 0) {
|
||||
setUpdatedLinkMap(prev => {
|
||||
const updated = { ...prev, ...newMap };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [allSections]);
|
||||
|
||||
// Process update results during polling when status is 'Succeeded' or results are present
|
||||
useEffect(() => {
|
||||
if (
|
||||
rerunLinkUpdateResult
|
||||
&& (rerunLinkUpdateResult.status === 'Succeeded'
|
||||
|| (rerunLinkUpdateResult.results && rerunLinkUpdateResult.results.length > 0))
|
||||
) {
|
||||
const allResultIds = rerunLinkUpdateResult.results?.map(r => r.id).sort().join(',') || '';
|
||||
const responseId = `${rerunLinkUpdateResult.status}-${rerunLinkUpdateResult.results?.length}-${allResultIds}-${isUpdateAllInProgress}`;
|
||||
|
||||
if (processedResponseIds.has(responseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessedResponseIds(prev => new Set([...prev, responseId]));
|
||||
processUpdateResults(rerunLinkUpdateResult, isUpdateAllInProgress);
|
||||
|
||||
// Handle completion for "Update All" operation (check for success status as indicator)
|
||||
if (rerunLinkUpdateResult.status === 'Succeeded' && isUpdateAllInProgress) {
|
||||
const failedCount = rerunLinkUpdateResult.results
|
||||
? rerunLinkUpdateResult.results.filter((r: any) => !r.success).length
|
||||
: 0;
|
||||
|
||||
setIsUpdateAllInProgress(false);
|
||||
setUpdateAllCompleted(failedCount === 0);
|
||||
setUpdateAllTrigger(t => t + 1);
|
||||
|
||||
if (failedCount > 0) {
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinksError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (onErrorStateChange) {
|
||||
onErrorStateChange(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [rerunLinkUpdateResult,
|
||||
rerunLinkUpdateInProgress,
|
||||
isUpdateAllInProgress,
|
||||
intl,
|
||||
onErrorStateChange,
|
||||
processUpdateResults,
|
||||
processedResponseIds,
|
||||
]);
|
||||
|
||||
// Handle completion of rerun link updates when polling stops
|
||||
useEffect(() => {
|
||||
const handleUpdateCompletion = async () => {
|
||||
if (rerunLinkUpdateInProgress === false && isUpdateAllInProgress) {
|
||||
try {
|
||||
const updateStatusResponse = await dispatch(fetchRerunLinkUpdateStatus(courseId)) as any;
|
||||
|
||||
if (!updateStatusResponse) {
|
||||
setIsUpdateAllInProgress(false);
|
||||
setUpdateAllCompleted(false);
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinksError));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
processUpdateResults(updateStatusResponse, true);
|
||||
let failedCount = 0;
|
||||
|
||||
if (updateStatusResponse.results) {
|
||||
failedCount = updateStatusResponse.results.filter((r: any) => !r.success).length;
|
||||
} else if (updateStatusResponse.status === 'Succeeded') {
|
||||
failedCount = 0;
|
||||
} else {
|
||||
failedCount = 1;
|
||||
}
|
||||
|
||||
setIsUpdateAllInProgress(false);
|
||||
setUpdateAllCompleted(failedCount === 0);
|
||||
setUpdateAllTrigger(t => t + 1);
|
||||
|
||||
if (failedCount > 0) {
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinksError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else if (onErrorStateChange) {
|
||||
onErrorStateChange(null);
|
||||
}
|
||||
} catch (error) {
|
||||
setIsUpdateAllInProgress(false);
|
||||
setUpdateAllCompleted(false);
|
||||
setUpdateAllTrigger(t => t + 1);
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinksError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleUpdateCompletion();
|
||||
}, [rerunLinkUpdateInProgress,
|
||||
isUpdateAllInProgress,
|
||||
dispatch,
|
||||
courseId,
|
||||
allSections,
|
||||
intl,
|
||||
onErrorStateChange,
|
||||
processUpdateResults,
|
||||
]);
|
||||
|
||||
const getContentType = useCallback((sectionId: string): string => {
|
||||
if (sectionId === 'course-updates') { return 'course_updates'; }
|
||||
if (sectionId === 'custom-pages') { return 'custom_pages'; }
|
||||
return 'course_content';
|
||||
}, []);
|
||||
|
||||
// Get update all button state
|
||||
const getUpdateAllButtonState = () => {
|
||||
if (rerunLinkUpdateInProgress || isUpdateAllInProgress) {
|
||||
return STATEFUL_BUTTON_STATES.pending;
|
||||
}
|
||||
return STATEFUL_BUTTON_STATES.default;
|
||||
};
|
||||
|
||||
// Disable the button if all links have been successfully updated or if polling is in progress
|
||||
const areAllLinksUpdated = useMemo(() => {
|
||||
if (!hasPreviousRunLinks) { return false; }
|
||||
if (rerunLinkUpdateInProgress || isUpdateAllInProgress) { return true; }
|
||||
|
||||
const checkBlockUpdated = (block) => {
|
||||
const noPreviousLinks = !block.previousRunLinks?.length;
|
||||
const allUpdated = block.previousRunLinks?.every(({ isUpdated }) => isUpdated) ?? true;
|
||||
return noPreviousLinks || allUpdated;
|
||||
};
|
||||
|
||||
const checkUnitUpdated = (unit) => unit.blocks.every(checkBlockUpdated);
|
||||
const checkSubsectionUpdated = (subsection) => subsection.units.every(checkUnitUpdated);
|
||||
const checkSectionUpdated = (section) => section.subsections.every(checkSubsectionUpdated);
|
||||
|
||||
const allLinksUpdatedInAPI = allSections.every(checkSectionUpdated);
|
||||
|
||||
if (allLinksUpdatedInAPI) { return true; }
|
||||
|
||||
const allPreviousRunLinks: { linkId: string; isUpdatedInAPI: boolean }[] = [];
|
||||
allSections.forEach(section => {
|
||||
section.subsections.forEach(subsection => {
|
||||
subsection.units.forEach(unit => {
|
||||
unit.blocks.forEach(block => {
|
||||
if (block.previousRunLinks) {
|
||||
block.previousRunLinks.forEach(({ originalLink, isUpdated }) => {
|
||||
const linkId = `${block.id}:${originalLink}`;
|
||||
allPreviousRunLinks.push({
|
||||
linkId,
|
||||
isUpdatedInAPI: isUpdated || false,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (allPreviousRunLinks.length === 0) { return false; }
|
||||
|
||||
const allUpdated = allPreviousRunLinks.every(({ linkId, isUpdatedInAPI }) => isUpdatedInAPI
|
||||
|| updatedLinkIds.includes(linkId));
|
||||
|
||||
return allUpdated;
|
||||
}, [allSections,
|
||||
hasPreviousRunLinks,
|
||||
updatedLinkIds,
|
||||
updateAllTrigger,
|
||||
rerunLinkUpdateInProgress,
|
||||
isUpdateAllInProgress,
|
||||
]);
|
||||
|
||||
// Handler for updating a single previous run link
|
||||
const handleUpdateLink = useCallback(async (link: string, blockId: string, sectionId?: string): Promise<boolean> => {
|
||||
const uniqueId = `${blockId}:${link}`;
|
||||
|
||||
try {
|
||||
setUpdatingLinkIds(prev => ({ ...prev, [uniqueId]: true }));
|
||||
const contentType = getContentType(sectionId || '');
|
||||
await dispatch(updateSinglePreviousRunLink(courseId, link, blockId, contentType));
|
||||
|
||||
const pollForSingleLinkResult = async (attempts = 0): Promise<boolean> => {
|
||||
if (attempts > 30) { // Max 30 attempts (60 seconds)
|
||||
throw new Error('Timeout waiting for link update result');
|
||||
}
|
||||
|
||||
const updateStatusResponse = await dispatch(fetchRerunLinkUpdateStatus(courseId)) as any;
|
||||
const pollStatus = updateStatusResponse?.status || updateStatusResponse?.updateStatus;
|
||||
|
||||
if (!updateStatusResponse || RERUN_LINK_UPDATE_IN_PROGRESS_STATUSES.includes(pollStatus)) {
|
||||
await new Promise(resolve => {
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
return pollForSingleLinkResult(attempts + 1);
|
||||
}
|
||||
|
||||
if (updateStatusResponse && updateStatusResponse.results && updateStatusResponse.results.length > 0) {
|
||||
const hasOriginalUrlField = updateStatusResponse.results.some(r => r.original_url !== undefined);
|
||||
|
||||
let exactMatch;
|
||||
if (hasOriginalUrlField) {
|
||||
exactMatch = updateStatusResponse.results.find(
|
||||
(result: any) => {
|
||||
const matches = result.id === blockId
|
||||
&& result.original_url === link
|
||||
&& result.success === true;
|
||||
|
||||
return matches;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
exactMatch = updateStatusResponse.results.find(
|
||||
(result: any) => {
|
||||
const matches = result.id === blockId && result.success === true;
|
||||
return matches;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (exactMatch) {
|
||||
const newUrl = exactMatch.new_url || exactMatch.newUrl || exactMatch.url;
|
||||
|
||||
if (newUrl) {
|
||||
setUpdatedLinkMap(prev => {
|
||||
const newMap = { ...prev, [uniqueId]: newUrl };
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setUpdatedLinkIds(prev => {
|
||||
const filtered = prev.filter(id => id !== uniqueId);
|
||||
const newIds = [...filtered, uniqueId];
|
||||
return newIds;
|
||||
});
|
||||
|
||||
setUpdatingLinkIds(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[uniqueId];
|
||||
return copy;
|
||||
});
|
||||
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(null);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const failed = updateStatusResponse.results.find(
|
||||
(result: any) => {
|
||||
if (hasOriginalUrlField) {
|
||||
return result.id === blockId
|
||||
&& result.original_url === link
|
||||
&& result.success === false;
|
||||
}
|
||||
return result.id === blockId && result.success === false;
|
||||
},
|
||||
);
|
||||
|
||||
if (failed) {
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinkError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
setUpdatingLinkIds(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[uniqueId];
|
||||
return copy;
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If status is 'Succeeded' but no results for this specific link, consider it failed
|
||||
if (pollStatus === 'Succeeded') {
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinkError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
setUpdatingLinkIds(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[uniqueId];
|
||||
return copy;
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinkError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
setUpdatingLinkIds(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[uniqueId];
|
||||
return copy;
|
||||
});
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return await pollForSingleLinkResult();
|
||||
} catch (error) {
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinkError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
setUpdatingLinkIds(prev => {
|
||||
const copy = { ...prev };
|
||||
delete copy[uniqueId];
|
||||
return copy;
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [dispatch, courseId, getContentType, intl, onErrorStateChange]);
|
||||
|
||||
// When updatedLinkIds changes (links marked updated), clear any updating flags for those ids
|
||||
useEffect(() => {
|
||||
if (!updatedLinkIds || updatedLinkIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
setUpdatingLinkIds(prev => {
|
||||
const copy = { ...prev };
|
||||
|
||||
updatedLinkIds.forEach(id => {
|
||||
if (copy[id]) {
|
||||
delete copy[id];
|
||||
}
|
||||
});
|
||||
|
||||
return copy;
|
||||
});
|
||||
}, [updatedLinkIds]);
|
||||
|
||||
const handleUpdateAllCourseLinks = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
setProcessedResponseIds(new Set());
|
||||
setIsUpdateAllInProgress(true);
|
||||
await dispatch(updateAllPreviousRunLinks(courseId));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
setIsUpdateAllInProgress(false); // Reset on error
|
||||
if (onErrorStateChange) {
|
||||
onErrorStateChange(intl.formatMessage(messages.updateLinksError));
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
return false;
|
||||
}
|
||||
}, [dispatch, courseId, intl, onErrorStateChange]);
|
||||
|
||||
if (!data || isDataEmpty(data)) {
|
||||
return (
|
||||
<>
|
||||
<div className="scan-results">
|
||||
<div className="scan-header-second-title-container px-3">
|
||||
<header className="sub-header-content">
|
||||
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.brokenLinksHeader)}</h2>
|
||||
</header>
|
||||
</div>
|
||||
<div className="no-results-found-container">
|
||||
<h3 className="no-results-found">{intl.formatMessage(messages.noResultsFound)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{waffleFlags.enableCourseOptimizerCheckPrevRunLinks && (
|
||||
<div className="scan-results">
|
||||
<div className="scan-header-second-title-container px-3">
|
||||
<header className="sub-header-content">
|
||||
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.linkToPrevCourseRun)}</h2>
|
||||
</header>
|
||||
</div>
|
||||
<div className="no-results-found-container">
|
||||
<h3 className="no-results-found">{intl.formatMessage(messages.noResultsFound)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { sections } = data;
|
||||
const handleToggle = (index: number) => {
|
||||
setOpenStates(prev => prev.map((isOpened, i) => (i === index ? !isOpened : isOpened)));
|
||||
};
|
||||
const handlePrevRunToggle = (index: number) => {
|
||||
setPrevRunOpenStates(prev => prev.map((isOpened, i) => (i === index ? !isOpened : isOpened)));
|
||||
};
|
||||
const filterOptions = [
|
||||
{ name: intl.formatMessage(messages.brokenLabel), value: 'brokenLinks' },
|
||||
{ name: intl.formatMessage(messages.manualLabel), value: 'externalForbiddenLinks' },
|
||||
{ name: intl.formatMessage(messages.lockedLabel), value: 'lockedLinks' },
|
||||
];
|
||||
const shouldSectionRender = (sectionIndex: number): boolean => (
|
||||
(!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
|| (filters.brokenLinks && brokenLinksCounts[sectionIndex] > 0)
|
||||
|| (filters.externalForbiddenLinks && externalForbiddenLinksCounts[sectionIndex] > 0)
|
||||
|| (filters.lockedLinks && lockedLinksCounts[sectionIndex] > 0)
|
||||
);
|
||||
|
||||
// Only show sections that have at least one unit with a visible link (not just previousRunLinks)
|
||||
const shouldSectionRender = (sectionIndex: number): boolean => {
|
||||
const section = allSections[sectionIndex];
|
||||
const hasVisibleUnit = section.subsections.some(
|
||||
(subsection) => subsection.units.some((unit) => unit.blocks.some((block) => {
|
||||
const hasBroken = block.brokenLinks?.length > 0;
|
||||
const hasLocked = block.lockedLinks?.length > 0;
|
||||
const hasExternal = block.externalForbiddenLinks?.length > 0;
|
||||
|
||||
const noFilters = !filters.brokenLinks
|
||||
&& !filters.lockedLinks
|
||||
&& !filters.externalForbiddenLinks;
|
||||
|
||||
const showBroken = filters.brokenLinks && hasBroken;
|
||||
const showLocked = filters.lockedLinks && hasLocked;
|
||||
const showExternal = filters.externalForbiddenLinks && hasExternal;
|
||||
|
||||
return (
|
||||
showBroken
|
||||
|| showLocked
|
||||
|| showExternal
|
||||
|| (noFilters && (hasBroken || hasLocked || hasExternal))
|
||||
);
|
||||
})),
|
||||
);
|
||||
return hasVisibleUnit;
|
||||
};
|
||||
|
||||
const findPreviousVisibleSection = (currentIndex: number): number => {
|
||||
let prevIndex = currentIndex - 1;
|
||||
@@ -98,7 +849,7 @@ const ScanResults: FC<Props> = ({ data }) => {
|
||||
|
||||
const findNextVisibleSection = (currentIndex: number): number => {
|
||||
let nextIndex = currentIndex + 1;
|
||||
while (nextIndex < sections.length) {
|
||||
while (nextIndex < allSections.length) {
|
||||
if (shouldSectionRender(nextIndex)) {
|
||||
return nextIndex;
|
||||
}
|
||||
@@ -108,42 +859,41 @@ const ScanResults: FC<Props> = ({ data }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scan-results">
|
||||
<div className="scan-header-title-container">
|
||||
<h2 className="scan-header-title">{intl.formatMessage(messages.scanHeader)}</h2>
|
||||
</div>
|
||||
<div className="scan-header-second-title-container">
|
||||
<header className="sub-header-content">
|
||||
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.brokenLinksHeader)}</h2>
|
||||
<Button
|
||||
ref={setButtonRef}
|
||||
variant="outline-primary"
|
||||
onClick={open}
|
||||
disabled={false}
|
||||
iconAfter={ArrowDropDown}
|
||||
className="justify-content-between"
|
||||
>
|
||||
{intl.formatMessage(messages.filterButtonLabel)}
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
<FilterModal
|
||||
isOpen={isOpen}
|
||||
<>
|
||||
<div className="scan-results">
|
||||
<div className="scan-header-second-title-container px-3">
|
||||
<header className="sub-header-content">
|
||||
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.brokenLinksHeader)}</h2>
|
||||
<Button
|
||||
ref={setButtonRef}
|
||||
variant="link"
|
||||
onClick={open}
|
||||
disabled={false}
|
||||
iconAfter={ArrowDropDown}
|
||||
className="border-0 bg-transparent"
|
||||
style={{ color: '#454545' }}
|
||||
>
|
||||
{intl.formatMessage(messages.filterButtonLabel)}
|
||||
</Button>
|
||||
</header>
|
||||
</div>
|
||||
<FilterModal
|
||||
isOpen={isOpen}
|
||||
// ignoring below line because filter modal doesn't have close button
|
||||
// istanbul ignore next
|
||||
onClose={close}
|
||||
onApply={setFilters}
|
||||
positionRef={buttonRef}
|
||||
filterOptions={filterOptions}
|
||||
initialFilters={filters}
|
||||
activeFilters={activeFilters}
|
||||
filterBy={filterBy}
|
||||
add={add}
|
||||
remove={remove}
|
||||
set={set}
|
||||
/>
|
||||
{activeFilters.length > 0 && <div className="border-bottom border-light-400" />}
|
||||
{activeFilters.length > 0 && (
|
||||
onClose={close}
|
||||
onApply={setFilters}
|
||||
positionRef={buttonRef}
|
||||
filterOptions={filterOptions}
|
||||
initialFilters={filters}
|
||||
activeFilters={activeFilters}
|
||||
filterBy={filterBy}
|
||||
add={add}
|
||||
remove={remove}
|
||||
set={set}
|
||||
/>
|
||||
{activeFilters.length > 0 && <div className="border-bottom border-light-400" />}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="scan-results-active-filters-container">
|
||||
<span className="scan-results-active-filters-chips">
|
||||
{activeFilters.map(filter => (
|
||||
@@ -159,7 +909,10 @@ const ScanResults: FC<Props> = ({ data }) => {
|
||||
setFilters(updatedFilters);
|
||||
}}
|
||||
>
|
||||
{filterOptions.find(option => option.value === filter)?.name}
|
||||
{(() => {
|
||||
const foundOption = filterOptions.filter(option => option.value === filter)[0];
|
||||
return foundOption ? foundOption.name : filter;
|
||||
})()}
|
||||
</Chip>
|
||||
))}
|
||||
</span>
|
||||
@@ -174,62 +927,191 @@ const ScanResults: FC<Props> = ({ data }) => {
|
||||
{intl.formatMessage(messages.clearFilters)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{sections?.map((section, index) => {
|
||||
if (!shouldSectionRender(index)) {
|
||||
{(() => {
|
||||
// Find all visible sections
|
||||
const visibleSections = allSections && allSections.length > 0
|
||||
? allSections
|
||||
.map((_, index) => (shouldSectionRender(index) ? index : -1))
|
||||
.filter(idx => idx !== -1)
|
||||
: [];
|
||||
if (visibleSections.length === 0) {
|
||||
return (
|
||||
<div className="no-results-found-container">
|
||||
<h3 className="no-results-found">{intl.formatMessage(messages.noResultsFound)}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return allSections.map((section, index) => {
|
||||
if (!shouldSectionRender(index)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SectionCollapsible
|
||||
index={index}
|
||||
handleToggle={handleToggle}
|
||||
isOpen={openStates[index]}
|
||||
hasPrevAndIsOpen={index > 0 ? (() => {
|
||||
const prevVisibleIndex = findPreviousVisibleSection(index);
|
||||
return prevVisibleIndex >= 0 && openStates[prevVisibleIndex];
|
||||
})() : true}
|
||||
hasNextAndIsOpen={index < allSections.length - 1 ? (() => {
|
||||
const nextVisibleIndex = findNextVisibleSection(index);
|
||||
return nextVisibleIndex >= 1 && openStates[nextVisibleIndex];
|
||||
})() : true}
|
||||
key={section.id}
|
||||
title={section.displayName}
|
||||
brokenNumber={brokenLinksCounts[index]}
|
||||
manualNumber={externalForbiddenLinksCounts[index]}
|
||||
lockedNumber={lockedLinksCounts[index]}
|
||||
className="section-collapsible-header"
|
||||
>
|
||||
{section.subsections.map((subsection) => (
|
||||
<>
|
||||
{subsection.units.map((unit) => {
|
||||
// Determine if any block in this unit should be shown based on filters
|
||||
const hasVisibleBlock = unit.blocks.some((block) => {
|
||||
const hasBroken = block.brokenLinks?.length > 0;
|
||||
const hasLocked = block.lockedLinks?.length > 0;
|
||||
const hasExternal = block.externalForbiddenLinks?.length > 0;
|
||||
|
||||
const showBroken = filters.brokenLinks && hasBroken;
|
||||
const showLocked = filters.lockedLinks && hasLocked;
|
||||
const showExternal = filters.externalForbiddenLinks && hasExternal;
|
||||
|
||||
const noFilters = !filters.brokenLinks
|
||||
&& !filters.lockedLinks
|
||||
&& !filters.externalForbiddenLinks;
|
||||
|
||||
return showBroken
|
||||
|| showLocked
|
||||
|| showExternal
|
||||
|| (noFilters && (hasBroken || hasLocked || hasExternal));
|
||||
});
|
||||
|
||||
if (hasVisibleBlock) {
|
||||
return (
|
||||
<div className="unit" key={unit.id}>
|
||||
<BrokenLinkTable unit={unit} filters={filters} updatedLinks={[]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</SectionCollapsible>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{waffleFlags.enableCourseOptimizerCheckPrevRunLinks
|
||||
&& allSections
|
||||
&& allSections.length > 0
|
||||
&& hasPreviousRunLinks && (() => {
|
||||
// Filter out sections/subsections/units that have no previous run links
|
||||
const filteredSections = allSections.map((section) => {
|
||||
// Filter subsections
|
||||
const filteredSubsections = section.subsections.map(subsection => {
|
||||
// Filter units
|
||||
const filteredUnits = subsection.units.filter(unit => unit.blocks.some(block => {
|
||||
const hasPreviousLinks = block.previousRunLinks?.length > 0;
|
||||
return hasPreviousLinks;
|
||||
}));
|
||||
return {
|
||||
...subsection,
|
||||
units: filteredUnits,
|
||||
};
|
||||
}).filter(subsection => subsection.units.length > 0);
|
||||
return {
|
||||
...section,
|
||||
subsections: filteredSubsections,
|
||||
};
|
||||
}).filter(section => section.subsections.length > 0);
|
||||
|
||||
if (filteredSections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
hasSectionsRendered = true;
|
||||
|
||||
return (
|
||||
<SectionCollapsible
|
||||
index={index}
|
||||
handleToggle={handleToggle}
|
||||
isOpen={openStates[index]}
|
||||
hasPrevAndIsOpen={index > 0 ? (() => {
|
||||
const prevVisibleIndex = findPreviousVisibleSection(index);
|
||||
return prevVisibleIndex >= 0 && openStates[prevVisibleIndex];
|
||||
})() : true}
|
||||
hasNextAndIsOpen={index < sections.length - 1 ? (() => {
|
||||
const nextVisibleIndex = findNextVisibleSection(index);
|
||||
return nextVisibleIndex >= 1 && openStates[nextVisibleIndex];
|
||||
})() : true}
|
||||
key={section.id}
|
||||
title={section.displayName}
|
||||
brokenNumber={brokenLinksCounts[index]}
|
||||
manualNumber={externalForbiddenLinksCounts[index]}
|
||||
lockedNumber={lockedLinksCounts[index]}
|
||||
className="section-collapsible-header"
|
||||
>
|
||||
{section.subsections.map((subsection) => (
|
||||
<>
|
||||
{subsection.units.map((unit) => {
|
||||
if (
|
||||
(!filters.brokenLinks && !filters.externalForbiddenLinks && !filters.lockedLinks)
|
||||
|| (filters.brokenLinks && unit.blocks.some(block => block.brokenLinks.length > 0))
|
||||
|| (filters.externalForbiddenLinks
|
||||
&& unit.blocks.some(block => block.externalForbiddenLinks.length > 0))
|
||||
|| (filters.lockedLinks && unit.blocks.some(block => block.lockedLinks.length > 0))
|
||||
) {
|
||||
return (
|
||||
<div className="scan-results">
|
||||
<div className="scan-header-second-title-container px-3">
|
||||
<header className="sub-header-content d-flex justify-content-between align-items-center">
|
||||
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.linkToPrevCourseRun)}</h2>
|
||||
<StatefulButton
|
||||
className="px-4 rounded-0 update-all-course-btn"
|
||||
labels={{
|
||||
default: 'Update all',
|
||||
pending: 'Update all',
|
||||
}}
|
||||
icons={{
|
||||
default: '',
|
||||
pending: <Spinner
|
||||
animation="border"
|
||||
size="sm"
|
||||
className="mr-2 spinner-icon"
|
||||
/>,
|
||||
}}
|
||||
state={getUpdateAllButtonState()}
|
||||
onClick={handleUpdateAllCourseLinks}
|
||||
disabled={areAllLinksUpdated}
|
||||
disabledStates={['pending']}
|
||||
variant="primary"
|
||||
data-testid="update-all-course"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
{filteredSections.map((section, index) => (
|
||||
<SectionCollapsible
|
||||
index={index}
|
||||
handleToggle={handlePrevRunToggle}
|
||||
isOpen={prevRunOpenStates[index]}
|
||||
hasPrevAndIsOpen={index > 0 ? prevRunOpenStates[index - 1] : true}
|
||||
hasNextAndIsOpen={index < filteredSections.length - 1 ? prevRunOpenStates[index + 1] : true}
|
||||
key={section.id}
|
||||
title={section.displayName}
|
||||
previousRunLinksCount={previousRunLinksCounts[section.id] || 0}
|
||||
isPreviousRunLinks
|
||||
className="section-collapsible-header"
|
||||
>
|
||||
{section.subsections.map((subsection) => (
|
||||
<>
|
||||
{subsection.units.map((unit) => (
|
||||
<div className="unit">
|
||||
<BrokenLinkTable unit={unit} filters={filters} />
|
||||
<BrokenLinkTable
|
||||
unit={unit}
|
||||
linkType="previous"
|
||||
onUpdateLink={handleUpdateLink}
|
||||
sectionId={section.id}
|
||||
updatedLinks={updatedLinkIds}
|
||||
updatedLinkMap={updatedLinkMap}
|
||||
updatedLinkInProgress={updatingLinkIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</SectionCollapsible>
|
||||
))}
|
||||
</SectionCollapsible>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasSectionsRendered === false && (
|
||||
})()}
|
||||
|
||||
{waffleFlags.enableCourseOptimizerCheckPrevRunLinks && !hasPreviousRunLinks && (
|
||||
<div className="scan-results">
|
||||
<div className="scan-header-second-title-container px-3">
|
||||
<header className="sub-header-content">
|
||||
<h2 className="broken-links-header-title pt-2">{intl.formatMessage(messages.linkToPrevCourseRun)}</h2>
|
||||
</header>
|
||||
</div>
|
||||
<div className="no-results-found-container">
|
||||
<h3 className="no-results-found">{intl.formatMessage(messages.noResultsFound)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
89
src/optimizer-page/scan-results/SectionCollapsible.test.tsx
Normal file
89
src/optimizer-page/scan-results/SectionCollapsible.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import SectionCollapsible from './SectionCollapsible';
|
||||
|
||||
const intlWrapper = (ui: React.ReactElement) => render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
{ui}
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('SectionCollapsible', () => {
|
||||
const defaultProps = {
|
||||
index: 1,
|
||||
handleToggle: jest.fn(),
|
||||
isOpen: false,
|
||||
hasPrevAndIsOpen: false,
|
||||
hasNextAndIsOpen: false,
|
||||
title: 'Section Title',
|
||||
children: <div>Section Content</div>,
|
||||
className: 'test-class',
|
||||
};
|
||||
|
||||
describe('Regular mode (broken/manual/locked links)', () => {
|
||||
const regularProps = {
|
||||
...defaultProps,
|
||||
brokenNumber: 3,
|
||||
manualNumber: 2,
|
||||
lockedNumber: 1,
|
||||
isPreviousRunLinks: false,
|
||||
};
|
||||
|
||||
it('renders with open state and shows children', () => {
|
||||
intlWrapper(<SectionCollapsible {...regularProps} isOpen />);
|
||||
expect(screen.getByText('Section Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleToggle with index when header is clicked', () => {
|
||||
const handleToggle = jest.fn();
|
||||
intlWrapper(<SectionCollapsible {...regularProps} handleToggle={handleToggle} />);
|
||||
|
||||
const header = screen.getByText('Section Title').closest('.section-collapsible-header-item');
|
||||
if (header) {
|
||||
fireEvent.click(header);
|
||||
} else {
|
||||
fireEvent.click(screen.getByText('Section Title'));
|
||||
}
|
||||
expect(handleToggle).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Previous run links mode', () => {
|
||||
const prevRunProps = {
|
||||
...defaultProps,
|
||||
previousRunLinksCount: 5,
|
||||
isPreviousRunLinks: true,
|
||||
};
|
||||
|
||||
it('renders with previous run links count', () => {
|
||||
intlWrapper(<SectionCollapsible {...prevRunProps} />);
|
||||
expect(screen.getByText('Section Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
// Should not show broken/manual/locked icons in previous run mode
|
||||
expect(screen.queryByText('3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows dash when previousRunLinksCount is 0', () => {
|
||||
intlWrapper(<SectionCollapsible {...prevRunProps} previousRunLinksCount={0} />);
|
||||
expect(screen.getByText('-')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with open state and shows children', () => {
|
||||
intlWrapper(<SectionCollapsible {...prevRunProps} isOpen />);
|
||||
expect(screen.getByText('Section Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleToggle with index when header is clicked', () => {
|
||||
const handleToggle = jest.fn();
|
||||
intlWrapper(<SectionCollapsible {...prevRunProps} handleToggle={handleToggle} />);
|
||||
|
||||
const header = screen.getByText('Section Title').closest('.section-collapsible-header-item');
|
||||
if (header) {
|
||||
fireEvent.click(header);
|
||||
} else {
|
||||
fireEvent.click(screen.getByText('Section Title'));
|
||||
}
|
||||
expect(handleToggle).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,11 @@ interface Props {
|
||||
hasNextAndIsOpen: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
brokenNumber: number;
|
||||
manualNumber: number;
|
||||
lockedNumber: number;
|
||||
brokenNumber?: number;
|
||||
manualNumber?: number;
|
||||
lockedNumber?: number;
|
||||
previousRunLinksCount?: number;
|
||||
isPreviousRunLinks?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -35,9 +37,11 @@ const SectionCollapsible: FC<Props> = ({
|
||||
hasNextAndIsOpen,
|
||||
title,
|
||||
children,
|
||||
brokenNumber,
|
||||
manualNumber,
|
||||
lockedNumber,
|
||||
brokenNumber = 0,
|
||||
manualNumber = 0,
|
||||
lockedNumber = 0,
|
||||
previousRunLinksCount = 0,
|
||||
isPreviousRunLinks = false,
|
||||
className,
|
||||
}) => {
|
||||
const styling = `card-lg open-section-rounded ${hasPrevAndIsOpen ? 'closed-section-rounded-top' : ''} ${hasNextAndIsOpen ? 'closed-section-rounded-bottom' : ''}`;
|
||||
@@ -48,24 +52,32 @@ const SectionCollapsible: FC<Props> = ({
|
||||
<p className="section-title">{title}</p>
|
||||
</div>
|
||||
<div className="section-collapsible-header-actions">
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<CustomIcon icon={LinkOff} message1={messages.brokenLabel} message2={messages.brokenInfoTooltip} />
|
||||
<p>{brokenNumber}</p>
|
||||
</div>
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<CustomIcon icon={ManualIcon} message1={messages.manualLabel} message2={messages.manualInfoTooltip} />
|
||||
<p>{manualNumber}</p>
|
||||
</div>
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<CustomIcon icon={lockedIcon} message1={messages.lockedLabel} message2={messages.lockedInfoTooltip} />
|
||||
<p>{lockedNumber}</p>
|
||||
</div>
|
||||
{isPreviousRunLinks ? (
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<p>{previousRunLinksCount > 0 ? previousRunLinksCount : '-'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<CustomIcon icon={LinkOff} message1={messages.brokenLabel} message2={messages.brokenInfoTooltip} />
|
||||
<p>{brokenNumber}</p>
|
||||
</div>
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<CustomIcon icon={ManualIcon} message1={messages.manualLabel} message2={messages.manualInfoTooltip} />
|
||||
<p>{manualNumber}</p>
|
||||
</div>
|
||||
<div className="section-collapsible-header-action-item">
|
||||
<CustomIcon icon={lockedIcon} message1={messages.lockedLabel} message2={messages.lockedInfoTooltip} />
|
||||
<p>{lockedNumber}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`section ${isOpen ? 'is-open' : ''}`}>
|
||||
<div className={`section px-3 ${isOpen ? 'is-open' : ''}`}>
|
||||
<Collapsible
|
||||
className="section-collapsible-item-container"
|
||||
styling={styling}
|
||||
|
||||
@@ -9,18 +9,14 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-optimizer.noDataCard',
|
||||
defaultMessage: 'No Scan data available',
|
||||
},
|
||||
noBrokenLinksCard: {
|
||||
id: 'course-authoring.course-optimizer.emptyResultsCard',
|
||||
defaultMessage: 'No broken links found',
|
||||
linkToPrevCourseRun: {
|
||||
id: 'course-authoring.course-optimizer.linkToPrevCourseRun',
|
||||
defaultMessage: 'Links to previous course run',
|
||||
},
|
||||
noResultsFound: {
|
||||
id: 'course-authoring.course-optimizer.noResultsFound',
|
||||
defaultMessage: 'No results found',
|
||||
},
|
||||
scanHeader: {
|
||||
id: 'course-authoring.course-optimizer.scanHeader',
|
||||
defaultMessage: 'Scan results',
|
||||
},
|
||||
brokenLinksHeader: {
|
||||
id: 'course-authoring.course-optimizer.brokenLinksHeader',
|
||||
defaultMessage: 'Broken links',
|
||||
@@ -62,6 +58,30 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-optimizer.clearFilters',
|
||||
defaultMessage: 'Clear filters',
|
||||
},
|
||||
customPagesHeader: {
|
||||
id: 'course-authoring.course-optimizer.customPagesHeader',
|
||||
defaultMessage: 'Custom pages',
|
||||
},
|
||||
courseUpdatesHeader: {
|
||||
id: 'course-authoring.course-optimizer.courseUpdatesHeader',
|
||||
defaultMessage: 'Course updates',
|
||||
},
|
||||
updateLinkError: {
|
||||
id: 'course-authoring.course-optimizer.updateLinkError',
|
||||
defaultMessage: 'Link couldn\'t be updated.',
|
||||
},
|
||||
updateLinksError: {
|
||||
id: 'course-authoring.course-optimizer.updateLinksError',
|
||||
defaultMessage: 'Some links couldn\'t be updated.',
|
||||
},
|
||||
updateButton: {
|
||||
id: 'course-authoring.scanResults.updateButton',
|
||||
defaultMessage: 'Update',
|
||||
},
|
||||
updated: {
|
||||
id: 'course-authoring.scanResults.updated',
|
||||
defaultMessage: 'Updated',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Unit {
|
||||
brokenLinks: string[];
|
||||
lockedLinks: string[];
|
||||
externalForbiddenLinks: string[];
|
||||
previousRunLinks: { originalLink: string; isUpdated: boolean; updatedLink?: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -25,6 +26,24 @@ export interface Section {
|
||||
|
||||
export interface LinkCheckResult {
|
||||
sections: Section[];
|
||||
courseUpdates?: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
brokenLinks: string[];
|
||||
lockedLinks: string[];
|
||||
externalForbiddenLinks: string[];
|
||||
previousRunLinks: { originalLink: string; isUpdated: boolean; updatedLink?: string }[];
|
||||
}[];
|
||||
customPages?: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
url: string;
|
||||
brokenLinks: string[];
|
||||
lockedLinks: string[];
|
||||
externalForbiddenLinks: string[];
|
||||
previousRunLinks: { originalLink: string; isUpdated: boolean; updatedLink?: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mockApiResponse } from './mocks/mockApiResponse';
|
||||
import { countBrokenLinks } from './utils';
|
||||
import { countBrokenLinks, isDataEmpty } from './utils';
|
||||
|
||||
describe('countBrokenLinks', () => {
|
||||
it('should return the count of broken links', () => {
|
||||
@@ -66,3 +66,41 @@ describe('countBrokenLinks', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDataEmpty', () => {
|
||||
it('should return true when data is null', () => {
|
||||
expect(isDataEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when courseUpdates contains previousRunLinks', () => {
|
||||
const data = {
|
||||
courseUpdates: [
|
||||
{
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev.link' }],
|
||||
},
|
||||
],
|
||||
sections: [],
|
||||
customPages: [],
|
||||
};
|
||||
expect(isDataEmpty(data)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when customPages contains previousRunLinks', () => {
|
||||
const data = {
|
||||
customPages: [
|
||||
{
|
||||
brokenLinks: [],
|
||||
lockedLinks: [],
|
||||
externalForbiddenLinks: [],
|
||||
previousRunLinks: [{ originalLink: 'https://prev.link' }],
|
||||
},
|
||||
],
|
||||
sections: [],
|
||||
courseUpdates: [],
|
||||
};
|
||||
expect(isDataEmpty(data)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,3 +37,64 @@ export const countBrokenLinks = (
|
||||
});
|
||||
return { brokenLinksCounts, lockedLinksCounts, externalForbiddenLinksCounts };
|
||||
};
|
||||
|
||||
export const isDataEmpty = (data: LinkCheckResult | null): boolean => {
|
||||
if (!data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check sections
|
||||
if (data.sections && data.sections.length > 0) {
|
||||
const hasAnyLinks = data.sections.some(
|
||||
(section) => section.subsections.some(
|
||||
(subsection) => subsection.units.some(
|
||||
(unit) => unit.blocks.some(
|
||||
(block) => {
|
||||
const hasBrokenLinks = block.brokenLinks && block.brokenLinks.length > 0;
|
||||
const hasLockedLinks = block.lockedLinks && block.lockedLinks.length > 0;
|
||||
const hasExternalForbiddenLinks = block.externalForbiddenLinks
|
||||
&& block.externalForbiddenLinks.length > 0;
|
||||
const hasPreviousRunLinks = block.previousRunLinks
|
||||
&& block.previousRunLinks.length > 0;
|
||||
|
||||
return (
|
||||
hasBrokenLinks
|
||||
|| hasLockedLinks
|
||||
|| hasExternalForbiddenLinks
|
||||
|| hasPreviousRunLinks
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (hasAnyLinks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check course updates
|
||||
if (data.courseUpdates && data.courseUpdates.length > 0) {
|
||||
const hasAnyLinks = data.courseUpdates.some((update) => (update.brokenLinks && update.brokenLinks.length > 0)
|
||||
|| (update.lockedLinks && update.lockedLinks.length > 0)
|
||||
|| (update.externalForbiddenLinks && update.externalForbiddenLinks.length > 0)
|
||||
|| (update.previousRunLinks && update.previousRunLinks.length > 0));
|
||||
if (hasAnyLinks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom pages
|
||||
if (data.customPages && data.customPages.length > 0) {
|
||||
const hasAnyLinks = data.customPages.some((page) => (page.brokenLinks && page.brokenLinks.length > 0)
|
||||
|| (page.lockedLinks && page.lockedLinks.length > 0)
|
||||
|| (page.externalForbiddenLinks && page.externalForbiddenLinks.length > 0)
|
||||
|| (page.previousRunLinks && page.previousRunLinks.length > 0));
|
||||
if (hasAnyLinks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user