Skip to content

feat: add ParentBreadcrumbs component [FC-0090] #2223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/library-authoring/__mocks__/unit-single.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"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",
"note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.",
"results": [
{
"indexUid": "studio_content",
"hits": [
{
"display_name": "Test Unit",
"block_id": "test-unit-9284e2",
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
"type": "library_container",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": 1742221203.895054,
"modified": 1742221203.895054,
"usage_key": "lct:org:lib:unit:test-unit-9a207",
"block_type": "unit",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": 15,
"num_children": 0,
"_formatted": {
"display_name": "Test Unit",
"block_id": "test-unit-9284e2",
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
"type": "library_container",
"breadcrumbs": [
{
"display_name": "Test Library"
}
],
"created": "1742221203.895054",
"modified": "1742221203.895054",
"usage_key": "lct:org:lib:unit:test-unit-9a207",
"block_type": "unit",
"context_key": "lib:Axim:TEST",
"org": "Axim",
"access_id": "15",
"num_children": "0",
"published": {
"display_name": "Published Test Unit"
}
},
"published": {
"display_name": "Published Test Unit"
}
}
],
"query": "",
"processingTimeMs": 1,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 10
}
]
}
1 change: 1 addition & 0 deletions src/library-authoring/generic/index.scss
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
@import "./history-widget/HistoryWidget";
@import "./status-widget/StatusWidget";
@import "./parent-breadcrumbs";
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { BrowserRouter } from 'react-router-dom';

import { ContainerType } from '@src/generic/key-utils';

import { type ContainerParents, ParentBreadcrumbs } from '.';

const mockNavigate = jest.fn();

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));

const renderComponent = (containerType: ContainerType, parents: ContainerParents) => (
render(
<BrowserRouter>
<IntlProvider locale="en">
<ParentBreadcrumbs
libraryData={{ id: 'library-id', title: 'Library Title' }}
containerType={containerType}
parents={parents}
/>
</IntlProvider>
</BrowserRouter>,
)
);

