Skip to content

Commit 72ae54c

Browse files
committed
fixup! feat: Syllabus page for learner MFE
1 parent 979d613 commit 72ae54c

16 files changed

+603
-311
lines changed

babel.config.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
const { createConfig } = require('@openedx/frontend-build');
22

3-
module.exports = createConfig('babel-preserve-modules');
3+
const config = createConfig('babel-preserve-modules');
4+
config.presets.push('@babel/preset-typescript');
5+
6+
module.exports = config;

package-lock.json

Lines changed: 149 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
"main": "dist/index.js",
1313
"scripts": {
1414
"preinstall": "npm run build",
15-
"build": "fedx-scripts babel src --out-dir dist --ignore **/*.test.jsx,**/*.test.js,**/setupTest.js --copy-files",
16-
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
17-
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
15+
"build": "fedx-scripts babel -x .ts,.tsx src --out-dir dist --ignore **/*.test.tsx,**/*.test.ts,**/setupTest.ts",
16+
"lint": "fedx-scripts eslint --ext .ts --ext .tsx .",
17+
"lint:fix": "fedx-scripts eslint --fix --ext .ts --ext .tsx .",
1818
"test": "fedx-scripts jest --coverage --passWithNoTests"
1919
},
2020
"author": "OpenCraft",
@@ -26,24 +26,19 @@
2626
"bugs": {
2727
"url": "https://github.com/open-craft/frontend-slot-syllabus-tab/issues"
2828
},
29-
"dependencies": {
30-
"@openedx/frontend-plugin-framework": "^1.2.1",
31-
"classnames": "^2.5.1"
32-
},
3329
"peerDependencies": {
3430
"@edx/frontend-platform": "^8.0.0",
35-
"@openedx/paragon": "22.3.0",
36-
"@openedx/paragon": "22.3.0",
31+
"@openedx/frontend-plugin-framework": "^1.2.1",
32+
"@openedx/paragon": "^22.3.0",
3733
"@types/react": "^17.0.39",
38-
"react": "^17.0.0 || ^18.0.0",
39-
"react-dom": "^17.0.0 || ^18.0.0",
40-
"react-router-dom": "6.15.0"
34+
"classnames": "^2.5.1",
35+
"react": "^17.0.00",
36+
"react-dom": "^17.0.0",
37+
"react-router-dom": "^6.15.0"
4138
},
4239
"devDependencies": {
4340
"@openedx/frontend-build": "^14.2.0",
44-
"react": "^17.0.2",
45-
"react-dom": "^17.0.2",
46-
"@openedx/paragon": "22.3.0",
41+
"@edx/typescript-config": "^1.0.1",
4742
"@edx/browserslist-config": "^1.1.1",
4843
"@testing-library/jest-dom": "5.17.0",
4944
"@testing-library/react": "12.1.5",

src/components/BlockCollapsible.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as React from 'react';
2+
import { Collapsible } from '@openedx/paragon';
3+
import { Block } from '../hooks';
4+
import { HighlightMatch } from './HighlightMatch';
5+
6+
interface BlockCollapsibleProps {
7+
block: Block;
8+
query: string;
9+
children: React.ReactNode[];
10+
isOpen: boolean;
11+
onToggle: () => void;
12+
border?: boolean;
13+
}
14+
15+
export const BlockCollapsible = ({
16+
block,
17+
query,
18+
children,
19+
isOpen,
20+
onToggle,
21+
border = false,
22+
}: BlockCollapsibleProps) => {
23+
if (!block) { return null; }
24+
const title = <HighlightMatch query={query} text={block.display_name} />;
25+
if (children && (children.length === 0 || (children.length === 1 && !children[0]))) {
26+
return (
27+
<div className="d-flex flex-column p-2">
28+
{title}
29+
</div>
30+
);
31+
}
32+
return (
33+
<Collapsible
34+
key={block.id}
35+
title={title}
36+
className={`m-0 p-0 ${border ? '' : 'border-0'}`}
37+
open={query ? true : isOpen}
38+
onToggle={onToggle}
39+
>
40+
{children}
41+
</Collapsible>
42+
);
43+
};

src/components/HighlightMatch.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as React from 'react';
2+
3+
interface HighlightMatchProps {
4+
text: string;
5+
query: string;
6+
}
7+
8+
export const HighlightMatch = ({ text, query }: HighlightMatchProps) => {
9+
if (!query) {
10+
return <div>{text}</div>;
11+
}
12+
13+
const parts = text.split(new RegExp(`(${query})`, 'gi'));
14+
15+
return (
16+
<div>
17+
{parts.map((part, index) => (index % 2 === 1 ? <b>{part}</b> : part))}
18+
</div>
19+
);
20+
};

src/components/Syllabus.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { Button, Icon, SearchField } from '@openedx/paragon';
2+
import { Link as LinkIcon } from '@openedx/paragon/icons';
3+
import * as React from 'react';
4+
import { useEffect, useRef, useState } from 'react';
5+
import { useParams } from 'react-router-dom';
6+
import { Block, BlockMap, UsageId, useBlockData, usePanels, } from '../hooks';
7+
import { BlockCollapsible } from './BlockCollapsible';
8+
import { HighlightMatch } from './HighlightMatch';
9+
10+
const PrintSyllabus = ({blockData}) => {
11+
const iframeRef = useRef(null);
12+
const blocks = blockData?.blocks;
13+
const rootBlock = blockData?.blocks[blockData.root];
14+
const makeList = (items: string[] | null) => {
15+
if (!items) { return ""; }
16+
const itemsList = items.filter(item => !!item).join("</li><li>");
17+
return itemsList
18+
? "<ul><li>" + items.filter(item => !!item).join("</li><li>") + "</li></ul>"
19+
: "";
20+
};
21+
const syllabusList = makeList(rootBlock.children.map(sectionId =>
22+
blocks[sectionId].display_name +
23+
makeList(blocks[sectionId].children.map(subsectionId =>
24+
blocks[subsectionId].display_name +
25+
makeList(blocks[subsectionId].children.map(unitId =>
26+
blocks[unitId].display_name +
27+
makeList(blocks[unitId].children.flatMap((blockId) => (
28+
blocks[blockId]?.links?.map(link => link.text)
29+
)))
30+
))
31+
))
32+
));
33+
34+
35+
const srcdoc = `<html>
36+
<head>
37+
<title>Syllabus</title>
38+
<style>
39+
body > ul {
40+
break-inside: avoid;
41+
break-after: auto;
42+
}
43+
ul ul {
44+
break-inside: avoid;
45+
break-before: avoid;
46+
break-after: avoid;
47+
}
48+
li {
49+
break-inside: avoid;
50+
}
51+
ul {
52+
margin: 0;
53+
padding-left: 1rem;
54+
list-style: square;
55+
}
56+
</style>
57+
</head>
58+
<body>
59+
${syllabusList}
60+
</body>
61+
</html>
62+
`;
63+
return (
64+
<div>
65+
<iframe srcDoc={srcdoc} ref={iframeRef} className="d-none"></iframe>
66+
<Button onClick={() => iframeRef.current.contentWindow.print()}>Print</Button>
67+
</div>
68+
)
69+
}
70+
71+
const filteredBlocks = (
72+
rootId: UsageId | undefined,
73+
blocks: BlockMap | null,
74+
query: string | null,
75+
): Set<UsageId> | null => {
76+
if (!query || !blocks || !rootId) {
77+
return null;
78+
}
79+
const matches = new Set<UsageId>();
80+
81+
function filterBlocks(blockId: UsageId) {
82+
const block = blocks[blockId];
83+
if (!block) {
84+
return false;
85+
}
86+
let foundMatch = false;
87+
if (block.display_name.toLowerCase().includes(query.toLowerCase())) {
88+
matches.add(blockId);
89+
foundMatch = true;
90+
}
91+
if (block.children) {
92+
const childMatch = block.children.filter(filterBlocks);
93+
if (childMatch.length > 0) {
94+
matches.add(blockId);
95+
foundMatch = true;
96+
}
97+
}
98+
return foundMatch;
99+
}
100+
101+
filterBlocks(rootId);
102+
103+
return matches;
104+
};
105+
export const Syllabus = () => {
106+
const {courseId} = useParams();
107+
const blockData = useBlockData(courseId);
108+
const blocks = blockData?.blocks;
109+
const rootBlock = blockData?.blocks[blockData.root];
110+
const {
111+
isPanelOpen, toggleAll, togglePanel, allOpen, setOpenPanels,
112+
} = usePanels();
113+
useEffect(() => {
114+
if (blocks) {
115+
setOpenPanels(Object.fromEntries(Object.keys(blocks).map(blockId => [blockId, true])));
116+
}
117+
}, [blocks, setOpenPanels]);
118+
const [query, setQuery] = useState('');
119+
if (!blockData || !blocks || !rootBlock) {
120+
return null;
121+
}
122+
const matches = filteredBlocks(blockData?.root, blocks, query);
123+
const iterMatches = (
124+
block: Block,
125+
children: (blockId: UsageId) => React.ReactNode,
126+
border = false,
127+
) => (
128+
block?.children?.map((blockId: UsageId) => (!matches || matches.has(blockId)) && (
129+
<BlockCollapsible
130+
query={query}
131+
block={blockData.blocks[blockId]}
132+
key={blockId}
133+
onToggle={() => togglePanel(blockId)}
134+
isOpen={isPanelOpen(blockId)}
135+
border={border}
136+
>
137+
{children(blockId)}
138+
</BlockCollapsible>
139+
))
140+
);
141+
142+
return (
143+
<div>
144+
<div className="d-flex justify-content-end mb-2">
145+
<SearchField value={query} onChange={setQuery} onSubmit={setQuery}/>
146+
<Button variant="outline-primary" size="sm" onClick={() => toggleAll()}>
147+
{allOpen
148+
? 'Collapse all'
149+
: 'Expand all'}
150+
</Button>
151+
</div>
152+
{blockData && <PrintSyllabus blockData={blockData}/>}
153+
{rootBlock && iterMatches(rootBlock, (sectionId: UsageId) => (
154+
iterMatches(blocks[sectionId], (subsectionId: UsageId) => (
155+
iterMatches(blocks[subsectionId], (unitId: UsageId) => (
156+
blocks[unitId].children.flatMap((blockId: UsageId) => (
157+
blocks[blockId]?.links?.map(link => (
158+
<div className="d-flex flex-column" key={blockId + link.href}>
159+
<a
160+
className="d-flex ml-2 align-items-center"
161+
href={link.href}
162+
>
163+
<Icon src={LinkIcon} size="xs" className="mr-2"/>
164+
<HighlightMatch query={query} text={link.text}/>
165+
</a>
166+
</div>
167+
))
168+
)).filter(item => !!item)
169+
))
170+
))
171+
), true)}
172+
173+
</div>
174+
);
175+
};

src/components/SyllabusTab.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { fetchOutlineTab } from '@src/course-home/data';
2+
import { TabContainer } from '@src/tab-page';
3+
import * as React from 'react';
4+
import { SYLLABUS_ROUTE } from '../const';
5+
import { Syllabus } from './Syllabus';
6+
7+
export const SyllabusTab = ({ route }: { route: string }) => route === SYLLABUS_ROUTE && (
8+
<TabContainer tab="syllabus" fetch={fetchOutlineTab} slice="courseHome">
9+
<Syllabus />
10+
</TabContainer>
11+
);

src/components/SyllabusTabLink.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import classNames from 'classnames';
2+
import * as React from 'react';
3+
import {
4+
generatePath, Link, useMatch, useParams,
5+
} from 'react-router-dom';
6+
import { useIntl } from '@edx/frontend-platform/i18n';
7+
import { SYLLABUS_ROUTE } from '../const';
8+
import messages from '../messages';
9+
10+
export const SyllabusTabLink = () => {
11+
const intl = useIntl();
12+
const match = useMatch(SYLLABUS_ROUTE);
13+
const { courseId } = useParams();
14+
return match && courseId && (
15+
<Link
16+
key="syllabus"
17+
className={classNames('nav-item flex-shrink-0 nav-link', { active: !!match })}
18+
to={generatePath(SYLLABUS_ROUTE, { courseId })}
19+
>
20+
{intl.formatMessage(messages.syllabusTabTitle)}
21+
</Link>
22+
);
23+
};

src/const.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const SYLLABUS_ROUTE = '/course/:courseId/syllabus';

src/hooks.js

Lines changed: 0 additions & 57 deletions
This file was deleted.

0 commit comments

Comments
 (0)