diff --git a/lms/static/scripts/frontend_apps/api-types.ts b/lms/static/scripts/frontend_apps/api-types.ts
index be6a9c4cc1..aec1d56c6b 100644
--- a/lms/static/scripts/frontend_apps/api-types.ts
+++ b/lms/static/scripts/frontend_apps/api-types.ts
@@ -61,13 +61,24 @@ export type Book = {
cover_image: string;
};
-/** Metadata for a chapter within an ebook. */
+/**
+ * Metadata for a table of contents entry within an ebook.
+ *
+ * The name "Chapter" is a misnomer. Although many ebooks do have one
+ * table-of-contents entry per chapter, the entries can be more or less
+ * fine-grained than this.
+ */
export type Chapter = {
page: string;
title: string;
/** Document URL to use for this chapter when creating an assignment. */
url: string;
+
+ /**
+ * The nesting depth of this entry in the table of contents.
+ */
+ level?: number;
};
export type JSTORContentItemInfo = {
diff --git a/lms/static/scripts/frontend_apps/components/ChapterList.tsx b/lms/static/scripts/frontend_apps/components/ChapterList.tsx
index e0bfc5b7fa..96bd3f19b0 100644
--- a/lms/static/scripts/frontend_apps/components/ChapterList.tsx
+++ b/lms/static/scripts/frontend_apps/components/ChapterList.tsx
@@ -3,7 +3,7 @@ import {
Scroll,
ScrollContainer,
} from '@hypothesis/frontend-shared';
-import { useEffect, useMemo, useRef } from 'preact/hooks';
+import { useCallback, useEffect, useMemo, useRef } from 'preact/hooks';
import type { Chapter } from '../api-types';
@@ -23,8 +23,8 @@ export type ChapterListProps = {
};
/**
- * Component that presents a list of chapters from a book and allows the user
- * to choose one for an assignment.
+ * Component that presents a book's table of contents and allows them to
+ * select a range for an assignment.
*/
export default function ChapterList({
chapters,
@@ -58,6 +58,31 @@ export default function ChapterList({
[]
);
+ const renderItem = useCallback((chapter: Chapter, field: keyof Chapter) => {
+ switch (field) {
+ case 'page':
+ return chapter.page;
+ case 'title': {
+ // DataTable doesn't have true support for hierarchical data structures.
+ // Here we indicate the ToC level visually by indenting rows.
+ const level = typeof chapter.level === 'number' ? chapter.level - 1 : 0;
+ return (
+ <>
+
+ {chapter.title}
+ >
+ );
+ }
+ /* istanbul ignore next */
+ default:
+ return '';
+ }
+ }, []);
+
return (
@@ -66,6 +91,7 @@ export default function ChapterList({
title="Table of Contents"
columns={columns}
loading={isLoading}
+ renderItem={renderItem}
rows={chapters}
onSelectRow={onSelectChapter}
onConfirmRow={onUseChapter}
diff --git a/lms/static/scripts/frontend_apps/components/test/ChapterList-test.js b/lms/static/scripts/frontend_apps/components/test/ChapterList-test.js
index 8b548a7a24..4ab9a677ed 100644
--- a/lms/static/scripts/frontend_apps/components/test/ChapterList-test.js
+++ b/lms/static/scripts/frontend_apps/components/test/ChapterList-test.js
@@ -6,10 +6,17 @@ describe('ChapterList', () => {
const chapterData = [
{
title: 'Chapter One',
+ level: 1,
page: '10',
},
{
title: 'Chapter Two',
+ level: 1,
+ page: '20',
+ },
+ {
+ title: 'Chapter Two - Part 1',
+ level: 2,
page: '20',
},
];
@@ -68,6 +75,13 @@ describe('ChapterList', () => {
assert.equal(rows.length, chapterData.length);
assert.equal(rows.at(0).find('td').at(0).text(), chapterData[0].title);
assert.equal(rows.at(0).find('td').at(1).text(), chapterData[0].page);
+
+ const tocLevels = [
+ rows.at(0).find('[data-testid="toc-indent"]').prop('data-level'),
+ rows.at(1).find('[data-testid="toc-indent"]').prop('data-level'),
+ rows.at(2).find('[data-testid="toc-indent"]').prop('data-level'),
+ ];
+ assert.deepEqual(tocLevels, [0, 0, 1]);
});
[true, false].forEach(isLoading => {