feat: course outline header update [FC-0114] (#2823)

Modifies new course outline header and actions
This commit is contained in:
Navin Karkera
2026-01-23 19:38:19 +05:30
committed by GitHub
parent 0db79f3527
commit 89e327b633
14 changed files with 204 additions and 91 deletions

View File

@@ -12,3 +12,7 @@
.border-dashed {
border: dashed;
}
.bg-draft-status {
background-color: #F4B57B;
}

View File

@@ -2498,7 +2498,7 @@ describe('<CourseOutline />', () => {
expect(btn).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument();
expect((await screen.findAllByRole('button', { name: 'Add' })).length).toEqual(2);
expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Course info' })).toBeInTheDocument();
const user = userEvent.setup();
await user.click(btn);
expect(await screen.findByRole('button', { name: 'Expand all' })).toBeInTheDocument();

View File

@@ -33,6 +33,7 @@ const initialState = {
},
videoSharingEnabled: false,
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
hasChanges: false,
},
sectionsList: [],
isCustomRelativeDatesActive: false,

View File

@@ -71,6 +71,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
videoSharingOptions,
actions,
end,
hasChanges,
},
} = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex));
@@ -80,6 +81,7 @@ export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any)
videoSharingOptions,
videoSharingEnabled,
endDate: end,
hasChanges,
}));
dispatch(updateCourseActions(actions));

View File

