Skip to content

Commit b0647f8

Browse files
committed
feat: add ParentBreadcrumbs component
1 parent 4905f3b commit b0647f8

File tree

9 files changed

+269
-132
lines changed

9 files changed

+269
-132
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts",
3+
"note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.",
4+
"results": [
5+
{
6+
"indexUid": "studio_content",
7+
"hits": [
8+
{
9+
"display_name": "Test Unit",
10+
"block_id": "test-unit-9284e2",
11+
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
12+
"type": "library_container",
13+
"breadcrumbs": [
14+
{
15+
"display_name": "Test Library"
16+
}
17+
],
18+
"created": 1742221203.895054,
19+
"modified": 1742221203.895054,
20+
"usage_key": "lct:org:lib:unit:test-unit-9a207",
21+
"block_type": "unit",
22+
"context_key": "lib:Axim:TEST",
23+
"org": "Axim",
24+
"access_id": 15,
25+
"num_children": 0,
26+
"_formatted": {
27+
"display_name": "Test Unit",
28+
"block_id": "test-unit-9284e2",
29+
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
30+
"type": "library_container",
31+
"breadcrumbs": [
32+
{
33+
"display_name": "Test Library"
34+
}
35+
],
36+
"created": "1742221203.895054",
37+
"modified": "1742221203.895054",
38+
"usage_key": "lct:org:lib:unit:test-unit-9a207",
39+
"block_type": "unit",
40+
"context_key": "lib:Axim:TEST",
41+
"org": "Axim",
42+
"access_id": "15",
43+
"num_children": "0",
44+
"published": {
45+
"display_name": "Published Test Unit"
46+
}
47+
},
48+
"published": {
49+
"display_name": "Published Test Unit"
50+
}
51+
}
52+
],
53+
"query": "",
54+
"processingTimeMs": 1,
55+
"limit": 20,
56+
"offset": 0,
57+
"estimatedTotalHits": 10
58+
}
59+
]
60+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
@import "./history-widget/HistoryWidget";
22
@import "./status-widget/StatusWidget";
3+
@import "./parent-breadcrumbs";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.breadcrumb-menu {
2+
button {
3+
padding: 0;
4+
}
5+
}
6+
7+
.parents-breadcrumb {
8+
max-width: 700px;
9+
display: block;
10+
white-space: nowrap;
11+
overflow: hidden;
12+
text-overflow: ellipsis;
13+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { ReactNode } from 'react';
2+
import { useIntl } from '@edx/frontend-platform/i18n';
3+
import { Link } from 'react-router-dom';
4+
import {
5+
Breadcrumb, MenuItem, SelectMenu,
6+
} from '@openedx/paragon';
7+
import { ContainerType } from '@src/generic/key-utils';
8+
import type { ContentLibrary } from '../../data/api';
9+
import messages from './messages';
10+
11+
interface OverflowLinksProps {
12+
to: string | string[];
13+
children: ReactNode | ReactNode[];
14+
containerType: ContainerType;
15+
}
16+
17+
const OverflowLinks = ({ children, to, containerType }: OverflowLinksProps) => {
18+
const intl = useIntl();
19+
20+
if (typeof to === 'string') {
21+
return (
22+
<Link className="parents-breadcrumb link-muted" to={to}>
23+
{children}
24+
</Link>
25+
);
26+
}
27+
28+
if (!Array.isArray(to) || !Array.isArray(children) || to.length !== children.length) {
29+
throw new Error('Both "to" and "children" should have the same length.');
30+
}
31+
32+
// to is string[] that should be converted to overflow menu
33+
const items = to.map((link, index) => (
34+
<MenuItem key={link} to={link} as={Link}>
35+
{children[index]}
36+
</MenuItem>
37+
));
38+
39+
const containerTypeName = containerType === ContainerType.Unit
40+
? intl.formatMessage(messages.breadcrumbsSubsectionsDropdown)
41+
: intl.formatMessage(messages.breadcrumbsSectionsDropdown);
42+
43+
return (
44+
<SelectMenu
45+
className="breadcrumb-menu"
46+
variant="link"
47+
defaultMessage={`${items.length} ${containerTypeName}`}
48+
>
49+
{items}
50+
</SelectMenu>
51+
);
52+
};
53+
54+
interface ContainerParents {
55+
displayName?: string[];
56+
key?: string[];
57+
}
58+
59+
interface ParentBreadcrumbsProps {
60+
libraryData: ContentLibrary;
61+
parents?: ContainerParents;
62+
containerType: ContainerType;
63+
}
64+
65+
export const ParentBreadcrumbs = ({ libraryData, parents, containerType }: ParentBreadcrumbsProps) => {
66+
const intl = useIntl();
67+
const { id: libraryId, title: libraryTitle } = libraryData;
68+
69+
const links: Array<{ label: string | string[], to: string | string[] }> = [
70+
{
71+
label: libraryTitle || '',
72+
to: `/library/${libraryId}`,
73+
},
74+
];
75+
76+
const parentLength = parents?.key?.length || 0;
77+
78+
if (parentLength !== 0 && parents!.key!.length !== parents!.displayName?.length) {
79+
throw new Error('Parents key and displayName arrays must have the same length.');
80+
}
81+
82+
const parentType = containerType === ContainerType.Unit
83+
? 'subsection'
84+
: 'section';
85+
86+
if (parentLength === 0 || !parents) {
87+
// Adding empty breadcrumb to add the last `>` spacer.
88+
links.push({
89+
label: '',
90+
to: '',
91+
});
92+
} else if (parentLength === 1) {
93+
links.push({
94+
label: parents.displayName?.[0] || '',
95+
to: `/library/${libraryId}/${parentType}/${parents.key?.[0]}`,
96+
});
97+
} else {
98+
// Add all parents as a single object containing list of links
99+
// This is converted to overflow menu by OverflowLinks component
100+
links.push({
101+
label: parents.displayName || [],
102+
to: parents.key?.map((parentKey) => `/library/${libraryId}/${parentType}/${parentKey}`) || [],
103+
});
104+
}
105+
106+
return (
107+
<Breadcrumb
108+
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
109+
links={links}
110+
linkAs={OverflowLinks}
111+
/>
112+
);
113+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
breadcrumbsAriaLabel: {
5+
id: 'course-authoring.library-authoring.parent-breadcrumbs.label.text',
6+
defaultMessage: 'Navigation breadcrumbs',
7+
description: 'Aria label for navigation breadcrumbs',
8+
},
9+
breadcrumbsSectionsDropdown: {
10+
id: 'course-authoring.library-authoring.parent-breadcrumbs.dropdown.sections',
11+
defaultMessage: 'Sections',
12+
description: 'Title for dropdown menu containing sections',
13+
},
14+
breadcrumbsSubsectionsDropdown: {
15+
id: 'course-authoring.library-authoring.parent-breadcrumbs.dropdown.subsections',
16+
defaultMessage: 'Subsections',
17+
description: 'Title for dropdown menu containing subsections',
18+
},
19+
});
20+
21+
export default messages;

