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 => {