@@ -7,6 +7,7 @@ export interface CourseStructure {
start: string,
end: string,
actions: XBlockActions,
hasChanges: boolean,
}
// TODO: Create interface for all `Object` fields in courseOutline
@@ -35,19 +36,22 @@ export interface CourseDetails {
description?: string;
}
export interface ChecklistType {
totalCourseLaunchChecks: number;
completedCourseLaunchChecks: number;
totalCourseBestPracticesChecks: number;
completedCourseBestPracticesChecks: number;
}
export interface CourseOutlineStatusBar {
courseReleaseDate: string;
endDate: string;
highlightsEnabledForMessaging: boolean;
isSelfPaced: boolean;
checklist: {
totalCourseLaunchChecks: number;
completedCourseLaunchChecks: number;
totalCourseBestPracticesChecks: number;
completedCourseBestPracticesChecks: number;
};
checklist: ChecklistType;
videoSharingEnabled: boolean;
videoSharingOptions: string;
hasChanges: boolean;
}
export interface CourseOutlineState {

View File

@@ -47,7 +47,6 @@ describe('<HeaderActions />', () => {
expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: messages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: messages.moreActionsButtonAriaLabel.defaultMessage })).toBeInTheDocument();
});
it('calls the correct handlers when clicking buttons', async () => {
@@ -67,17 +66,13 @@ describe('<HeaderActions />', () => {
expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeDisabled();
});
it('should change pages using the dropdown button', async () => {
it('should show course info on click', async () => {
renderComponent();
// Click on the dropdown button
await userEvent.click(screen.getByRole('button', { name: 'More actions' }));
// Select the Help option
const helpButton = screen.getByRole('button', { name: 'Help' });
await userEvent.click(helpButton);
await userEvent.click(screen.getByRole('button', { name: 'Course info' }));
// Check if the current page change is called
expect(setCurrentPageKeyMock).toHaveBeenCalledWith('help');
expect(setCurrentPageKeyMock).toHaveBeenCalledWith('info');
});
});

View File

@@ -1,18 +1,16 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Dropdown, Icon, OverlayTrigger, Stack, Tooltip,
Button, OverlayTrigger, Stack, Tooltip,
} from '@openedx/paragon';
import {
Add as IconAdd, FindInPage, ViewSidebar,
Add as IconAdd, FindInPage, InfoOutline,
} from '@openedx/paragon/icons';
import { OutlinePageErrors, XBlockActions } from '@src/data/types';
import type { SidebarPage } from '@src/generic/sidebar';
import { type OutlineSidebarPageKeys, useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
import messages from './messages';
import { getOutlineSidebarPages } from '../outline-sidebar/sidebarPages';
export interface HeaderActionsProps {
actions: {
@@ -29,12 +27,27 @@ const HeaderActions = ({
}: HeaderActionsProps) => {
const intl = useIntl();
const { lmsLink } = actions;
const sidebarPages = getOutlineSidebarPages();
const { setCurrentPageKey } = useOutlineSidebarContext();
return (
<Stack direction="horizontal" gap={3}>
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id={intl.formatMessage(messages.courseInfoButtonTooltip)}>
{intl.formatMessage(messages.courseInfoButtonTooltip)}
</Tooltip>
)}
>
<Button
iconBefore={InfoOutline}
onClick={() => setCurrentPageKey('info')}
variant="outline-primary"
>
{intl.formatMessage(messages.courseInfoButton)}
</Button>
</OverlayTrigger>
{courseActions.childAddable && (
<OverlayTrigger
placement="bottom"
@@ -71,31 +84,6 @@ const HeaderActions = ({
{intl.formatMessage(messages.viewLiveButton)}
</Button>
</OverlayTrigger>
<Dropdown>
<Dropdown.Toggle
id="dropdown-toggle-with-iconbutton"
as={Button}
variant="outline-primary"
aria-label={intl.formatMessage(messages.moreActionsButtonAriaLabel)}
>
<Icon src={ViewSidebar} />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
{Object.entries(sidebarPages).filter(([, page]) => !page.hideFromActionMenu)
.map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => (
<Dropdown.Item
key={key}
onClick={() => setCurrentPageKey(key)}
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{intl.formatMessage(page.title)}
</Stack>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</Stack>
);
};

View File

@@ -34,14 +34,20 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.header-navigations.button.view-live',
defaultMessage: 'View live',
},
moreActionsButtonAriaLabel: {
id: 'course-authoring.course-outline.header-navigations.button.more-actions.aria-label',
defaultMessage: 'More actions',
description: 'More actions button aria label in course outline',
courseInfoButtonTooltip: {
id: 'course-authoring.course-outline.header-navigations.button.course.info.tooltip',
defaultMessage: 'Click to open course info in sidebar',
description: 'Tooltip text of course info button',
},
courseInfoButton: {
id: 'course-authoring.course-outline.header-navigations.button.course.info',
defaultMessage: 'Course info',
description: 'Course info button in course outline header',
},
viewLiveButtonTooltip: {
id: 'course-authoring.course-outline.header-navigations.button.view-live.tooltip',
defaultMessage: 'Click to open the courseware in the LMS in a new tab',
description: 'Tooltip text of view live button',
},
});

View File

@@ -40,7 +40,6 @@ export const getOutlineSidebarPages = (): OutlineSidebarPages => {
component: AddSidebar,
icon: Plus,
title: messages.sidebarButtonAdd,
hideFromActionMenu: true,
},
} satisfies OutlineSidebarPages;
};

View File

@@ -50,6 +50,7 @@ const statusBarData: CourseOutlineStatusBar = {
highlightsEnabledForMessaging: true,
videoSharingEnabled: true,
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
hasChanges: true,
};
const queryClient = new QueryClient();

View File

@@ -20,8 +20,16 @@ const statusBarData: CourseOutlineStatusBar = {
highlightsEnabledForMessaging: true,
videoSharingEnabled: true,
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
hasChanges: false,
};
jest.mock('@src/course-libraries/data/apiHooks', () => ({
useEntityLinksSummaryByDownstreamContext: () => ({
data: [{ readyToSyncCount: 2 }],
isLoading: false,
}),
}));
const renderComponent = (props?: Partial<StatusBarProps>) => render(
<StatusBar
courseId={courseId}
@@ -73,4 +81,36 @@ describe('<StatusBar />', () => {
expect(await screen.findByTestId('redux-provider')).toBeEmptyDOMElement();
});
it('renders unpublished badge', async () => {
renderComponent({
statusBarData: {
...statusBarData,
hasChanges: true,
},
});
expect(await screen.findByText('Unpublished Changes')).toBeInTheDocument();
});
it('renders library updates', async () => {
renderComponent();
expect(await screen.findByText('2 Library Updates')).toBeInTheDocument();
});
it('hides checklist if completed', async () => {
renderComponent({
statusBarData: {
...statusBarData,
checklist: {
totalCourseLaunchChecks: 5,
completedCourseLaunchChecks: 5,
totalCourseBestPracticesChecks: 4,
completedCourseBestPracticesChecks: 4,
},
},
});
// wait for render
expect(await screen.findByText('Feb 05, 2013 - Apr 09, 2013')).toBeInTheDocument();
expect(screen.queryByText(`9/9 ${messages.checklistCompleted.defaultMessage}`)).toBeNull();
});
});

View File

@@ -1,12 +1,15 @@
import moment, { Moment } from 'moment/moment';
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedDate, FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform/config';
import { Badge, Icon, Stack } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { CourseOutlineStatusBar } from '@src/course-outline/data/types';
import { ChecklistRtl } from '@openedx/paragon/icons';
import type { ChecklistType, CourseOutlineStatusBar } from '@src/course-outline/data/types';
import {
Cached, ChecklistRtl, Description, Event,
} from '@openedx/paragon/icons';
import { useWaffleFlags } from '@src/data/apiHooks';
import { useEntityLinksSummaryByDownstreamContext } from '@src/course-libraries/data/apiHooks';
import messages from './messages';
import { NotificationStatusIcon } from './NotificationStatusIcon';
@@ -23,13 +26,13 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen
);
case now.isBefore(startDate):
return (
<Badge className="px-3 py-2 bg-white text-success-400 border border-success-500" variant="success">
<Badge className="px-3 py-2" variant="info">
<FormattedMessage {...messages.upcomingBadgeText} />
</Badge>
);
case endDate.isValid() && endDate.isBefore(now):
return (
<Badge className="px-3 py-2" variant="light">
<Badge className="px-3 py-2 bg-gray-500" variant="secondary">
<FormattedMessage {...messages.archivedBadgeText} />
</Badge>
);
@@ -39,6 +42,43 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen
}
};
const UnpublishedBadgeStatus = () => (
<Badge
className="px-2 py-2 bg-draft-status text-gray-700 font-weight-normal"
variant="light"
>
<Stack direction="horizontal" gap={2}>
<Icon size="xs" src={Description} />
<FormattedMessage {...messages.unpublishedBadgeText} />
</Stack>
</Badge>
);
const LibraryUpdates = ({ courseId }: { courseId: string }) => {
const { data } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const url = `/course/${courseId}/libraries?tab=review`;
if (!outOfSyncCount || outOfSyncCount === 0) {
return null;
}
return (
<Link
className="small text-gray-700"
to={url}
>
<Stack direction="horizontal" gap={2}>
<Icon size="sm" src={Cached} />
<FormattedMessage
{...messages.libraryUpdatesText}
values={{ count: outOfSyncCount }}
/>
</Stack>
</Link>
);
};
const CourseDates = ({
startDate, endDate, startDateRaw, datesLink,
}: {
@@ -54,7 +94,10 @@ const CourseDates = ({
className="small"
to={datesLink}
>
{startDateRaw}
<Stack direction="horizontal" gap={2}>
<Icon size="sm" className="mb-1" src={Event} />
{startDateRaw}
</Stack>
</Link>
);
}
@@ -64,23 +107,56 @@ const CourseDates = ({
className="small text-gray-700"
to={datesLink}
>
<FormattedDate
value={startDate.toString()}
year="numeric"
month="short"
day="2-digit"
/>
{endDate.isValid() && (
<>
{' - '}
<FormattedDate
value={endDate.toString()}
year="numeric"
month="short"
day="2-digit"
/>
</>
)}
<Stack direction="horizontal" gap={2}>
<Icon size="sm" className="mb-1" src={Event} />
<FormattedDate
value={startDate.toString()}
year="numeric"
month="short"
day="2-digit"
/>
{endDate.isValid() && (
<>
{' - '}
<FormattedDate
value={endDate.toString()}
year="numeric"
month="short"
day="2-digit"
/>
</>
)}
</Stack>
</Link>
);
};
const Checklists = ({ courseId, checklist }: {
courseId: string;
checklist: ChecklistType;
}) => {
const {
completedCourseLaunchChecks,
completedCourseBestPracticesChecks,
totalCourseLaunchChecks,
totalCourseBestPracticesChecks,
} = checklist;
const completed = completedCourseLaunchChecks + completedCourseBestPracticesChecks;
const total = totalCourseLaunchChecks + totalCourseBestPracticesChecks;
if (completed === total) {
return null;
}
const checkListTitle = `${completed}/${total}`;
return (
<Link
className="small text-primary-500 d-flex"
to={`/course/${courseId}/checklists`}
>
<Icon src={ChecklistRtl} size="md" className="mr-2" />
{checkListTitle} <FormattedMessage {...messages.checklistCompleted} />
</Link>
);
};
@@ -96,25 +172,17 @@ export const StatusBar = ({
isLoading,
courseId,
}: StatusBarProps) => {
const intl = useIntl();
const waffleFlags = useWaffleFlags(courseId);
const {
endDate,
courseReleaseDate,
checklist,
hasChanges,
} = statusBarData;
const {
completedCourseLaunchChecks,
completedCourseBestPracticesChecks,
totalCourseLaunchChecks,
totalCourseBestPracticesChecks,
} = checklist;
const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true);
const endDateObj = moment.utc(endDate);
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, getConfig().STUDIO_BASE_URL).href;
if (isLoading) {
@@ -124,20 +192,16 @@ export const StatusBar = ({
return (
<Stack direction="horizontal" gap={4}>
<CourseBadge startDate={courseReleaseDateObj} endDate={endDateObj} />
{hasChanges && <UnpublishedBadgeStatus />}
<CourseDates
startDate={courseReleaseDateObj}
endDate={endDateObj}
startDateRaw={courseReleaseDate}
datesLink={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()}
/>
<Checklists courseId={courseId} checklist={checklist} />
<LibraryUpdates courseId={courseId} />
<NotificationStatusIcon />
<Link
className="small text-primary-500 d-flex"
to={`/course/${courseId}/checklists`}
>
<Icon src={ChecklistRtl} size="md" className="mr-2" />
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
</Link>
</Stack>
);
};

View File

@@ -76,6 +76,11 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.status-bar.video-sharing.allOn.text',
defaultMessage: 'All Videos',
},
unpublishedBadgeText: {
id: 'course-authoring.course-outline.status-bar.unpublished.badge.text',
defaultMessage: 'Unpublished Changes',
description: 'Text in badge displayed in course outline if course has unpublished changes.',
},
activeBadgeText: {
id: 'course-authoring.course-outline.status-bar.active.badge.text',
defaultMessage: 'Active',
@@ -91,6 +96,11 @@ const messages = defineMessages({
defaultMessage: 'Upcoming',
description: 'Upcoming Badge shown in course outline when the course has not started yet.',
},
libraryUpdatesText: {
id: 'course-authoring.course-outline.status-bar.library.updates.text',
defaultMessage: '{count, plural, one {{count} Library Update} other {{count} Library Updates}}',
description: 'Status text displaying count of library updates',
},
});
export default messages;

View File

@@ -19,7 +19,6 @@ export interface SidebarPage {
component: React.ComponentType;
icon: React.ComponentType;
title: MessageDescriptor;
hideFromActionMenu?: boolean;
}
type SidebarPages = Record<string, SidebarPage>;