src/library-authoring/section-subsections/LibrarySubsectionPage.tsx

Lines changed: 12 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { ReactNode, useMemo } from 'react';
21
import { useIntl } from '@edx/frontend-platform/i18n';
32
import { Helmet } from 'react-helmet';
4-
import {
5-
Breadcrumb, Container, MenuItem, SelectMenu,
6-
} from '@openedx/paragon';
7-
import { Link } from 'react-router-dom';
3+
import { Container } from '@openedx/paragon';
4+
5+
import type { ContainerHit } from '@src/search-manager';
86
import { useLibraryContext } from '../common/context/LibraryContext';
97
import { useSidebarContext } from '../common/context/SidebarContext';
108
import { useContentFromSearchIndex, useContentLibrary } from '../data/apiHooks';
@@ -15,41 +13,11 @@ import { ContainerType } from '../../generic/key-utils';
1513
import Header from '../../header';
1614
import SubHeader from '../../generic/sub-header/SubHeader';
1715
import { SubHeaderTitle } from '../LibraryAuthoringPage';
18-
import { messages, subsectionMessages } from './messages';
16+
import { subsectionMessages } from './messages';
1917
import { LibrarySidebar } from '../library-sidebar';
18+
import { ParentBreadcrumbs } from '../generic/parent-breadcrumbs';
2019
import { LibraryContainerChildren } from './LibraryContainerChildren';
2120
import { ContainerEditableTitle, FooterActions, HeaderActions } from '../containers';
22-
import { ContainerHit } from '../../search-manager';
23-
24-
interface OverflowLinksProps {
25-
to: string | string[];
26-
children: ReactNode | ReactNode[];
27-
}
28-
29-
const OverflowLinks = ({ children, to }: OverflowLinksProps) => {
30-
if (typeof to === 'string') {
31-
return (
32-
<Link className="subsection-breadcrumb link-muted" to={to}>
33-
{children}
34-
</Link>
35-
);
36-
}
37-
// to is string[] that should be converted to overflow menu
38-
const items = to?.map((link, index) => (
39-
<MenuItem key={link} to={link} as={Link}>
40-
{children?.[index]}
41-
</MenuItem>
42-
));
43-
return (
44-
<SelectMenu
45-
className="breadcrumb-menu"
46-
variant="link"
47-
defaultMessage={`${items.length} Sections`}
48-
>
49-
{items}
50-
</SelectMenu>
51-
);
52-
};
5321

