feat: course outline sidebar (#2731)
implements the new sidebar design for the Course Outline
This commit is contained in:
@@ -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 = (
|
||||
|
||||
@@ -407,7 +407,6 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
|
||||
tags: tags.contentTags.map(t => t.value),
|
||||
});
|
||||
});
|
||||
// @ts-ignore
|
||||
updateTags.mutate({ tagsData });
|
||||
}, [tagsByTaxonomy]);
|
||||
|
||||
|
||||
3
src/content-tags-drawer/ContentTagsSnippet.scss
Normal file
3
src/content-tags-drawer/ContentTagsSnippet.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.tag-snippet-chip {
|
||||
max-width: 260px;
|
||||
}
|
||||
55
src/content-tags-drawer/ContentTagsSnippet.test.tsx
Normal file
55
src/content-tags-drawer/ContentTagsSnippet.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
67
src/content-tags-drawer/ContentTagsSnippet.tsx
Normal file
67
src/content-tags-drawer/ContentTagsSnippet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
109
src/content-tags-drawer/data/api.ts
Normal file
109
src/content-tags-drawer/data/api.ts
Normal 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]);
|
||||
}
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -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;
|
||||
59
src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx
Normal file
59
src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
src/course-outline/outline-sidebar/OutlineSidebar.test.tsx
Normal file
81
src/course-outline/outline-sidebar/OutlineSidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
44
src/course-outline/outline-sidebar/OutlineSidebar.tsx
Normal file
44
src/course-outline/outline-sidebar/OutlineSidebar.tsx
Normal 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;
|
||||
89
src/course-outline/outline-sidebar/OutlineSidebarContext.tsx
Normal file
89
src/course-outline/outline-sidebar/OutlineSidebarContext.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
18
src/generic/block-type-utils/ComponentCountSnippet.test.tsx
Normal file
18
src/generic/block-type-utils/ComponentCountSnippet.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
11
src/generic/block-type-utils/messages.ts
Normal file
11
src/generic/block-type-utils/messages.ts
Normal 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;
|
||||
142
src/generic/sidebar/Sidebar.test.tsx
Normal file
142
src/generic/sidebar/Sidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
148
src/generic/sidebar/Sidebar.tsx
Normal file
148
src/generic/sidebar/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/generic/sidebar/SidebarContent.test.tsx
Normal file
26
src/generic/sidebar/SidebarContent.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
40
src/generic/sidebar/SidebarContent.tsx
Normal file
40
src/generic/sidebar/SidebarContent.tsx
Normal 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>
|
||||
);
|
||||
76
src/generic/sidebar/SidebarSection.tsx
Normal file
76
src/generic/sidebar/SidebarSection.tsx
Normal 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>
|
||||
);
|
||||
24
src/generic/sidebar/SidebarTitle.tsx
Normal file
24
src/generic/sidebar/SidebarTitle.tsx
Normal 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>
|
||||
);
|
||||
53
src/generic/sidebar/index.scss
Normal file
53
src/generic/sidebar/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/generic/sidebar/index.tsx
Normal file
5
src/generic/sidebar/index.tsx
Normal 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';
|
||||
11
src/generic/sidebar/messages.ts
Normal file
11
src/generic/sidebar/messages.ts
Normal 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;
|
||||
@@ -16,3 +16,4 @@
|
||||
@import "./alert-message";
|
||||
@import "./inplace-text-editor/InplaceTextEditor";
|
||||
@import "./upstream-info-icon/UpstreamInfoIcon";
|
||||
@import "./sidebar/";
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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() */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user