Skip to content

feat: Enhance Course Optimizer Page with Previous Run Links and Improved UI #2356

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
113 changes: 107 additions & 6 deletions src/optimizer-page/CourseOptimizerPage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,14 @@ import generalMessages from '../messages';
import scanResultsMessages from './scan-results/messages';
import CourseOptimizerPage, { pollLinkCheckDuringScan } 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;
Expand All @@ -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={{}}>
Expand Down Expand Up @@ -155,7 +174,7 @@ 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();
});
});

Expand All @@ -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');
Expand All @@ -205,7 +224,6 @@ 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);
Expand Down Expand Up @@ -317,14 +335,14 @@ 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();
});

// 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);

Expand Down Expand Up @@ -361,5 +379,88 @@ describe('CourseOptimizerPage', () => {
expect(getByText(scanResultsMessages.noResultsFound.defaultMessage)).toBeInTheDocument();
});
});

it('should always show broken links section header even when no data', async () => {
axiosMock.onGet(getLinkCheckStatusApiUrl(courseId)).reply(200, mockApiResponseEmpty);
const { getByText } = render(<OptimizerPage />);

fireEvent.click(getByText(messages.buttonTitle.defaultMessage));

await waitFor(() => {
expect(getByText(scanResultsMessages.brokenLinksHeader.defaultMessage)).toBeInTheDocument();
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);
});
});
});
});
});
69 changes: 36 additions & 33 deletions src/optimizer-page/CourseOptimizerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Badge, Container, Layout, Button, Card,
Badge, Container, Layout, Button, Card, Spinner,
} 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 { RequestFailureStatuses } from '../data/constants';
import messages from './messages';
import {
Expand Down Expand Up @@ -53,7 +52,6 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
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);
Expand Down Expand Up @@ -136,45 +134,50 @@ const CourseOptimizerPage: FC<{ courseId: string }> = ({ courseId }) => {
<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>
<div className="d-flex flex-wrap justify-content-between align-items-center mb-3 px-3 py-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="dark" className="ml-2">{intl.formatMessage(messages.new)}</Badge>
</div>
</div>
<Button
variant="primary"
size="md"
className="px-4 rounded-0 scan-course-btn"
onClick={() => dispatch(startLinkCheck(courseId))}
disabled={(!!linkCheckInProgress) && !errorMessage}
>
{linkCheckInProgress && !errorMessage ? (
<>
<Spinner
animation="border"
size="sm"
className="mr-2"
style={{ width: '1rem', height: '1rem' }}
/>
{intl.formatMessage(messages.buttonTitle)}
</>
) : (
intl.formatMessage(messages.buttonTitle)
)}
</Button>
</div>
<Card style={{ boxShadow: 'none', backgroundColor: 'transparent' }}>
<p className="px-3 py-1 small">{intl.formatMessage(messages.description)}</p>
<hr style={{ margin: '0 20px' }} />
<Card.Header
className="scan-header h3 px-3 text-black mb-2"
title={intl.formatMessage(messages.card1Title)}
title={intl.formatMessage(messages.scanHeader)}
/>
<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>
)}
{showStepper && (
<Card.Section className="px-3 py-1">
<CourseStepper
Expand Down
6 changes: 5 additions & 1 deletion src/optimizer-page/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -57,6 +57,10 @@ 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',
},
});

export default messages;
Loading