describe('<ParentBreadcrumbs />', () => {
it('show breadcrubs without parent', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('show breadcrubs without parent', async () => {
it('show breadcrumb without parent', async () => {

renderComponent(ContainerType.Unit, { displayName: [], key: [] });
const links = screen.queryAllByRole('link');
expect(links).toHaveLength(2); // Library link + Empty link

expect(links[0]).toHaveTextContent('Library Title');
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');

expect(links[1]).toHaveTextContent(''); // Empty link for no parent
expect(links[1]).toHaveProperty('href', 'http://localhost/');
});

it('show breadcrubs to a unit without one parent', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('show breadcrubs to a unit without one parent', async () => {
it('show breadcrumb to a unit without one parent', async () => {

renderComponent(ContainerType.Unit, { displayName: ['Parent Subsection'], key: ['subsection-key'] });
const links = screen.queryAllByRole('link');
expect(links).toHaveLength(2); // Library link + Parent Subsection link

expect(links[0]).toHaveTextContent('Library Title');
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');

expect(links[1]).toHaveTextContent('Parent Subsection');
expect(links[1]).toHaveProperty('href', 'http://localhost/library/library-id/subsection/subsection-key');
});

it('show breadcrubs to a subsection without one parent', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('show breadcrubs to a subsection without one parent', async () => {
it('show breadcrumb to a subsection without one parent', async () => {

renderComponent(ContainerType.Subsection, { displayName: ['Parent Section'], key: ['section-key'] });
const links = screen.queryAllByRole('link');
expect(links).toHaveLength(2); // Library link + Parent Subsection link

expect(links[0]).toHaveTextContent('Library Title');
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');

expect(links[1]).toHaveTextContent('Parent Section');
expect(links[1]).toHaveProperty('href', 'http://localhost/library/library-id/section/section-key');
});

it('should throw an error if displayName and key arrays are not the same length', async () => {
expect(() => renderComponent(ContainerType.Unit, {
displayName: ['Parent 1'],
key: ['key1', 'key2'],
})).toThrow('Parents key and displayName arrays must have the same length.');
});

it('show breadcrubs with multiple parents', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('show breadcrubs with multiple parents', async () => {
it('show breadcrumb with multiple parents', async () => {

renderComponent(ContainerType.Unit, {
displayName: ['Parent Subsection 1', 'Parent Subsection 2'],
key: ['subsection-key-1', 'subsection-key-2'],
});
const links = screen.queryAllByRole('link');
expect(links).toHaveLength(1); // Library link only. Parents are displayed in a dropdown.

expect(links[0]).toHaveTextContent('Library Title');
expect(links[0]).toHaveProperty('href', 'http://localhost/library/library-id');

const dropdown = screen.getByRole('button', { name: '2 Subsections' });
expect(dropdown).toBeInTheDocument();

fireEvent.click(dropdown);

const subsectionLinks = screen.queryAllByRole('link');
expect(subsectionLinks).toHaveLength(2); // Library link only. Parents are displayed in a dropdown.

expect(subsectionLinks[0]).toHaveTextContent('Parent Subsection 1');
expect(subsectionLinks[0]).toHaveProperty('href', 'http://localhost/library/library-id/subsection/subsection-key-1');

expect(subsectionLinks[1]).toHaveTextContent('Parent Subsection 2');
expect(subsectionLinks[1]).toHaveProperty('href', 'http://localhost/library/library-id/subsection/subsection-key-2');
});
});
13 changes: 13 additions & 0 deletions src/library-authoring/generic/parent-breadcrumbs/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.breadcrumb-menu {
button {
padding: 0;
}
}

.parents-breadcrumb {
max-width: 700px;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
121 changes: 121 additions & 0 deletions src/library-authoring/generic/parent-breadcrumbs/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import type { ReactNode } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Link } from 'react-router-dom';
import {
Breadcrumb, MenuItem, SelectMenu,
} from '@openedx/paragon';
import { ContainerType } from '@src/generic/key-utils';
import type { ContentLibrary } from '../../data/api';
import messages from './messages';

interface OverflowLinksProps {
to: string | string[];
children: ReactNode | ReactNode[];
containerType: ContainerType;
}

const OverflowLinks = ({ children, to, containerType }: OverflowLinksProps) => {
const intl = useIntl();

if (typeof to === 'string') {
return (
<Link className="parents-breadcrumb link-muted" to={to}>
{children}
</Link>
);
}

// istanbul ignore if: this should never happen
if (!Array.isArray(to) || !Array.isArray(children) || to.length !== children.length) {
throw new Error('Both "to" and "children" should have the same length.');
}

// to is string[] that should be converted to overflow menu
const items = to.map((link, index) => (
<MenuItem key={link} to={link} as={Link}>
{children[index]}
</MenuItem>
));

const containerTypeName = containerType === ContainerType.Unit
? intl.formatMessage(messages.breadcrumbsSubsectionsDropdown)
: intl.formatMessage(messages.breadcrumbsSectionsDropdown);

return (
<SelectMenu
className="breadcrumb-menu"
variant="link"
defaultMessage={`${items.length} ${containerTypeName}`}
>
{items}
</SelectMenu>
);
};

export interface ContainerParents {
displayName?: string[];
key?: string[];
}

type ContentLibraryPartial = Pick<ContentLibrary, 'id' | 'title'> & Partial<ContentLibrary>;

interface ParentBreadcrumbsProps {
libraryData: ContentLibraryPartial;
parents?: ContainerParents;
containerType: ContainerType;
}

export const ParentBreadcrumbs = ({ libraryData, parents, containerType }: ParentBreadcrumbsProps) => {
const intl = useIntl();
const { id: libraryId, title: libraryTitle } = libraryData;

const links: Array<{ label: string | string[], to: string | string[], containerType: ContainerType }> = [
{
label: libraryTitle,
to: `/library/${libraryId}`,
containerType,
},
];

const parentLength = parents?.key?.length || 0;
const parentNameLength = parents?.displayName?.length || 0;

if (parentLength !== parentNameLength) {
throw new Error('Parents key and displayName arrays must have the same length.');
}

const parentType = containerType === ContainerType.Unit
? 'subsection'
: 'section';

if (parentLength === 0 || !parents) {
// Adding empty breadcrumb to add the last `>` spacer.
links.push({
label: '',
to: '',
containerType,
});
} else if (parentLength === 1) {
links.push({
label: parents.displayName?.[0] || '',
to: `/library/${libraryId}/${parentType}/${parents.key?.[0]}`,
containerType,
});
} else {
// Add all parents as a single object containing list of links
// This is converted to overflow menu by OverflowLinks component
links.push({
label: parents.displayName || [],
to: parents.key?.map((parentKey) => `/library/${libraryId}/${parentType}/${parentKey}`) || [],
containerType,
});
}

return (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={links}
linkAs={OverflowLinks}
/>
);
};
21 changes: 21 additions & 0 deletions src/library-authoring/generic/parent-breadcrumbs/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
breadcrumbsAriaLabel: {
id: 'course-authoring.library-authoring.parent-breadcrumbs.label.text',
defaultMessage: 'Navigation breadcrumbs',
description: 'Aria label for navigation breadcrumbs',
},
breadcrumbsSectionsDropdown: {
id: 'course-authoring.library-authoring.parent-breadcrumbs.dropdown.sections',
defaultMessage: 'Sections',
description: 'Title for dropdown menu containing sections',
},
breadcrumbsSubsectionsDropdown: {
id: 'course-authoring.library-authoring.parent-breadcrumbs.dropdown.subsections',
defaultMessage: 'Subsections',
description: 'Title for dropdown menu containing subsections',
},
});

export default messages;
Loading