5422
/** Full library subsection page */
5523
export const LibrarySubsectionPage = () => {
@@ -64,42 +32,6 @@ export const LibrarySubsectionPage = () => {
6432
} = useContentFromSearchIndex(containerId ? [containerId] : []);
6533
const subsectionData = (hits as ContainerHit[])?.[0];
6634

67-
const breadcrumbs = useMemo(() => {
68-
const links: Array<{ label: string | string[], to: string | string[] }> = [
69-
{
70-
label: libraryData?.title || '',
71-
to: `/library/${libraryId}`,
72-
},
73-
];
74-
const sectionLength = subsectionData?.sections?.displayName?.length || 0;
75-
if (sectionLength === 1) {
76-
links.push({
77-
label: subsectionData.sections?.displayName?.[0] || '',
78-
to: `/library/${libraryId}/section/${subsectionData?.sections?.key?.[0]}`,
79-
});
80-
} else if (sectionLength > 1) {
81-
// Add all sections as a single object containing list of links
82-
// This is converted to overflow menu by OverflowLinks component
83-
links.push({
84-
label: subsectionData?.sections?.displayName || '',
85-
to: subsectionData?.sections?.key?.map((link) => `/library/${libraryId}/section/${link}`) || '',
86-
});
87-
} else {
88-
// Adding empty breadcrumb to add the last `>` spacer.
89-
links.push({
90-
label: '',
91-
to: '',
92-
});
93-
}
94-
return (
95-
<Breadcrumb
96-
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
97-
links={links}
98-
linkAs={OverflowLinks}
99-
/>
100-
);
101-
}, [libraryData, subsectionData, libraryId]);
102-
10335
if (!containerId || !libraryId) {
10436
// istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
10537
throw new Error('Rendered without containerId or libraryId URL parameter');
@@ -141,7 +73,13 @@ export const LibrarySubsectionPage = () => {
14173
<div className="px-4 bg-light-200 border-bottom mb-2">
14274
<SubHeader
14375
title={<SubHeaderTitle title={<ContainerEditableTitle containerId={containerId} />} />}
144-
breadcrumbs={breadcrumbs}
76+
breadcrumbs={(
77+
<ParentBreadcrumbs
78+
libraryData={libraryData}
79+
parents={subsectionData.sections}
80+
containerType={subsectionData.blockType}
81+
/>
82+
)}
14583
headerActions={(
14684
<HeaderActions
14785
containerKey={containerId}

src/library-authoring/section-subsections/index.scss

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,3 @@
3030
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .15), 0 .125rem .5rem rgb(0 0 0 / .15);
3131
}
3232
}
33-
34-
.breadcrumb-menu {
35-
button {
36-
padding: 0;
37-
}
38-
}
39-
40-
.subsection-breadcrumb {
41-
max-width: 700px;
42-
display: inline-block;
43-
white-space: nowrap;
44-
overflow: hidden;
45-
text-overflow: ellipsis;
46-
}

0 commit comments

Comments
 (0)