From 220924233ee09902b4119eb3d4de873ccd4b2452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 6 Jan 2026 10:13:25 -0300 Subject: [PATCH] feat: course outline sidebar (#2731) implements the new sidebar design for the Course Outline --- .../ContentTagsCollapsibleHelper.jsx | 11 -- .../ContentTagsDrawerHelper.jsx | 1 - .../ContentTagsSnippet.scss | 3 + .../ContentTagsSnippet.test.tsx | 55 +++++++ .../ContentTagsSnippet.tsx | 67 ++++++++ src/content-tags-drawer/TagOutlineIcon.tsx | 20 --- src/content-tags-drawer/data/api.js | 101 ------------ src/content-tags-drawer/data/api.mocks.ts | 32 ++++ .../data/{api.test.js => api.test.ts} | 1 - src/content-tags-drawer/data/api.ts | 109 +++++++++++++ src/content-tags-drawer/data/apiHooks.ts | 19 ++- src/content-tags-drawer/data/types.ts | 2 +- src/content-tags-drawer/index.scss | 1 + src/content-tags-drawer/index.ts | 1 + src/course-outline/CourseOutline.tsx | 34 ++-- .../header-navigations/HeaderActions.test.tsx | 28 ++++ .../header-navigations/HeaderActions.tsx | 37 +++-- .../header-navigations/messages.ts | 15 -- src/course-outline/{index.js => index.ts} | 0 .../{messages.js => messages.ts} | 0 ...r.test.jsx => OutlineHelpSidebar.test.tsx} | 22 ++- ...lineSidebar.jsx => OutlineHelpSidebar.tsx} | 19 +-- .../outline-sidebar/OutlineInfoSidebar.tsx | 59 +++++++ .../outline-sidebar/OutlineSidebar.test.tsx | 81 ++++++++++ .../outline-sidebar/OutlineSidebar.tsx | 44 ++++++ .../outline-sidebar/OutlineSidebarContext.tsx | 89 +++++++++++ .../{messages.js => messages.ts} | 25 +++ .../ComponentCountSnippet.test.tsx | 18 +++ src/generic/block-type-utils/index.scss | 32 ++++ src/generic/block-type-utils/index.tsx | 65 ++++++++ src/generic/block-type-utils/messages.ts | 11 ++ src/generic/sidebar/Sidebar.test.tsx | 142 +++++++++++++++++ src/generic/sidebar/Sidebar.tsx | 148 ++++++++++++++++++ src/generic/sidebar/SidebarContent.test.tsx | 26 +++ src/generic/sidebar/SidebarContent.tsx | 40 +++++ src/generic/sidebar/SidebarSection.tsx | 76 +++++++++ src/generic/sidebar/SidebarTitle.tsx | 24 +++ src/generic/sidebar/index.scss | 53 +++++++ src/generic/sidebar/index.tsx | 5 + src/generic/sidebar/messages.ts | 11 ++ src/generic/styles.scss | 1 + .../ComponentManagement.test.tsx | 24 +-- .../containers/ContainerCard.tsx | 12 +- .../index.tsx | 8 +- src/search-manager/data/api.ts | 4 +- src/testUtils.tsx | 21 +++ 46 files changed, 1349 insertions(+), 248 deletions(-) create mode 100644 src/content-tags-drawer/ContentTagsSnippet.scss create mode 100644 src/content-tags-drawer/ContentTagsSnippet.test.tsx create mode 100644 src/content-tags-drawer/ContentTagsSnippet.tsx delete mode 100644 src/content-tags-drawer/TagOutlineIcon.tsx delete mode 100644 src/content-tags-drawer/data/api.js rename src/content-tags-drawer/data/{api.test.js => api.test.ts} (99%) create mode 100644 src/content-tags-drawer/data/api.ts rename src/course-outline/{index.js => index.ts} (100%) rename src/course-outline/{messages.js => messages.ts} (100%) rename src/course-outline/outline-sidebar/{OutlineSidebar.test.jsx => OutlineHelpSidebar.test.tsx} (78%) rename src/course-outline/outline-sidebar/{OutlineSidebar.jsx => OutlineHelpSidebar.tsx} (80%) create mode 100644 src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/OutlineSidebar.test.tsx create mode 100644 src/course-outline/outline-sidebar/OutlineSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/OutlineSidebarContext.tsx rename src/course-outline/outline-sidebar/{messages.js => messages.ts} (76%) create mode 100644 src/generic/block-type-utils/ComponentCountSnippet.test.tsx create mode 100644 src/generic/block-type-utils/messages.ts create mode 100644 src/generic/sidebar/Sidebar.test.tsx create mode 100644 src/generic/sidebar/Sidebar.tsx create mode 100644 src/generic/sidebar/SidebarContent.test.tsx create mode 100644 src/generic/sidebar/SidebarContent.tsx create mode 100644 src/generic/sidebar/SidebarSection.tsx create mode 100644 src/generic/sidebar/SidebarTitle.tsx create mode 100644 src/generic/sidebar/index.scss create mode 100644 src/generic/sidebar/index.tsx create mode 100644 src/generic/sidebar/messages.ts diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx index 26e27a118..4c41463c2 100644 --- a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -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, - * stagedContentTagsTree: Record, - * contentTagsCount: number, - * checkedTags: any, - * commitStagedTagsToGlobal: () => void, - * updateTags: import('@tanstack/react-query').UseMutationResult< - * any, unknown, { tagsData: Promise; }, unknown - * > * }} */ const useContentTagsCollapsibleHelper = ( diff --git a/src/content-tags-drawer/ContentTagsDrawerHelper.jsx b/src/content-tags-drawer/ContentTagsDrawerHelper.jsx index b77401e9e..554d8c30b 100644 --- a/src/content-tags-drawer/ContentTagsDrawerHelper.jsx +++ b/src/content-tags-drawer/ContentTagsDrawerHelper.jsx @@ -407,7 +407,6 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch tags: tags.contentTags.map(t => t.value), }); }); - // @ts-ignore updateTags.mutate({ tagsData }); }, [tagsByTaxonomy]); diff --git a/src/content-tags-drawer/ContentTagsSnippet.scss b/src/content-tags-drawer/ContentTagsSnippet.scss new file mode 100644 index 000000000..b116cd2ca --- /dev/null +++ b/src/content-tags-drawer/ContentTagsSnippet.scss @@ -0,0 +1,3 @@ +.tag-snippet-chip { + max-width: 260px; +} diff --git a/src/content-tags-drawer/ContentTagsSnippet.test.tsx b/src/content-tags-drawer/ContentTagsSnippet.test.tsx new file mode 100644 index 000000000..801a985de --- /dev/null +++ b/src/content-tags-drawer/ContentTagsSnippet.test.tsx @@ -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('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('should render the tags correctly', async () => { + render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/content-tags-drawer/ContentTagsSnippet.tsx b/src/content-tags-drawer/ContentTagsSnippet.tsx new file mode 100644 index 000000000..8b63636b8 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsSnippet.tsx @@ -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 ( + + {lineageStr} + + ); +}; + +export const ContentTagsSnippet = ({ contentId }: ContentTagsSnippetProps) => { + const { + data, + } = useContentTaxonomyTagsData(contentId); + + if (!data) { + return null; + } + + return ( + + {data.taxonomies.map((taxonomy) => ( +
+

+ {`${taxonomy.name} (${taxonomy.tags.length})`} +

+
+ {taxonomy.tags.map((tag) => ( + + ))} +
+
+ ))} +
+ ); +}; diff --git a/src/content-tags-drawer/TagOutlineIcon.tsx b/src/content-tags-drawer/TagOutlineIcon.tsx deleted file mode 100644 index 7f9d43925..000000000 --- a/src/content-tags-drawer/TagOutlineIcon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -const TagOutlineIcon = (props) => ( - -); - -export default TagOutlineIcon; diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js deleted file mode 100644 index 78ef97512..000000000 --- a/src/content-tags-drawer/data/api.js +++ /dev/null @@ -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} - */ -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} - */ -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} - */ -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} - */ -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} tagsData The list of tags (values) to set on content object - * @returns {Promise} - */ -export async function updateContentTaxonomyTags(contentId, tagsData) { - const url = getContentTaxonomyTagsApiUrl(contentId); - const { data } = await getAuthenticatedHttpClient().put(url, { tagsData }); - return camelCaseObject(data[contentId]); -} diff --git a/src/content-tags-drawer/data/api.mocks.ts b/src/content-tags-drawer/data/api.mocks.ts index 932ae7311..9e7419a13 100644 --- a/src/content-tags-drawer/data/api.mocks.ts +++ b/src/content-tags-drawer/data/api.mocks.ts @@ -15,6 +15,7 @@ export async function mockContentTaxonomyTagsData(contentId: string): Promise jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData); diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.ts similarity index 99% rename from src/content-tags-drawer/data/api.test.js rename to src/content-tags-drawer/data/api.test.ts index 70a02680e..0f9549ff6 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.ts @@ -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'; diff --git a/src/content-tags-drawer/data/api.ts b/src/content-tags-drawer/data/api.ts new file mode 100644 index 000000000..22bbbc2ad --- /dev/null +++ b/src/content-tags-drawer/data/api.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const url = getContentTaxonomyTagsApiUrl(contentId); + const { data } = await getAuthenticatedHttpClient().put(url, { tagsData }); + return camelCaseObject(data[contentId]); +} diff --git a/src/content-tags-drawer/data/apiHooks.ts b/src/content-tags-drawer/data/apiHooks.ts index ca66a5ff2..72940b15d 100644 --- a/src/content-tags-drawer/data/apiHooks.ts +++ b/src/content-tags-drawer/data/apiHooks.ts @@ -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 }) => ( + mutationFn: ({ tagsData }: { tagsData: UpdateTagsData[] }) => ( updateContentTaxonomyTags(contentId, tagsData) ), onSettled: () => { diff --git a/src/content-tags-drawer/data/types.ts b/src/content-tags-drawer/data/types.ts index 127be3dc9..44e671120 100644 --- a/src/content-tags-drawer/data/types.ts +++ b/src/content-tags-drawer/data/types.ts @@ -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 { diff --git a/src/content-tags-drawer/index.scss b/src/content-tags-drawer/index.scss index 0713b4929..549c3e3cc 100644 --- a/src/content-tags-drawer/index.scss +++ b/src/content-tags-drawer/index.scss @@ -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"; diff --git a/src/content-tags-drawer/index.ts b/src/content-tags-drawer/index.ts index 0da1520d0..ff18561c6 100644 --- a/src/content-tags-drawer/index.ts +++ b/src/content-tags-drawer/index.ts @@ -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'; diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 0406b95e5..813ab24f8 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -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 ( - <> + {getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))} @@ -357,14 +357,8 @@ const CourseOutline = () => { /> )}
- - +
+
{showNewActionsBar && ( @@ -384,7 +378,7 @@ const CourseOutline = () => { )} )} -
+
{!errors?.outlineIndexApi && (
{sections.length ? ( @@ -525,15 +519,13 @@ const CourseOutline = () => {
- - - - - +
+ +
{ {toastMessage} )} - +
); }; diff --git a/src/course-outline/header-navigations/HeaderActions.test.tsx b/src/course-outline/header-navigations/HeaderActions.test.tsx index 07fd29b5d..ee5ed1515 100644 --- a/src/course-outline/header-navigations/HeaderActions.test.tsx +++ b/src/course-outline/header-navigations/HeaderActions.test.tsx @@ -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) => render( , + { extraWrapper: OutlineSidebarProvider }, ); describe('', () => { @@ -55,4 +69,18 @@ describe('', () => { 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'); + }); }); diff --git a/src/course-outline/header-navigations/HeaderActions.tsx b/src/course-outline/header-navigations/HeaderActions.tsx index f54f69f60..71367b248 100644 --- a/src/course-outline/header-navigations/HeaderActions.tsx +++ b/src/course-outline/header-navigations/HeaderActions.tsx @@ -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 ( {courseActions.childAddable && ( @@ -74,24 +80,17 @@ const HeaderActions = ({ - - - - {intl.formatMessage(messages.infoButton)} - - - - - - {intl.formatMessage(messages.analyticsButton)} - - - - - - {intl.formatMessage(messages.helpButton)} - - + {Object.entries(sidebarPages).map(([key, page]: [OutlineSidebarPageKeys, SidebarPage]) => ( + setCurrentPageKey(key)} + > + + + {page.title} + + + ))} diff --git a/src/course-outline/header-navigations/messages.ts b/src/course-outline/header-navigations/messages.ts index ec4d91754..b03c8c6ce 100644 --- a/src/course-outline/header-navigations/messages.ts +++ b/src/course-outline/header-navigations/messages.ts @@ -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', diff --git a/src/course-outline/index.js b/src/course-outline/index.ts similarity index 100% rename from src/course-outline/index.js rename to src/course-outline/index.ts diff --git a/src/course-outline/messages.js b/src/course-outline/messages.ts similarity index 100% rename from src/course-outline/messages.js rename to src/course-outline/messages.ts diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.test.jsx b/src/course-outline/outline-sidebar/OutlineHelpSidebar.test.tsx similarity index 78% rename from src/course-outline/outline-sidebar/OutlineSidebar.test.jsx rename to src/course-outline/outline-sidebar/OutlineHelpSidebar.test.tsx index d9af4a979..ca1ede701 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.test.jsx +++ b/src/course-outline/outline-sidebar/OutlineHelpSidebar.test.tsx @@ -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 }) => ( + + {children} + +); + let axiosMock; const mockPathname = '/foo-bar'; -const courseId = '123'; -const renderComponent = (props) => render(, { path: mockPathname }); +const renderComponent = () => render(, { path: mockPathname, extraWrapper }); describe('', () => { beforeEach(() => { diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.jsx b/src/course-outline/outline-sidebar/OutlineHelpSidebar.tsx similarity index 80% rename from src/course-outline/outline-sidebar/OutlineSidebar.jsx rename to src/course-outline/outline-sidebar/OutlineHelpSidebar.tsx index 6d659d238..663b8b7ce 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.jsx +++ b/src/course-outline/outline-sidebar/OutlineHelpSidebar.tsx @@ -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) => (

{description}

))} - {Boolean(link) && Boolean(link.href) && ( + {!!link?.href && ( { ); }; -OutlineSideBar.propTypes = { - courseId: PropTypes.string.isRequired, -}; - -export default OutlineSideBar; +export default OutlineHelpSideBar; diff --git a/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx b/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx new file mode 100644 index 000000000..b75dad5a8 --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineInfoSidebar.tsx @@ -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 ( +
+ + + + {componentData && } + + + + + + +
+ ); +}; diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx new file mode 100644 index 000000000..b0a6d4dec --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineSidebar.test.tsx @@ -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 }) => ( + + + {children} + + +); + +const renderComponent = () => render( + , + { extraWrapper }, +); + +describe('', () => { + 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(); + }); +}); diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.tsx b/src/course-outline/outline-sidebar/OutlineSidebar.tsx new file mode 100644 index 000000000..f9eb13c86 --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineSidebar.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + ); +}; + +export default OutlineSideBar; diff --git a/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx new file mode 100644 index 000000000..fbe03d99b --- /dev/null +++ b/src/course-outline/outline-sidebar/OutlineSidebarContext.tsx @@ -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; + +interface OutlineSidebarContextData { + currentPageKey: OutlineSidebarPageKeys; + setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void; + isOpen: boolean; + open: () => void; + toggle: () => void; + sidebarPages: OutlineSidebarPages; +} + +const OutlineSidebarContext = createContext(undefined); + +export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => { + const intl = useIntl(); + + const [currentPageKey, setCurrentPageKeyState] = useState('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( + () => ({ + currentPageKey, + setCurrentPageKey, + sidebarPages, + isOpen, + open, + toggle, + }), + [ + currentPageKey, + setCurrentPageKey, + sidebarPages, + isOpen, + open, + toggle, + ], + ); + + return ( + + {children} + + ); +}; + +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 ancestor.'); + } + return ctx; +} diff --git a/src/course-outline/outline-sidebar/messages.js b/src/course-outline/outline-sidebar/messages.ts similarity index 76% rename from src/course-outline/outline-sidebar/messages.js rename to src/course-outline/outline-sidebar/messages.ts index 7ef0cf0cf..c1514648f 100644 --- a/src/course-outline/outline-sidebar/messages.js +++ b/src/course-outline/outline-sidebar/messages.ts @@ -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; diff --git a/src/generic/block-type-utils/ComponentCountSnippet.test.tsx b/src/generic/block-type-utils/ComponentCountSnippet.test.tsx new file mode 100644 index 000000000..958ce5837 --- /dev/null +++ b/src/generic/block-type-utils/ComponentCountSnippet.test.tsx @@ -0,0 +1,18 @@ +import { + initializeMocks, matchInnerText, render, screen, +} from '@src/testUtils'; + +import { ComponentCountSnippet } from '.'; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('should render the component', () => { + render(); + expect(screen.getByText('3 Total')).toBeInTheDocument(); + expect(screen.getByText(matchInnerText('DIV', 'text1'))).toBeInTheDocument(); + expect(screen.getByText(matchInnerText('DIV', 'video2'))).toBeInTheDocument(); + }); +}); diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index 70e488214..c68dcf303 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -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; + } } diff --git a/src/generic/block-type-utils/index.tsx b/src/generic/block-type-utils/index.tsx index e88f69cb4..a4e2ce4b6 100644 --- a/src/generic/block-type-utils/index.tsx +++ b/src/generic/block-type-utils/index.tsx @@ -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) => ( +
+ + {text && {text}} +
+); + +interface ComponentCountSnippetProps { + componentData: Record; +} + +export const ComponentCountSnippet = ({ componentData }: ComponentCountSnippetProps) => { + const intl = useIntl(); + + const totalCount = componentData && Object.values(componentData).reduce((acc, val) => acc + val, 0); + + return ( +
+ {componentData && ( +
+ + + {`${totalCount} ${intl.formatMessage(messages.componentTotal)}`} + +
+ )} + {componentData && Object.keys(componentData).map((blockType) => ( + + ))} +
+ ); +}; diff --git a/src/generic/block-type-utils/messages.ts b/src/generic/block-type-utils/messages.ts new file mode 100644 index 000000000..ee6d3b319 --- /dev/null +++ b/src/generic/block-type-utils/messages.ts @@ -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; diff --git a/src/generic/sidebar/Sidebar.test.tsx b/src/generic/sidebar/Sidebar.test.tsx new file mode 100644 index 000000000..97261b273 --- /dev/null +++ b/src/generic/sidebar/Sidebar.test.tsx @@ -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 = () =>
Component 1
; +const Component2 = () =>
Component 2
; +const Icon1 = () =>
Icon 1
; +const Icon2 = () =>
Icon 2
; +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('page1'); + + return ( + + ); +}; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('should render the sidebar', () => { + render(); + + // 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(); + + // 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(); + + 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(); + + 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(); + }); +}); diff --git a/src/generic/sidebar/Sidebar.tsx b/src/generic/sidebar/Sidebar.tsx new file mode 100644 index 000000000..e6974cf05 --- /dev/null +++ b/src/generic/sidebar/Sidebar.tsx @@ -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; + +/** + * Sidebar component + */ +interface SidebarProps { + /** 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 ( + * + *); + * ``` + */ +// eslint-disable-next-line react/function-component-definition +export function Sidebar({ + pages, + currentPageKey, + setCurrentPageKey, + isOpen, + toggle, +}: SidebarProps) { + const intl = useIntl(); + + const SidebarComponent = pages[currentPageKey].component; + + return ( + + {isOpen && !!currentPageKey && ( +
+ + + {pages[currentPageKey].title} + + + + {Object.entries(pages).map(([key, page]) => ( + setCurrentPageKey(key)} + > + + + {page.title} + + + ))} + + + +
+ )} +
+ + + {Object.entries(pages).map(([key, page]) => ( + + ))} + +
+
+ ); +} diff --git a/src/generic/sidebar/SidebarContent.test.tsx b/src/generic/sidebar/SidebarContent.test.tsx new file mode 100644 index 000000000..362d2e2eb --- /dev/null +++ b/src/generic/sidebar/SidebarContent.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { SidebarContent, SidebarSection } from '.'; + +const Icon1 = () =>
Icon 1
; + +describe('', () => { + it('should render the sidebar content', () => { + render( + + +
Content 1
+
+ +
Content 2
+
+
, + ); + + 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(); + }); +}); diff --git a/src/generic/sidebar/SidebarContent.tsx b/src/generic/sidebar/SidebarContent.tsx new file mode 100644 index 000000000..718275bd3 --- /dev/null +++ b/src/generic/sidebar/SidebarContent.tsx @@ -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 + * + * + *

