feat: course outline header update [FC-0114] (#2823)
Modifies new course outline header and actions
This commit is contained in:
@@ -12,3 +12,7 @@
|
||||
.border-dashed {
|
||||
border: dashed;
|
||||
}
|
||||
|
||||
.bg-draft-status {
|
||||
background-color: #F4B57B;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -33,6 +33,7 @@ const initialState = {
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
hasChanges: false,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ export const getOutlineSidebarPages = (): OutlineSidebarPages => {
|
||||
component: AddSidebar,
|
||||
icon: Plus,
|
||||
title: messages.sidebarButtonAdd,
|
||||
hideFromActionMenu: true,
|
||||
},
|
||||
} satisfies OutlineSidebarPages;
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ const statusBarData: CourseOutlineStatusBar = {
|
||||
highlightsEnabledForMessaging: true,
|
||||
videoSharingEnabled: true,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
|
||||
hasChanges: true,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface SidebarPage {
|
||||
component: React.ComponentType;
|
||||
icon: React.ComponentType;
|
||||
title: MessageDescriptor;
|
||||
hideFromActionMenu?: boolean;
|
||||
}
|
||||
|
||||
type SidebarPages = Record<string, SidebarPage>;
|
||||
|
||||
Reference in New Issue
Block a user