feat: course outline sidebar (#2731)

implements the new sidebar design for the Course Outline
This commit is contained in:
Rômulo Penido
2026-01-06 10:13:25 -03:00
committed by GitHub
parent 122414cb73
commit 220924233e
46 changed files with 1349 additions and 248 deletions

View File

@@ -68,17 +68,6 @@ const getLeafTags = (tree) => {
* @param {StagedTagData[]} stagedContentTags
* - Array of staged tags represented as objects with value/label
* @param {TaxonomyData & {contentTags: ContentTagData[]}} taxonomyAndTagsData
* @returns {{
* tagChangeHandler: (tagSelectableBoxValue: string, checked: boolean) => void,
* removeAppliedTagHandler: (tagSelectableBoxValue: string) => void,
* appliedContentTagsTree: Record<string, TagTreeEntry>,
* stagedContentTagsTree: Record<string, TagTreeEntry>,
* contentTagsCount: number,
* checkedTags: any,
* commitStagedTagsToGlobal: () => void,
* updateTags: import('@tanstack/react-query').UseMutationResult<
* any, unknown, { tagsData: Promise<UpdateTagsData[]>; }, unknown
* >
* }}
*/
const useContentTagsCollapsibleHelper = (

View File

@@ -407,7 +407,6 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
tags: tags.contentTags.map(t => t.value),
});
});
// @ts-ignore
updateTags.mutate({ tagsData });
}, [tagsByTaxonomy]);

View File

@@ -0,0 +1,3 @@
.tag-snippet-chip {
max-width: 260px;
}

View File

