Skip to content

Commit ac9faac

Browse files
authored
feat: add ParentBreadcrumbs component [FC-0090] (#2223)
Adds the `ParentBreadcrumbs` component to show a list of parent containers on the Unit and Subsection breadcrumbs.
1 parent 37313b3 commit ac9faac

File tree

10 files changed

+373
-128
lines changed

10 files changed

+373
-128
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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { fireEvent, render, screen } from '@testing-library/react';
2+
import { IntlProvider } from '@edx/frontend-platform/i18n';
3+
import { BrowserRouter } from 'react-router-dom';
4+
5+
import { ContainerType } from '@src/generic/key-utils';
6+
7+
import { type ContainerParents, ParentBreadcrumbs } from '.';
8+
9+
const mockNavigate = jest.fn();
10+
11+
jest.mock('react-router-dom', () => ({
12+
...jest.requireActual('react-router-dom'),
13+
useNavigate: () => mockNavigate,
14+
}));
15+
16+
const renderComponent = (containerType: ContainerType, parents: ContainerParents) => (
17+
render(
18+
<BrowserRouter>
19+
<IntlProvider locale="en">
20+
<ParentBreadcrumbs
21+
libraryData={{ id: 'library-id', title: 'Library Title' }}
22+
containerType={containerType}
23+
parents={parents}
24+
/>
25+
</IntlProvider>
26+
</BrowserRouter>,
27+
)
28+
);
29+
30+
describe('<ParentBreadcrumbs />', () => {
31+
it('show breadcrumb without parent', async () => {
32+
renderComponent(ContainerType.Unit, { displayName: [], key: [] });
33+
const links = screen.queryAllByRole('link');
34+
expect(links).toHaveLength(2); // Library link + Empty link
35+
36+
expect(links[0]).toHaveTextContent('Library Title');
37+
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');
38+
39+
expect(links[1]).toHaveTextContent(''); // Empty link for no parent
40+
expect(links[1]).toHaveProperty('href', 'http://localhost/');
41+
});
42+
43+
it('show breadcrumb to a unit without one parent', async () => {
44+
renderComponent(ContainerType.Unit, { displayName: ['Parent Subsection'], key: ['subsection-key'] });
45+
const links = screen.queryAllByRole('link');
46+
expect(links).toHaveLength(2); // Library link + Parent Subsection link
47+
48+
expect(links[0]).toHaveTextContent('Library Title');
49+
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');
50+
51+
expect(links[1]).toHaveTextContent('Parent Subsection');
52+
expect(links[1]).toHaveProperty('href', 'http://localhost/library/library-id/subsection/subsection-key');
53+
});
54+
55+
it('show breadcrumb to a subsection without one parent', async () => {
56+
renderComponent(ContainerType.Subsection, { displayName: ['Parent Section'], key: ['section-key'] });
57+
const links = screen.queryAllByRole('link');
58+
expect(links).toHaveLength(2); // Library link + Parent Subsection link
59+
60+
expect(links[0]).toHaveTextContent('Library Title');
61+
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');
62+
63+
expect(links[1]).toHaveTextContent('Parent Section');
64+
expect(links[1]).toHaveProperty('href', 'http://localhost/library/library-id/section/section-key');
65+
});
66+
67+
it('should throw an error if displayName and key arrays are not the same length', async () => {
68+
expect(() => renderComponent(ContainerType.Unit, {
69+
displayName: ['Parent 1'],
70+
key: ['key1', 'key2'],
71+
})).toThrow('Parents key and displayName arrays must have the same length.');
72+
});
73+
74+
it('show breadcrumb with multiple parents', async () => {
75+
renderComponent(ContainerType.Unit, {
76+
displayName: ['Parent Subsection 1', 'Parent Subsection 2'],
77+
key: ['subsection-key-1', 'subsection-key-2'],
78+
});
79+
const links = screen.queryAllByRole('link');
80+
expect(links).toHaveLength(1); // Library link only. Parents are displayed in a dropdown.
81+
82+
expect(links[0]).toHaveTextContent('Library Title');
83+
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');
84+
85+
const dropdown = screen.getByRole('button', { name: '2 Subsections' });
86+
expect(dropdown).toBeInTheDocument();
87+
88+
fireEvent.click(dropdown);
89+
90+
const subsectionLinks = screen.queryAllByRole('link');
91+
expect(subsectionLinks).toHaveLength(2); // Library link only. Parents are displayed in a dropdown.
92+
93+
expect(subsectionLinks[0]).toHaveTextContent('Parent Subsection 1');
94+
expect(subsectionLinks[0]).toHaveProperty('href', 'http://localhost/library/library-id/subsection/subsection-key-1');
95+
96+
expect(subsectionLinks[1]).toHaveTextContent('Parent Subsection 2');
97+
expect(subsectionLinks[1]).toHaveProperty('href', 'http://localhost/library/library-id/subsection/subsection-key-2');
98+
});
99+
});
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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
// istanbul ignore if: this should never happen
29+
if (!Array.isArray(to) || !Array.isArray(children) || to.length !== children.length) {
30+
throw new Error('Both "to" and "children" should have the same length.');
31+
}
32+
33+
// to is string[] that should be converted to overflow menu
34+
const items = to.map((link, index) => (
35+
<MenuItem key={link} to={link} as={Link}>
36+
{children[index]}
37+
</MenuItem>
38+
));
39+
40+
const containerTypeName = containerType === ContainerType.Unit
41+
? intl.formatMessage(messages.breadcrumbsSubsectionsDropdown)
42+
: intl.formatMessage(messages.breadcrumbsSectionsDropdown);
43+
44+
return (
45+
<SelectMenu
46+
className="breadcrumb-menu"
47+
variant="link"
48+
defaultMessage={`${items.length} ${containerTypeName}`}
49+
>
50+
{items}
51+
</SelectMenu>
52+
);
53+
};
54+
55+
export interface ContainerParents {
56+
displayName?: string[];
57+
key?: string[];
58+
}
59+
60+
type ContentLibraryPartial = Pick<ContentLibrary, 'id' | 'title'> & Partial<ContentLibrary>;
61+
62+
interface ParentBreadcrumbsProps {
63+
libraryData: ContentLibraryPartial;
64+
parents?: ContainerParents;
65+
containerType: ContainerType;
66+
}
67+
68+
export const ParentBreadcrumbs = ({ libraryData, parents, containerType }: ParentBreadcrumbsProps) => {
69+
const intl = useIntl();
70+
const { id: libraryId, title: libraryTitle } = libraryData;
71+
72+
const links: Array<{ label: string | string[], to: string | string[], containerType: ContainerType }> = [
73+
{
74+
label: libraryTitle,
75+
to: `/library/${libraryId}`,
76+
containerType,
77+
},
78+
];
79+
80+
const parentLength = parents?.key?.length || 0;
81+
const parentNameLength = parents?.displayName?.length || 0;
82+
83+
if (parentLength !== parentNameLength) {
84+
throw new Error('Parents key and displayName arrays must have the same length.');
85+
}
86+
87+
const parentType = containerType === ContainerType.Unit
88+
? 'subsection'
89+
: 'section';
90+
91+
if (parentLength === 0 || !parents) {
92+
// Adding empty breadcrumb to add the last `>` spacer.
93+
links.push({
94+
label: '',
95+
to: '',
96+
containerType,
97+
});
98+
} else if (parentLength === 1) {
99+
links.push({
100+
label: parents.displayName?.[0] || '',
101+
to: `/library/${libraryId}/${parentType}/${parents.key?.[0]}`,
102+
containerType,
103+
});
104+
} else {
105+
// Add all parents as a single object containing list of links
106+
// This is converted to overflow menu by OverflowLinks component
107+
links.push({
108+
label: parents.displayName || [],
109+
to: parents.key?.map((parentKey) => `/library/${libraryId}/${parentType}/${parentKey}`) || [],
110+
containerType,
111+
});
112+
}
113+
114+
return (
115+
<Breadcrumb
116+
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
117+
links={links}
118+
linkAs={OverflowLinks}
119+
/>
120+
);
121+
};
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;

0 commit comments

Comments
 (0)