Content 1

+ *
+ * + * + * + *
+ * ``` + */ +export const SidebarContent = ({ children } : SidebarContentProps) => ( + + {Array.isArray(children) ? children.map((child, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {child} + {index !== children.length - 1 &&
} +
+ )) : children} +
+); diff --git a/src/generic/sidebar/SidebarSection.tsx b/src/generic/sidebar/SidebarSection.tsx new file mode 100644 index 000000000..5a6fed522 --- /dev/null +++ b/src/generic/sidebar/SidebarSection.tsx @@ -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 + * + *

Content 1

+ *
+ * ``` + */ +export const SidebarSection = ({ + title, icon, actions, children, +}: SidebarSectionProps) => ( + + + {icon && } +

+ {title} +

+ {actions && ( + + + + {actions.map((action) => ( + + {action.label} + + ))} + + + )} +
+ {children} +
+); diff --git a/src/generic/sidebar/SidebarTitle.tsx b/src/generic/sidebar/SidebarTitle.tsx new file mode 100644 index 000000000..93b29c373 --- /dev/null +++ b/src/generic/sidebar/SidebarTitle.tsx @@ -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) => ( + + +

{title}

+
+); diff --git a/src/generic/sidebar/index.scss b/src/generic/sidebar/index.scss new file mode 100644 index 000000000..084f8b257 --- /dev/null +++ b/src/generic/sidebar/index.scss @@ -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); + } + } + } +} diff --git a/src/generic/sidebar/index.tsx b/src/generic/sidebar/index.tsx new file mode 100644 index 000000000..2fccb9515 --- /dev/null +++ b/src/generic/sidebar/index.tsx @@ -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'; diff --git a/src/generic/sidebar/messages.ts b/src/generic/sidebar/messages.ts new file mode 100644 index 000000000..05657c30a --- /dev/null +++ b/src/generic/sidebar/messages.ts @@ -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; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 49e1cf64b..4764f9bb8 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -16,3 +16,4 @@ @import "./alert-message"; @import "./inplace-text-editor/InplaceTextEditor"; @import "./upstream-info-icon/UpstreamInfoIcon"; +@import "./sidebar/"; diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx index 4cfd524aa..4d5051ff5 100644 --- a/src/library-authoring/component-info/ComponentManagement.test.tsx +++ b/src/library-authoring/component-info/ComponentManagement.test.tsx @@ -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(, { extraWrapper: ({ children }) => ( diff --git a/src/library-authoring/containers/ContainerCard.tsx b/src/library-authoring/containers/ContainerCard.tsx index 00fde2c8d..cc012bd27 100644 --- a/src/library-authoring/containers/ContainerCard.tsx +++ b/src/library-authoring/containers/ContainerCard.tsx @@ -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 = ( - ); } 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} diff --git a/src/plugin-slots/CourseAuthoringOutlineSidebarSlot/index.tsx b/src/plugin-slots/CourseAuthoringOutlineSidebarSlot/index.tsx index 06682dbec..1386d7aeb 100644 --- a/src/plugin-slots/CourseAuthoringOutlineSidebarSlot/index.tsx +++ b/src/plugin-slots/CourseAuthoringOutlineSidebarSlot/index.tsx @@ -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, }} > - + ); diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 80e4fea0d..18e3ed719 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -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() */ diff --git a/src/testUtils.tsx b/src/testUtils.tsx index cd32abe4d..204950490 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -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;