@@ -0,0 +1,55 @@
import {
render, screen, waitFor, initializeMocks,
} from '@src/testUtils';
import { mockContentTaxonomyTagsData } from './data/api.mocks';
import { ContentTagsSnippet } from './ContentTagsSnippet';
mockContentTaxonomyTagsData.applyMock();
const {
otherTagsId,
largeTagsId,
veryLongTagsId,
} = mockContentTaxonomyTagsData;
describe('<ContentTagsSnippet />', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the tags correctly', async () => {
render(<ContentTagsSnippet contentId={otherTagsId} />);
await waitFor(() => {
expect(screen.getByText('Taxonomy 1 (2)')).toBeInTheDocument();
});
expect(screen.getByText('Tag 1')).toBeInTheDocument();
expect(screen.getByText('Tag 2')).toBeInTheDocument();
expect(screen.getByText('Taxonomy 2 (2)')).toBeInTheDocument();
expect(screen.getByText('Tag 3')).toBeInTheDocument();
expect(screen.getByText('Tag 4')).toBeInTheDocument();
});
it('should render the tags with lineage correctly', async () => {
render(<ContentTagsSnippet contentId={largeTagsId} />);
await waitFor(() => {
expect(screen.getByText('Taxonomy 3 (1)')).toBeInTheDocument();
});
expect(screen.getByText('Tag 1 > Tag 1.1 > Tag 1.1.1')).toBeInTheDocument();
});
it('should render the very long lineage correctly', async () => {
render(<ContentTagsSnippet contentId={veryLongTagsId} />);
await waitFor(() => {
expect(screen.getByText('ESDC Skills and Competencies (2)')).toBeInTheDocument();
});
// Skills > Technical Skills Sub-Category > Technical Skills
// Can fit only first and last level
expect(screen.getByText('Skills > .. > Technical Skills')).toBeInTheDocument();
// Abilities > Cognitive Abilities > Communication Abilities
// can fit only last level
expect(screen.getByText('.. > Communication Abilities')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
import { Chip, Stack } from '@openedx/paragon';
import { Tag as TagIcon } from '@openedx/paragon/icons';
import { useContentTaxonomyTagsData } from './data/apiHooks';
import { Tag } from './data/types';
interface ContentTagsSnippetProps {
contentId: string;
}
const ContentTagChip = ({ tag }: { tag: Tag }) => {
let lineageStr = tag.lineage.join(' > ');
const lineageLength = tag.lineage.length;
const MAX_TAG_LENGTH = 30;
if (lineageStr.length > MAX_TAG_LENGTH && lineageLength > 1) {
if (lineageLength > 2) {
// NOTE: If the tag lineage is too long and have more than 2 tags, we truncate it to the first and last level
// i.e "Abilities > Cognitive Abilities > Communication Abilities" becomes
// "Abilities > .. > Communication Abilities"
lineageStr = `${tag.lineage[0]} > .. > ${tag.lineage[lineageLength - 1]}`;
}
if (lineageStr.length > MAX_TAG_LENGTH) {
// NOTE: If the tag lineage is still too long, we truncate it only to the last level
// i.e "Knowledge > .. > Administration and Management" becomes
// ".. > Administration and Management"
lineageStr = `.. > ${tag.lineage[lineageLength - 1]}`;
}
}
return (
<Chip
iconBefore={TagIcon}
className="mr-1 tag-snippet-chip small"
>
{lineageStr}
</Chip>
);
};
export const ContentTagsSnippet = ({ contentId }: ContentTagsSnippetProps) => {
const {
data,
} = useContentTaxonomyTagsData(contentId);
if (!data) {
return null;
}
return (
<Stack gap={2}>
{data.taxonomies.map((taxonomy) => (
<div key={taxonomy.taxonomyId}>
<h4 className="font-weight-bold x-small text-muted">
{`${taxonomy.name} (${taxonomy.tags.length})`}
</h4>
<div className="d-flex flex-wrap">
{taxonomy.tags.map((tag) => (
<ContentTagChip key={tag.value} tag={tag} />
))}
</div>
</div>
))}
</Stack>
);
};

View File

@@ -1,20 +0,0 @@
const TagOutlineIcon = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="currentColor"
role="img"
focusable="false"
aria-hidden="true"
{...props}
>
<path
d="m21.41 11.58-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01 4 11V4h7v-.01l9 9-7 7.02z"
/>
<circle cx="6.5" cy="6.5" r="1.5" />
</svg>
);
export default TagOutlineIcon;

View File

@@ -1,101 +0,0 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
/**
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
* @param {number} taxonomyId
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {string} the URL
*/
export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
if (options.parentTag) {
url.searchParams.append('parent_tag', options.parentTag);
}
if (options.page) {
url.searchParams.append('page', String(options.page));
}
if (options.searchTerm) {
url.searchParams.append('search_term', options.searchTerm);
}
// Load in the full tree if children at once, if we can:
// Note: do not combine this with page_size (we currently aren't using page_size)
url.searchParams.append('full_depth_threshold', '1000');
return url.href;
};
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getCourseContentDataApiURL = (contentId) => new URL(`/api/contentstore/v1/course_settings/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
/**
* Get all tags that belong to taxonomy.
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
* @returns {Promise<import("../../taxonomy/data/types.js").TagListData>}
*/
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Get the tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
*/
export async function getContentTaxonomyTagsData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
return camelCaseObject(data[contentId]);
}
/**
* Get the count of tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the count of the applied tags for
* @returns {Promise<number>}
*/
export async function getContentTaxonomyTagsCount(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId));
if (contentId in data) {
return camelCaseObject(data[contentId]);
}
return 0;
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/component)
* @param {string} contentId The id of the content object (unit/component)
* @returns {Promise<import("./types.js").ContentData>}
*/
export async function getContentData(contentId) {
let url;
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
} else if (contentId.startsWith('course-v1:')) {
url = getCourseContentDataApiURL(contentId);
} else {
url = getXBlockContentDataApiURL(contentId);
}
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Update content object's applied tags
* @param {string} contentId The id of the content object (unit/component)
* @param {Promise<import("./types.js").UpdateTagsData[]>} tagsData The list of tags (values) to set on content object
* @returns {Promise<import("./types.js").ContentTaxonomyTagsData>}
*/
export async function updateContentTaxonomyTags(contentId, tagsData) {
const url = getContentTaxonomyTagsApiUrl(contentId);
const { data } = await getAuthenticatedHttpClient().put(url, { tagsData });
return camelCaseObject(data[contentId]);
}

View File

@@ -15,6 +15,7 @@ export async function mockContentTaxonomyTagsData(contentId: string): Promise<an
case thisMock.largeTagsId: return thisMock.largeTags;
case thisMock.containerTagsId: return thisMock.largeTags;
case thisMock.emptyTagsId: return thisMock.emptyTags;
case thisMock.veryLongTagsId: return thisMock.veryLongTags;
default: throw new Error(`No mock has been set up for contentId "${contentId}"`);
}
}
@@ -205,6 +206,37 @@ mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+typ
mockContentTaxonomyTagsData.emptyTags = {
taxonomies: [],
};
mockContentTaxonomyTagsData.veryLongTagsId = 'block-v1:VeryLongTagsOrg+STC1+2023_1+type@vertical+block@veryLongTagsId';
mockContentTaxonomyTagsData.veryLongTags = {
taxonomies: [
{
name: 'ESDC Skills and Competencies',
taxonomyId: 1,
canTagObject: true,
tags: [
{
value: 'Technical Skills',
lineage: [
'Skills',
'Technical Skills Sub-Category',
'Technical Skills',
],
canDeleteObjecttag: true,
},
{
value: 'Communication Abilities',
lineage: [
'Abilities',
'Cognitive Abilities',
'Communication Abilities',
],
canDeleteObjecttag: true,
},
],
},
],
};
mockContentTaxonomyTagsData.containerTagsId = 'lct:StagedTagsOrg:lib:unit:container_tags';
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);

View File

@@ -1,4 +1,3 @@
// @ts-check
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

View File

@@ -0,0 +1,109 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type { TagListData } from '@src/taxonomy/data/types';
import type { ContentData, ContentTaxonomyTagsData, UpdateTagsData } from './types';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
interface GetTaxonomyTagsApiUrlOptions {
parentTag?: string;
page?: number;
searchTerm?: string;
}
/**
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
*/
export const getTaxonomyTagsApiUrl = (taxonomyId: number, options: GetTaxonomyTagsApiUrlOptions = {}): string => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
if (options.parentTag) {
url.searchParams.append('parent_tag', options.parentTag);
}
if (options.page) {
url.searchParams.append('page', String(options.page));
}
if (options.searchTerm) {
url.searchParams.append('search_term', options.searchTerm);
}
// Load in the full tree if children at once, if we can:
// Note: do not combine this with page_size (we currently aren't using page_size)
url.searchParams.append('full_depth_threshold', '1000');
return url.href;
};
export const getContentTaxonomyTagsApiUrl = (contentId: string) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getXBlockContentDataApiURL = (contentId: string) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getCourseContentDataApiURL = (contentId: string) => new URL(`/api/contentstore/v1/course_settings/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId: string) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
export const getContentTaxonomyTagsCountApiUrl = (contentId: string) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;
/**
* Get all tags that belong to taxonomy.
*/
export async function getTaxonomyTagsData(
taxonomyId: number,
options: GetTaxonomyTagsApiUrlOptions = {},
): Promise<TagListData> {
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Get the tags that are applied to the content object
* @param contentId The id of the content object to fetch the applied tags for
*/
export async function getContentTaxonomyTagsData(contentId: string): Promise<ContentTaxonomyTagsData> {
const url = getContentTaxonomyTagsApiUrl(contentId);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data[contentId]);
}
/**
* Get the count of tags that are applied to the content object
* @param contentId The id of the content object to fetch the count of the applied tags for
*/
export async function getContentTaxonomyTagsCount(contentId: string): Promise<number> {
const url = getContentTaxonomyTagsCountApiUrl(contentId);
const { data } = await getAuthenticatedHttpClient().get(url);
if (contentId in data) {
return camelCaseObject(data[contentId]);
}
return 0;
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/component)
* @param contentId The id of the content object (unit/component)
*/
export async function getContentData(contentId: string): Promise<ContentData> {
let url: string;
if (contentId.startsWith('lb:')) {
url = getLibraryContentDataApiUrl(contentId);
} else if (contentId.startsWith('course-v1:')) {
url = getCourseContentDataApiURL(contentId);
} else {
url = getXBlockContentDataApiURL(contentId);
}
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}
/**
* Update content object's applied tags
* @param contentId The id of the content object (unit/component)
* @param tagsData The list of tags (values) to set on content object
*/
export async function updateContentTaxonomyTags(
contentId: string,
tagsData: UpdateTagsData[],
): Promise<ContentTaxonomyTagsData> {
const url = getContentTaxonomyTagsApiUrl(contentId);
const { data } = await getAuthenticatedHttpClient().put(url, { tagsData });
return camelCaseObject(data[contentId]);
}

View File

@@ -17,16 +17,21 @@ import {
} from './api';
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
import { UpdateTagsData } from './types';
import type { UpdateTagsData } from './types';
/**
* Builds the query to get the taxonomy tags
* @param taxonomyId The id of the taxonomy to fetch tags for
* @param parentTag The tag whose children we're loading, if any
* @param searchTerm The term passed in to perform search on tags
* @param numPages How many pages of tags to load at this level
*/
export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null = null, numPages = 1, searchTerm = '') => {
export const useTaxonomyTagsData = (
/** The id of the taxonomy to fetch tags for */
taxonomyId: number,
/** The tag whose children we're loading, if any */
parentTag: string | null = null,
/** How many pages of tags to load at this level */
numPages = 1,
/** The term passed in to perform search on tags */
searchTerm = '',
) => {
const queryClient = useQueryClient();
const queryFn = async ({ queryKey }) => {
@@ -128,7 +133,7 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => {
const { containerId } = useParams();
return useMutation({
mutationFn: ({ tagsData }: { tagsData: Promise<UpdateTagsData[]> }) => (
mutationFn: ({ tagsData }: { tagsData: UpdateTagsData[] }) => (
updateContentTaxonomyTags(contentId, tagsData)
),
onSettled: () => {

View File

@@ -1,4 +1,4 @@
import type { TaxonomyData } from '../../taxonomy/data/types';
import type { TaxonomyData } from '@src/taxonomy/data/types';
/** A tag that has been applied to some content. */
export interface Tag {

View File

@@ -1,3 +1,4 @@
@import "content-tags-drawer/TagsTree";
@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls";
@import "content-tags-drawer/ContentTagsDrawer";
@import "content-tags-drawer/ContentTagsSnippet";

View File

@@ -1,3 +1,4 @@
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
export { default as ContentTagsDrawerSheet } from './ContentTagsDrawerSheet';
export { useContentTaxonomyTagsData } from './data/apiHooks';
export { ContentTagsSnippet } from './ContentTagsSnippet';

View File

@@ -3,7 +3,6 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import {
Container,
Layout,
Row,
TransitionReplace,
Toast,
@@ -67,6 +66,7 @@ import messages from './messages';
import headerMessages from './header-navigations/messages';
import { getTagsExportFile } from './data/api';
import OutlineAddChildButtons from './OutlineAddChildButtons';
import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext';
import { StatusBar } from './status-bar/StatusBar';
import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
@@ -288,7 +288,7 @@ const CourseOutline = () => {
}
return (
<>
<OutlineSidebarProvider>
<Helmet>
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
</Helmet>
@@ -357,14 +357,8 @@ const CourseOutline = () => {
/>
)}
<hr className="mt-4 mb-0 w-100 text-light-400" />
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 12 }, { span: 12 }]}
xs={[{ span: 12 }, { span: 12 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
<div className="d-flex align-items-baseline flex-wrap">
<div className="flex-fill">
<article>
<div>
{showNewActionsBar && (
@@ -384,7 +378,7 @@ const CourseOutline = () => {
)}
</ActionRow>
)}
<section className="course-outline-section">
<section>
{!errors?.outlineIndexApi && (
<div className="pt-4">
{sections.length ? (
@@ -525,15 +519,13 @@ const CourseOutline = () => {
</section>
</div>
</article>
</Layout.Element>
<Layout.Element>
<CourseAuthoringOutlineSidebarSlot
courseId={courseId}
courseName={courseName}
sections={sections}
/>
</Layout.Element>
</Layout>
</div>
<CourseAuthoringOutlineSidebarSlot
courseId={courseId}
courseName={courseName}
sections={sections}
/>
</div>
<EnableHighlightsModal
isOpen={isEnableHighlightsModalOpen}
close={closeEnableHighlightsModal}
@@ -615,7 +607,7 @@ const CourseOutline = () => {
{toastMessage}
</Toast>
)}
</>
</OutlineSidebarProvider>
);
};

View File

@@ -1,8 +1,11 @@
import { userEvent } from '@testing-library/user-event';
import {
fireEvent, initializeMocks, render, screen,
} from '@src/testUtils';
import messages from './messages';
import HeaderActions, { HeaderActionsProps } from './HeaderActions';
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
const handleNewSectionMock = jest.fn();
@@ -18,12 +21,23 @@ const courseActions = {
duplicable: true,
};
const setCurrentPageKeyMock = jest.fn();
jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({
...jest.requireActual('../outline-sidebar/OutlineSidebarContext'),
useOutlineSidebarContext: () => ({
...jest.requireActual('../outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
setCurrentPageKey: setCurrentPageKeyMock,
}),
}));
const renderComponent = (props?: Partial<HeaderActionsProps>) => render(
<HeaderActions
actions={headerNavigationsActions}
courseActions={courseActions}
{...props}
/>,
{ extraWrapper: OutlineSidebarProvider },
);
describe('<HeaderActions />', () => {
@@ -55,4 +69,18 @@ describe('<HeaderActions />', () => {
expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: messages.addButton.defaultMessage })).toBeDisabled();
});
it('should change pages using the dropdown button', 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);
// Check if the current page change is called
expect(setCurrentPageKeyMock).toHaveBeenCalledWith('help');
});
});

View File

@@ -3,10 +3,14 @@ import {
Button, Dropdown, Icon, OverlayTrigger, Stack, Tooltip,
} from '@openedx/paragon';
import {
Add as IconAdd, AutoGraph, FindInPage, HelpOutline, InfoOutline, ViewSidebar,
Add as IconAdd, FindInPage, ViewSidebar,
} from '@openedx/paragon/icons';
import { OutlinePageErrors, XBlockActions } from '@src/data/types';
import type { SidebarPage } from '@src/generic/sidebar';
import { useOutlineSidebarContext, OutlineSidebarPageKeys } from '../outline-sidebar/OutlineSidebarContext';
import messages from './messages';
export interface HeaderActionsProps {
@@ -26,6 +30,8 @@ const HeaderActions = ({
const intl = useIntl();
const { handleNewSection, lmsLink } = actions;
const { setCurrentPageKey, sidebarPages } = useOutlineSidebarContext();
return (
<Stack direction="horizontal" gap={3}>
{courseActions.childAddable && (
@@ -74,24 +80,17 @@ const HeaderActions = ({
<Icon src={ViewSidebar} />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
<Dropdown.Item>
<Stack direction="horizontal" gap={2}>
<Icon src={InfoOutline} />
{intl.formatMessage(messages.infoButton)}
</Stack>
</Dropdown.Item>
<Dropdown.Item>
<Stack direction="horizontal" gap={2}>
<Icon src={AutoGraph} />
{intl.formatMessage(messages.analyticsButton)}
</Stack>
</Dropdown.Item>
<Dropdown.Item>
<Stack direction="horizontal" gap={2}>
<Icon src={HelpOutline} />
{intl.formatMessage(messages.helpButton)}
</Stack>
</Dropdown.Item>
{Object.entries(sidebarPages).map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => (
<Dropdown.Item
key={key}
onClick={() => setCurrentPageKey(key)}
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{page.title}
</Stack>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>

View File

@@ -10,21 +10,6 @@ const messages = defineMessages({
defaultMessage: 'Add',
description: 'Add button text in course outline header',
},
infoButton: {
id: 'course-authoring.course-outline.header-navigations.button.infoButton',
defaultMessage: 'Info',
description: 'Info button text in course outline header',
},
analyticsButton: {
id: 'course-authoring.course-outline.header-navigations.button.analyticsButton',
defaultMessage: 'Analytics',
description: 'Analytics button text in course outline header',
},
helpButton: {
id: 'course-authoring.course-outline.header-navigations.button.helpButton',
defaultMessage: 'Help',
description: 'Help button text in course outline header',
},
newSectionButtonTooltip: {
id: 'course-authoring.course-outline.header-navigations.button.new-section.tooltip',
defaultMessage: 'Click to add a new section',

View File

@@ -1,10 +1,13 @@
// @ts-check
import { initializeMocks, render, waitFor } from '../../testUtils';
import { helpUrls } from '../../help-urls/__mocks__';
import { getHelpUrlsApiUrl } from '../../help-urls/data/api';
import OutlineSidebar from './OutlineSidebar';
import { initializeMocks, render, waitFor } from '@src/testUtils';
import { helpUrls } from '@src/help-urls/__mocks__';
import { getHelpUrlsApiUrl } from '@src/help-urls/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import messages from './messages';
const courseId = '123';
jest.mock('@edx/frontend-platform/i18n', () => ({
...jest.requireActual('@edx/frontend-platform/i18n'),
useIntl: () => ({
@@ -12,11 +15,16 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
const extraWrapper = ({ children }) => (
<CourseAuthoringProvider courseId={courseId}>
{children}
</CourseAuthoringProvider>
);
let axiosMock;
const mockPathname = '/foo-bar';
const courseId = '123';
const renderComponent = (props) => render(<OutlineSidebar courseId={courseId} {...props} />, { path: mockPathname });
const renderComponent = () => render(<OutlineHelpSidebar />, { path: mockPathname, extraWrapper });
describe('<OutlineSidebar />', () => {
beforeEach(() => {

View File

@@ -1,19 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Hyperlink } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { HelpSidebar } from '../../generic/help-sidebar';
import { useHelpUrls } from '../../help-urls/hooks';
import { HelpSidebar } from '@src/generic/help-sidebar';
import { useHelpUrls } from '@src/help-urls/hooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { getFormattedSidebarMessages } from './utils';
const OutlineSideBar = ({ courseId }) => {
const OutlineHelpSideBar = () => {
const intl = useIntl();
const {
visibility: learnMoreVisibilityUrl,
grading: learnMoreGradingUrl,
outline: learnMoreOutlineUrl,
} = useHelpUrls(['visibility', 'grading', 'outline']);
const { courseId } = useCourseAuthoringContext();
const sidebarMessages = getFormattedSidebarMessages(
{
@@ -40,7 +41,7 @@ const OutlineSideBar = ({ courseId }) => {
{descriptions.map((description) => (
<p className="help-sidebar-about-descriptions" key={description}>{description}</p>
))}
{Boolean(link) && Boolean(link.href) && (
{!!link?.href && (
<Hyperlink
className="small"
destination={link.href}
@@ -58,8 +59,4 @@ const OutlineSideBar = ({ courseId }) => {
);
};
OutlineSideBar.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default OutlineSideBar;
export default OutlineHelpSideBar;

View File

@@ -0,0 +1,59 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { SchoolOutline, Tag } from '@openedx/paragon/icons';
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
import { ComponentCountSnippet } from '@src/generic/block-type-utils';
import { useGetBlockTypes } from '@src/search-manager';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
import { useCourseDetails } from '../data/apiHooks';
import messages from './messages';
export const OutlineInfoSidebar = () => {
const intl = useIntl();
const { courseId } = useCourseAuthoringContext();
const { data: courseDetails } = useCourseDetails(courseId);
const { data: componentData } = useGetBlockTypes(
[`context_key = "${courseId}"`],
);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
return (
<div>
<SidebarTitle
title={courseDetails?.title || ''}
icon={SchoolOutline}
/>
<SidebarContent>
<SidebarSection
title={intl.formatMessage(messages.sidebarSectionSummary)}
icon={SchoolOutline}
>
{componentData && <ComponentCountSnippet componentData={componentData} />}
</SidebarSection>
<SidebarSection
title={intl.formatMessage(messages.sidebarSectionTaxonomy)}
icon={Tag}
actions={[
{
label: intl.formatMessage(messages.sidebarSectionTaxonomyManageTags),
onClick: openManageTagsDrawer,
},
]}
>
<ContentTagsSnippet contentId={courseId} />
</SidebarSection>
</SidebarContent>
<ContentTagsDrawerSheet
id={courseId}
onClose={closeManageTagsDrawer}
showSheet={isManageTagsDrawerOpen}
/>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import { getConfig, setConfig } from '@edx/frontend-platform';
import { userEvent } from '@testing-library/user-event';
import {
initializeMocks, render, screen, waitFor, within,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { OutlineSidebarProvider } from './OutlineSidebarContext';
import OutlineSidebar from './OutlineSidebar';
// Mock the useCourseDetails hook
jest.mock('@src/course-outline/data/apiHooks', () => ({
useCourseDetails: jest.fn().mockReturnValue({ isPending: false, data: { title: 'Test Course' } }),
}));
const courseId = '123';
const extraWrapper = ({ children }) => (
<CourseAuthoringProvider courseId={courseId}>
<OutlineSidebarProvider>
{children}
</OutlineSidebarProvider>
</CourseAuthoringProvider>
);
const renderComponent = () => render(
<OutlineSidebar />,
{ extraWrapper },
);
describe('<OutlineSidebar>', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the help sidebar by default', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('Creating your course organization')).toBeInTheDocument();
});
});
it('should render the new sidebar if ENABLE_COURSE_OUTLINE_NEW_DESIGN is true', async () => {
setConfig({
...getConfig(),
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
});
renderComponent();
// Check that the new sidebar is rendered, with the Info page
await waitFor(() => {
expect(screen.getByText('Test Course')).toBeInTheDocument();
});
const sidebarToggle = screen.getByTestId('sidebar-toggle');
expect(sidebarToggle).toBeInTheDocument();
// Hide the sidebar
const toggleButton = within(sidebarToggle).getByRole('button', { name: 'Toggle' });
expect(toggleButton).toBeInTheDocument();
await userEvent.click(toggleButton);
// Check that there are no more Info sidebar elements
expect(screen.queryByText('Test Course')).not.toBeInTheDocument();
// Show the sidebar
await userEvent.click(toggleButton);
// Check that the new sidebar is rendered, with the Info page
await waitFor(() => {
expect(screen.getByText('Test Course')).toBeInTheDocument();
});
// Change page
await userEvent.click(screen.getByRole('button', { name: 'Help' }));
// Check that the help page is rendered
expect(screen.getByText('Creating your course organization')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,44 @@
import { getConfig } from '@edx/frontend-platform';
import { breakpoints } from '@openedx/paragon';
import { useMediaQuery } from 'react-responsive';
import { Sidebar } from '@src/generic/sidebar';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { useOutlineSidebarContext } from './OutlineSidebarContext';
const OutlineSideBar = () => {
const isMedium = useMediaQuery({ maxWidth: breakpoints.medium.maxWidth });
const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true';
const {
currentPageKey,
setCurrentPageKey,
isOpen,
toggle,
sidebarPages,
} = useOutlineSidebarContext();
// Returns the previous help sidebar component if the waffle flag is disabled
if (!showNewSidebar) {
// On screens smaller than medium, the help sidebar is shown below the course outline
const colSpan = isMedium ? 'col-12' : 'col-3';
return (
<div className={colSpan}>
<OutlineHelpSidebar />
</div>
);
}
return (
<Sidebar
pages={sidebarPages}
currentPageKey={currentPageKey}
setCurrentPageKey={setCurrentPageKey}
isOpen={isOpen}
toggle={toggle}
/>
);
};
export default OutlineSideBar;

View File

@@ -0,0 +1,89 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle } from '@openedx/paragon';
import { HelpOutline, Info } from '@openedx/paragon/icons';
import type { SidebarPage } from '@src/generic/sidebar';
import OutlineHelpSidebar from './OutlineHelpSidebar';
import { OutlineInfoSidebar } from './OutlineInfoSidebar';
import messages from './messages';
export type OutlineSidebarPageKeys = 'help' | 'info';
export type OutlineSidebarPages = Record<OutlineSidebarPageKeys, SidebarPage>;
interface OutlineSidebarContextData {
currentPageKey: OutlineSidebarPageKeys;
setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void;
isOpen: boolean;
open: () => void;
toggle: () => void;
sidebarPages: OutlineSidebarPages;
}
const OutlineSidebarContext = createContext<OutlineSidebarContextData | undefined>(undefined);
export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => {
const intl = useIntl();
const [currentPageKey, setCurrentPageKeyState] = useState<OutlineSidebarPageKeys>('info');
const [isOpen, open, , toggle] = useToggle(true);
const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => {
setCurrentPageKeyState(pageKey);
open();
}, [open]);
const sidebarPages = {
info: {
component: OutlineInfoSidebar,
icon: Info,
title: intl.formatMessage(messages.sidebarButtonInfo),
},
help: {
component: OutlineHelpSidebar,
icon: HelpOutline,
title: intl.formatMessage(messages.sidebarButtonHelp),
},
} satisfies OutlineSidebarPages;
const context = useMemo<OutlineSidebarContextData>(
() => ({
currentPageKey,
setCurrentPageKey,
sidebarPages,
isOpen,
open,
toggle,
}),
[
currentPageKey,
setCurrentPageKey,
sidebarPages,
isOpen,
open,
toggle,
],
);
return (
<OutlineSidebarContext.Provider value={context}>
{children}
</OutlineSidebarContext.Provider>
);
};
export function useOutlineSidebarContext(): OutlineSidebarContextData {
const ctx = useContext(OutlineSidebarContext);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('useOutlineSidebarContext() was used in a component without a <OutlineSidebarProvider> ancestor.');
}
return ctx;
}

View File

@@ -65,6 +65,31 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.sidebar.section-4.link',
defaultMessage: 'Learn more about content visibility settings',
},
sidebarButtonHelp: {
id: 'course-authoring.course-outline.sidebar.sidebar-button-help',
defaultMessage: 'Help',
description: 'Button label for the help sidebar',
},
sidebarButtonInfo: {
id: 'course-authoring.course-outline.sidebar.sidebar-button-info',
defaultMessage: 'Info',
description: 'Button label for the info sidebar',
},
sidebarSectionSummary: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-summary',
defaultMessage: 'Course Content Summary',
description: 'Title of the summary section in the sidebar',
},
sidebarSectionTaxonomy: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-taxonomy',
defaultMessage: 'Taxonomy Alignments',
description: 'Title of the taxonomy section in the sidebar',
},
sidebarSectionTaxonomyManageTags: {
id: 'course-authoring.course-outline.sidebar.sidebar-section-taxonomy.manage-tags-action',
defaultMessage: 'Manage tags',
description: 'Action to open the tags drawer',
},
});
export default messages;

View File

@@ -0,0 +1,18 @@
import {
initializeMocks, matchInnerText, render, screen,
} from '@src/testUtils';
import { ComponentCountSnippet } from '.';
describe('<ComponentCountSnippet>', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the component', () => {
render(<ComponentCountSnippet componentData={{ text: 1, video: 2 }} />);
expect(screen.getByText('3 Total')).toBeInTheDocument();
expect(screen.getByText(matchInnerText('DIV', 'text1'))).toBeInTheDocument();
expect(screen.getByText(matchInnerText('DIV', 'video2'))).toBeInTheDocument();
});
});

View File

@@ -21,6 +21,10 @@
margin: -1px;
}
}
span {
color: white;
}
}
.component-style-html {
@@ -46,6 +50,10 @@
margin: -1px;
}
}
span {
color: white;
}
}
.component-style-collection {
@@ -71,6 +79,10 @@
margin: -1px;
}
}
span {
color: black;
}
}
.component-style-video {
@@ -96,6 +108,10 @@
margin: -1px;
}
}
span {
color: white;
}
}
.component-style-vertical {
@@ -121,6 +137,10 @@
margin: -1px;
}
}
span {
color: white;
}
}
.component-style-sequential {
@@ -146,6 +166,10 @@
margin: -1px;
}
}
span {
color: white;
}
}
.component-style-chapter {
@@ -172,6 +196,10 @@
margin: -1px;
}
}
span {
color: white;
}
}
.component-style-other {
@@ -197,4 +225,8 @@
margin: -1px;
}
}
span {
color: white;
}
}

View File

@@ -1,4 +1,6 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Article } from '@openedx/paragon/icons';
import {
COMPONENT_TYPE_ICON_MAP,
@@ -6,6 +8,8 @@ import {
COMPONENT_TYPE_STYLE_COLOR_MAP,
} from './constants';
import messages from './messages';
export function getItemIcon(blockType: string): React.ComponentType {
return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article;
}
@@ -13,3 +17,64 @@ export function getItemIcon(blockType: string): React.ComponentType {
export function getComponentStyleColor(blockType: string): string {
return COMPONENT_TYPE_STYLE_COLOR_MAP[blockType] ?? COMPONENT_TYPE_STYLE_COLOR_MAP.other;
}
interface ComponentIconProps {
blockType: string;
iconTitle: string;
text?: string;
className?: string;
}
export const ComponentIcon = ({
blockType,
iconTitle,
text,
className,
}: ComponentIconProps) => (
<div className={`rounded p-1 d-inline-flex ${getComponentStyleColor(blockType)} ${className}`}>
<Icon
src={getItemIcon(blockType)}
screenReaderText={blockType}
title={iconTitle}
size="sm"
/>
{text && <span className="ml-1 x-small">{text}</span>}
</div>
);
interface ComponentCountSnippetProps {
componentData: Record<string, number>;
}
export const ComponentCountSnippet = ({ componentData }: ComponentCountSnippetProps) => {
const intl = useIntl();
const totalCount = componentData && Object.values(componentData).reduce((acc, val) => acc + val, 0);
return (
<div>
{componentData && (
<div className="d-inline-flex py-1 px-2 mr-2 border-right border-light">
<Icon
src={getItemIcon('components')}
title={intl.formatMessage(messages.componentTotal)}
className="mr-1"
size="sm"
/>
<span className="x-small">
{`${totalCount} ${intl.formatMessage(messages.componentTotal)}`}
</span>
</div>
)}
{componentData && Object.keys(componentData).map((blockType) => (
<ComponentIcon
key={blockType}
blockType={blockType}
iconTitle={blockType}
text={componentData[blockType].toString()}
className="px-2 mr-2"
/>
))}
</div>
);
};

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
componentTotal: {
id: 'course-authoring.block-type-utils.component-total',
defaultMessage: 'Total',
description: 'Total number of components',
},
});
export default messages;

View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import {
initializeMocks, render, screen, within,
} from '@src/testUtils';
import { userEvent } from '@testing-library/user-event';
import { useToggle } from '@openedx/paragon';
import { Sidebar } from '.';
const Component1 = () => <div>Component 1</div>;
const Component2 = () => <div>Component 2</div>;
const Icon1 = () => <div>Icon 1</div>;
const Icon2 = () => <div>Icon 2</div>;
const pages = {
page1: {
title: 'Page 1',
component: Component1,
icon: Icon1,
},
page2: {
title: 'Page 2',
component: Component2,
icon: Icon2,
},
};
const TestSidebar = () => {
const [isOpen, , , toggle] = useToggle(true);
const [pageKey, setPageKey] = useState<keyof typeof pages>('page1');
return (
<Sidebar
pages={pages}
currentPageKey={pageKey}
setCurrentPageKey={setPageKey}
isOpen={isOpen}
toggle={toggle}
/>
);
};
describe('<Sidebar>', () => {
beforeEach(() => {
initializeMocks();
});
it('should render the sidebar', () => {
render(<TestSidebar />);
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
// Check the IconButtonToggle
const sidebarToggle = screen.getByTestId('sidebar-toggle');
expect(sidebarToggle).toBeInTheDocument();
const page1Button = within(sidebarToggle).getByRole('button', { name: 'Page 1' });
expect(page1Button).toBeInTheDocument();
expect(page1Button).toHaveAttribute('aria-selected', 'true');
const page2Button = within(sidebarToggle).getByRole('button', { name: 'Page 2' });
expect(page2Button).toBeInTheDocument();
expect(page2Button).toHaveAttribute('aria-selected', 'false');
});
it('should change pages using the icon button', async () => {
render(<TestSidebar />);
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
const page2Button = screen.getByRole('button', { name: 'Page 2' });
await userEvent.click(page2Button);
expect(page2Button).toHaveAttribute('aria-selected', 'true');
// Check the Page 2 content
expect(screen.getByText('Component 2')).toBeInTheDocument();
const page1Button = screen.getByRole('button', { name: 'Page 1' });
expect(page1Button).toHaveAttribute('aria-selected', 'false');
await userEvent.click(page1Button);
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
});
it('should change pages using the dropdown button', async () => {
render(<TestSidebar />);
const sidebarDropdown = screen.getByTestId('sidebar-dropdown');
expect(sidebarDropdown).toBeInTheDocument();
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
// Click on the dropdown button
await userEvent.click(within(sidebarDropdown).getByRole('button', { name: 'Page 1 Icon 1' }));
// Select the Page 2 option
const page2Button = within(sidebarDropdown).getByRole('button', { name: 'Icon 2 Page 2' });
await userEvent.click(page2Button);
// Check the Page 2 content
expect(screen.getByText('Component 2')).toBeInTheDocument();
// Click on the dropdown button again
await userEvent.click(within(sidebarDropdown).getByRole('button', { name: 'Page 2 Icon 2' }));
// Select the Page 1 option
const page1Button = within(sidebarDropdown).getByRole('button', { name: 'Icon 1 Page 1' });
await userEvent.click(page1Button);
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
});
it('should toggle the sidebar', async () => {
render(<TestSidebar />);
const sidebarToggle = screen.getByTestId('sidebar-toggle');
expect(sidebarToggle).toBeInTheDocument();
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
// Hide the sidebar
const toggleButton = within(sidebarToggle).getByRole('button', { name: 'Toggle' });
expect(toggleButton).toBeInTheDocument();
await userEvent.click(toggleButton);
// Check the Page 1 content is hidden
expect(screen.queryByText('Component 1')).not.toBeInTheDocument();
// Show the sidebar
await userEvent.click(toggleButton);
// Check the Page 1 content
expect(screen.getByText('Component 1')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Dropdown,
Icon,
IconButton,
IconButtonToggle,
Stack,
} from '@openedx/paragon';
import {
FormatIndentDecrease,
FormatIndentIncrease,
} from '@openedx/paragon/icons';
import messages from './messages';
export interface SidebarPage {
component: React.ComponentType;
icon: React.ComponentType;
title: string;
}
type SidebarPages = Record<string, SidebarPage>;
/**
* Sidebar component
*/
interface SidebarProps<T extends SidebarPages> {
/** Object containing the pages that are rendered in the sidebar.
* Must satisfy the SidebarPages interface */
pages: T;
/** The page that is initially rendered in the sidebar.
* Must be a key of the pages object */
currentPageKey: keyof T;
/** Function that is called when the page is changed.
* Must be a key of the pages object */
setCurrentPageKey: (pageKey: keyof T) => void;
/** Whether the sidebar is open or not */
isOpen: boolean;
/** Function that toggles the sidebar */
toggle: () => void;
}
/**
* Sidebar component
*
* This component is used to render a sidebar that can be toggled open and closed.
* The generic type T is used to define the pages that are rendered in the sidebar.
*
* Example usage:
*
* ```tsx
* const sidebarPages = {
* help: {
* component: OutlineHelpSidebar,
* icon: HelpOutline,
* title: intl.formatMessage(messages.sidebarButtonHelp),
* },
* info: {
* component: OutlineInfoSidebar,
* icon: Info,
* title: intl.formatMessage(messages.sidebarButtonInfo),
* },
* } satisfies SidebarPages;
*
* const [isOpen, open, , toggle] = useToggle(true);
*
* return (
* <Sidebar
* pages={sidebarPages}
* currentPageKey="help"
* isOpen={isOpen}
* toggle={toggle}
* />
*);
* ```
*/
// eslint-disable-next-line react/function-component-definition
export function Sidebar<T extends SidebarPages>({
pages,
currentPageKey,
setCurrentPageKey,
isOpen,
toggle,
}: SidebarProps<T>) {
const intl = useIntl();
const SidebarComponent = pages[currentPageKey].component;
return (
<Stack direction="horizontal" className="sidebar align-items-baseline mx-3" gap={2}>
{isOpen && !!currentPageKey && (
<div className="sidebar-content p-2 bg-white border-right">
<Dropdown data-testid="sidebar-dropdown">
<Dropdown.Toggle
id="dropdown-toggle-with-iconbutton"
as={Button}
variant="tertiary"
className="x-small text-primary font-weight-bold pl-0"
>
{pages[currentPageKey].title}
<Icon src={pages[currentPageKey].icon} size="xs" className="ml-2" />
</Dropdown.Toggle>
<Dropdown.Menu className="mt-1">
{Object.entries(pages).map(([key, page]) => (
<Dropdown.Item
key={key}
onClick={() => setCurrentPageKey(key)}
>
<Stack direction="horizontal" gap={2}>
<Icon src={page.icon} />
{page.title}
</Stack>
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
<SidebarComponent />
</div>
)}
<div className="sidebar-toggle" data-testid="sidebar-toggle">
<IconButton
src={isOpen ? FormatIndentIncrease : FormatIndentDecrease}
alt={intl.formatMessage(messages.toggle)}
onClick={toggle}
variant="primary"
/>
<IconButtonToggle
activeValue={currentPageKey}
onChange={setCurrentPageKey}
>
{Object.entries(pages).map(([key, page]) => (
<IconButton
key={key}
// FIXME: The following ts-ignore can be removed when the type fix is released in paragon
// https://github.com/openedx/paragon/pull/4031
// @ts-ignore
value={key}
src={page.icon}
alt={page.title}
className="rounded-iconbutton"
/>
))}
</IconButtonToggle>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import { SidebarContent, SidebarSection } from '.';
const Icon1 = () => <div>Icon 1</div>;
describe('<SidebarContent>', () => {
it('should render the sidebar content', () => {
render(
<SidebarContent>
<SidebarSection title="Section 1" icon={Icon1}>
<div>Content 1</div>
</SidebarSection>
<SidebarSection title="Section 2">
<div>Content 2</div>
</SidebarSection>
</SidebarContent>,
);
expect(screen.getByText('Section 1')).toBeInTheDocument();
expect(screen.getByText('Icon 1')).toBeInTheDocument();
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.getByText('Section 2')).toBeInTheDocument();
expect(screen.getByText('Content 2')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Stack } from '@openedx/paragon';
interface SidebarContentProps {
children: React.ReactNode | React.ReactNode[],
}
/**
* Sidebar content component
*
* This component is used to render the content body of the sidebar.
* It is used as a child of the Sidebar component.
*
* This is meant to standardize the look and feel of the sidebar content,
* so that it can be reused across different parts of the application.
*
* Example usage:
*
* ```tsx
* <SidebarContent>
* <SidebarSection title="Section 1">
* <p>Content 1</p>
* </SidebarSection>
* <SidebarSection title="Section 2">
* <MyContentComponent someProps={someProps} />
* </SidebarSection>
* </SidebarContent>
* ```
*/
export const SidebarContent = ({ children } : SidebarContentProps) => (
<Stack gap={1}>
{Array.isArray(children) ? children.map((child, index) => (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}>
{child}
{index !== children.length - 1 && <hr className="w-100" />}
</React.Fragment>
)) : children}
</Stack>
);

View File

@@ -0,0 +1,76 @@
import {
Dropdown,
Icon,
IconButton,
Stack,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
export interface SidebarSectionProps {
/** Title of the section */
title: string;
/** Icon to be displayed in the section */
icon?: React.ComponentType;
/** Actions to be displayed in the section */
actions?: [
{
/** Label of the action */
label: string;
/** Function to be called when the action is clicked */
onClick: () => void;
},
];
/** Content of the section */
children: React.ReactNode;
}
/**
* Sidebar section component
*
* This component is used to render a section in the sidebar.
* It is used as a child of the SidebarContent component.
*
* This is meant to standardize the look and feel of the sidebar sections,
* so that it can be reused across different parts of the application.
*
* Example usage:
*
* ```tsx
* <SidebarSection title="Section 1">
* <p>Content 1</p>
* </SidebarSection>
* ```
*/
export const SidebarSection = ({
title, icon, actions, children,
}: SidebarSectionProps) => (
<Stack gap={2}>
<Stack direction="horizontal" gap={2}>
{icon && <Icon src={icon} className="mr-1 text-primary" size="sm" />}
<h3 className="h5 font-weight-bold text-primary mb-0">
{title}
</h3>
{actions && (
<Dropdown className="ml-auto">
<Dropdown.Toggle
as={IconButton}
src={MoreVert}
iconAs={Icon}
size="sm"
/>
<Dropdown.Menu>
{actions.map((action) => (
<Dropdown.Item
key={action.label}
onClick={action.onClick}
>
{action.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
)}
</Stack>
{children}
</Stack>
);

View File

@@ -0,0 +1,24 @@
import { Icon, Stack } from '@openedx/paragon';
interface SidebarTitleProps {
/** Title of the section */
title: string;
/** Icon to be displayed in the section title */
icon?: React.ComponentType;
}
/**
* Sidebar title component
*
* This component is used to render a title in the sidebar.
* It is used as a child of the SidebarContent component.
*
* This is meant to standardize the look and feel of the sidebar section titles,
* so that it can be reused across different parts of the application.
*/
export const SidebarTitle = ({ title, icon }: SidebarTitleProps) => (
<Stack direction="horizontal" gap={2} className="mb-3">
<Icon src={icon} className="mr-2 text-primary" />
<h2 className="text-primary h3 mb-0">{title}</h2>
</Stack>
);

View File

@@ -0,0 +1,53 @@
.sidebar {
.sidebar-content {
width: 400px;
}
.dropdown-toggle {
padding: .2rem .5rem;
background-color: transparent !important;
}
.sidebar-toggle {
display: flex;
flex-direction: column;
align-self: flex-start;
align-items: center;
.pgn__icon-button-toggle__container {
flex-direction: column;
align-items: flex-center;
.btn-icon + .btn-icon {
margin-top: var(--pgn-spacing-spacer-1);
margin-inline-start: unset;
}
}
.rounded-iconbutton {
border-radius: var(--pgn-size-menu-base-border-radius);
border: 1px solid var(--pgn-color-primary-base);
background-color: transparent;
color: var(--pgn-color-primary-base) !important;
width: 56px;
&.btn-icon-primary-active {
border-width: 3px;
}
&:hover {
border-width: 2px;
}
&.btn-icon-primary-active::before {
content: "";
border-right: 5px solid var(--pgn-color-primary-base);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: none;
position: absolute;
transform: translate(-30px, 15px);
}
}
}
}

View File

@@ -0,0 +1,5 @@
export { SidebarContent } from './SidebarContent';
export { SidebarTitle } from './SidebarTitle';
export { SidebarSection } from './SidebarSection';
export { Sidebar } from './Sidebar';
export type { SidebarPage } from './Sidebar';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
toggle: {
id: 'course-authoring.sidebar.toggle-button',
defaultMessage: 'Toggle',
description: 'Toggle button alt',
},
});
export default messages;

View File

@@ -16,3 +16,4 @@
@import "./alert-message";
@import "./inplace-text-editor/InplaceTextEditor";
@import "./upstream-info-icon/UpstreamInfoIcon";
@import "./sidebar/";

View File

@@ -1,12 +1,14 @@
import { setConfig, getConfig } from '@edx/frontend-platform';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { mockContentTaxonomyTagsData } from '@src/content-tags-drawer/data/api.mocks';
import {
initializeMocks,
render as baseRender,
screen,
waitFor,
} from '../../testUtils';
matchInnerText,
} from '@src/testUtils';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
@@ -33,24 +35,6 @@ mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockContentTaxonomyTagsData.applyMock();
/*
* Utility to get the inner text of an element safely.
*/
const getInnerText = (element: Element | null): string => {
if (!element) {
return '';
}
return (element.textContent ?? '')
.split('\n')
.filter((text) => text && !/^\s+$/.test(text))
.map((text) => text.trim())
.join(' ');
};
const matchInnerText = (nodeName: string, textToMatch: string) => (_: string, element: Element | null) => !!element
&& element.nodeName === nodeName
&& getInnerText(element) === textToMatch;
const render = (usageKey: string, libraryId?: string) => baseRender(<ComponentManagement />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId || mockContentLibrary.libraryId}>

View File

@@ -10,7 +10,7 @@ import {
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { getItemIcon, getComponentStyleColor } from '@src/generic/block-type-utils';
import { ComponentIcon } from '@src/generic/block-type-utils';
import { useClipboard } from '@src/generic/clipboard';
import { getBlockType } from '@src/generic/key-utils';
import { type ContainerHit, Highlight, PublishStatus } from '@src/search-manager';
@@ -171,17 +171,14 @@ const UnitcardPreview = ({ childKeys, showMaxChildren = 5 }: UnitCardPreviewProp
childKeys.slice(0, showMaxChildren).map((usageKey, idx) => {
const blockType = getBlockType(usageKey);
let blockPreview: ReactNode;
let classNames;
if (idx < showMaxChildren - 1 || hiddenChildren <= 0) {
// Show the first N-1 blocks as item icons
// (or all N blocks if no hidden children)
classNames = `rounded p-1 ${getComponentStyleColor(blockType)}`;
blockPreview = (
<Icon
src={getItemIcon(blockType)}
screenReaderText={blockType}
title={usageKey}
<ComponentIcon
blockType={blockType}
iconTitle={usageKey}
/>
);
} else {
@@ -198,7 +195,6 @@ const UnitcardPreview = ({ childKeys, showMaxChildren = 5 }: UnitCardPreviewProp
// A container can have multiple instances of the same block
// eslint-disable-next-line react/no-array-index-key
key={`${usageKey}-${idx}`}
className={classNames}
>
{blockPreview}
</div>

View File

@@ -1,6 +1,6 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
import React from 'react';
import OutlineSideBar from '../../course-outline/outline-sidebar/OutlineSidebar';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import OutlineSideBar from '@src/course-outline/outline-sidebar/OutlineSidebar';
export const CourseAuthoringOutlineSidebarSlot = ({
courseId,
@@ -16,7 +16,7 @@ export const CourseAuthoringOutlineSidebarSlot = ({
sections,
}}
>
<OutlineSideBar courseId={courseId} />
<OutlineSideBar />
</PluginSlot>
);

View File

@@ -338,7 +338,9 @@ export const fetchBlockTypes = async (
}],
});
return results[0].facetDistribution?.block_type ?? {};
const blockTypeFacet = results[0].facetDistribution?.block_type;
return blockTypeFacet ? camelCaseObject(blockTypeFacet) : {};
};
/** Information about a single tag in the tag tree, as returned by fetchAvailableTagOptions() */

View File

@@ -230,3 +230,24 @@ export function createAxiosError({ code, message, path }: { code: number, messag
);
return error;
}
/*
* Utility to get the inner text of an element safely.
*/
const getInnerText = (element: Element | null): string => {
if (!element) {
return '';
}
return (element.textContent ?? '')
.split('\n')
.filter((text) => text && !/^\s+$/.test(text))
.map((text) => text.trim())
.join(' ');
};
export const matchInnerText = (
nodeName: string,
textToMatch: string,
) => (_: string, element: Element | null) => !!element
&& element.nodeName === nodeName
&& getInnerText(element) === textToMatch;