Compare commits
4 Commits
dependabot
...
djoy/hacka
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cddde17a0d | ||
|
|
6d35d33559 | ||
|
|
d61fe28e4e | ||
|
|
76a85224b8 |
7961
package-lock.json
generated
7961
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@
|
|||||||
"truncate-html": "1.0.3"
|
"truncate-html": "1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edx/frontend-build": "5.5.5",
|
"@edx/frontend-build": "git+https://github.com/edx/frontend-build.git#alpha",
|
||||||
"@testing-library/dom": "7.16.3",
|
"@testing-library/dom": "7.16.3",
|
||||||
"@testing-library/jest-dom": "5.10.1",
|
"@testing-library/jest-dom": "5.10.1",
|
||||||
"@testing-library/react": "10.3.0",
|
"@testing-library/react": "10.3.0",
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ function Course({
|
|||||||
<AlertList topic="sequence" />
|
<AlertList topic="sequence" />
|
||||||
<Sequence
|
<Sequence
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
|
sectionId={section ? section.id : null}
|
||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
unitNavigationHandler={unitNavigationHandler}
|
unitNavigationHandler={unitNavigationHandler}
|
||||||
|
|||||||
96
src/courseware/course/sequence/InContextSidebar.jsx
Normal file
96
src/courseware/course/sequence/InContextSidebar.jsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Button, Form } from '@edx/paragon';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPlug } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import PluginComponent from '../../../plugin-test/PluginComponent';
|
||||||
|
import { useModel, useModels } from '../../../generic/model-store';
|
||||||
|
import { loadDynamicScript, loadScriptComponent } from '../../../plugin-test/utils';
|
||||||
|
|
||||||
|
export default function InContextSidebar({
|
||||||
|
courseId, sectionId, unitId, sequenceId,
|
||||||
|
}) {
|
||||||
|
const course = useModel('coursewareMeta', courseId);
|
||||||
|
const section = useModel('sections', sectionId);
|
||||||
|
const unit = useModel('units', unitId);
|
||||||
|
const sequence = useModel('sequences', sequenceId);
|
||||||
|
|
||||||
|
const sections = useModels('sections', course ? course.sectionIds : []);
|
||||||
|
let sequenceIds = [];
|
||||||
|
if (sections) {
|
||||||
|
sections.forEach((curSection) => {
|
||||||
|
sequenceIds = sequenceIds.concat(curSection.sequenceIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sequences = useModels('sequences', sequenceIds);
|
||||||
|
let unitIds = [];
|
||||||
|
if (sequences) {
|
||||||
|
sequences.forEach((curSequence) => {
|
||||||
|
unitIds = unitIds.concat(curSequence.unitIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const units = useModels('units', unitIds);
|
||||||
|
|
||||||
|
const [pluginUrl, setPluginUrl] = useState('');
|
||||||
|
const handlePluginUrlChange = (event) => {
|
||||||
|
setPluginUrl(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePluginAdd = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
loadDynamicScript(pluginUrl).then(() => {
|
||||||
|
const pluginFunction = loadScriptComponent('plugin', './Pomodoro');
|
||||||
|
pluginFunction();
|
||||||
|
setPluginUrl('');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!course || !section || !unit || !sequence) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="pt-3">
|
||||||
|
<div className="mb-3">
|
||||||
|
<Form onSubmit={handlePluginAdd}>
|
||||||
|
<h4><FontAwesomeIcon icon={faPlug} /> Add a plugin?</h4>
|
||||||
|
<Form.Control className="mb-1" onChange={handlePluginUrlChange} placeholder="URL to your plugin..." type="text" />
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
{getConfig().plugins.slots.coursewareSidebar.map((plugin) => (
|
||||||
|
<React.Fragment key={`plugin-${plugin.url}-${plugin.module}`}>
|
||||||
|
<PluginComponent
|
||||||
|
course={course}
|
||||||
|
activeUnit={unit}
|
||||||
|
activeSequence={sequence}
|
||||||
|
activeSection={section}
|
||||||
|
unitIds={unitIds}
|
||||||
|
units={units}
|
||||||
|
sequences={sequences}
|
||||||
|
sections={sections}
|
||||||
|
plugin={plugin}
|
||||||
|
/>
|
||||||
|
<hr />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InContextSidebar.propTypes = {
|
||||||
|
courseId: PropTypes.string,
|
||||||
|
sectionId: PropTypes.string,
|
||||||
|
unitId: PropTypes.string,
|
||||||
|
sequenceId: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
InContextSidebar.defaultProps = {
|
||||||
|
courseId: null,
|
||||||
|
sectionId: null,
|
||||||
|
unitId: null,
|
||||||
|
sequenceId: null,
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { history } from '@edx/frontend-platform';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
import { Col, Container, Row } from '@edx/paragon';
|
||||||
import PageLoading from '../../../generic/PageLoading';
|
import PageLoading from '../../../generic/PageLoading';
|
||||||
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages';
|
||||||
import { useModel } from '../../../generic/model-store';
|
import { useModel } from '../../../generic/model-store';
|
||||||
@@ -23,6 +24,7 @@ import CourseLicense from '../course-license';
|
|||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||||
import SequenceContent from './SequenceContent';
|
import SequenceContent from './SequenceContent';
|
||||||
|
import InContextSidebar from './InContextSidebar';
|
||||||
|
|
||||||
function REV1512Flyover({ toggleREV1512Flyover }) {
|
function REV1512Flyover({ toggleREV1512Flyover }) {
|
||||||
// This component should be reverted after the REV1512 experiment
|
// This component should be reverted after the REV1512 experiment
|
||||||
@@ -183,6 +185,7 @@ REV1512FlyoverMobile.propTypes = {
|
|||||||
|
|
||||||
function Sequence({
|
function Sequence({
|
||||||
unitId,
|
unitId,
|
||||||
|
sectionId,
|
||||||
sequenceId,
|
sequenceId,
|
||||||
courseId,
|
courseId,
|
||||||
unitNavigationHandler,
|
unitNavigationHandler,
|
||||||
@@ -309,61 +312,74 @@ function Sequence({
|
|||||||
if (sequenceStatus === 'loaded') {
|
if (sequenceStatus === 'loaded') {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
|
<Container className="sequence-container px-3">
|
||||||
<div className="sequence" style={{ width: '100%' }}>
|
<Row>
|
||||||
<SequenceNavigation
|
<Col sm={8} xs={12} className="sequence px-0">
|
||||||
sequenceId={sequenceId}
|
<SequenceNavigation
|
||||||
unitId={unitId}
|
|
||||||
className="mb-4"
|
|
||||||
toggleREV1512Flyover={toggleREV1512Flyover} /* This line should be reverted after REV1512 experiment */
|
|
||||||
REV1512FlyoverEnabled={REV1512FlyoverEnabled} /* This line should be reverted after REV1512 experiment */
|
|
||||||
isREV1512FlyoverVisible={isREV1512FlyoverVisible} /* should be reverted after REV1512 experiment */
|
|
||||||
nextSequenceHandler={() => {
|
|
||||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
|
||||||
handleNext();
|
|
||||||
}}
|
|
||||||
onNavigate={(destinationUnitId) => {
|
|
||||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
|
||||||
handleNavigate(destinationUnitId);
|
|
||||||
}}
|
|
||||||
previousSequenceHandler={() => {
|
|
||||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
|
||||||
handlePrevious();
|
|
||||||
}}
|
|
||||||
goToCourseExitPage={() => goToCourseExitPage()}
|
|
||||||
/>
|
|
||||||
<div className="unit-container flex-grow-1">
|
|
||||||
<SequenceContent
|
|
||||||
courseId={courseId}
|
|
||||||
gated={gated}
|
|
||||||
sequenceId={sequenceId}
|
sequenceId={sequenceId}
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
unitLoadedHandler={handleUnitLoaded}
|
className="mb-4"
|
||||||
/>
|
/* This line should be reverted after REV1512 experiment */
|
||||||
{unitHasLoaded && (
|
toggleREV1512Flyover={toggleREV1512Flyover}
|
||||||
<UnitNavigation
|
/* This line should be reverted after REV1512 experiment */
|
||||||
sequenceId={sequenceId}
|
REV1512FlyoverEnabled={REV1512FlyoverEnabled}
|
||||||
unitId={unitId}
|
/* should be reverted after REV1512 experiment */
|
||||||
onClickPrevious={() => {
|
isREV1512FlyoverVisible={isREV1512FlyoverVisible}
|
||||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
nextSequenceHandler={() => {
|
||||||
handlePrevious();
|
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||||
}}
|
|
||||||
onClickNext={() => {
|
|
||||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
|
||||||
handleNext();
|
handleNext();
|
||||||
}}
|
}}
|
||||||
|
onNavigate={(destinationUnitId) => {
|
||||||
|
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||||
|
handleNavigate(destinationUnitId);
|
||||||
|
}}
|
||||||
|
previousSequenceHandler={() => {
|
||||||
|
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||||
|
handlePrevious();
|
||||||
|
}}
|
||||||
goToCourseExitPage={() => goToCourseExitPage()}
|
goToCourseExitPage={() => goToCourseExitPage()}
|
||||||
/>
|
/>
|
||||||
)}
|
<div className="unit-container flex-grow-1">
|
||||||
</div>
|
<SequenceContent
|
||||||
</div>
|
courseId={courseId}
|
||||||
{/* This block of code should be reverted post REV1512 experiment */}
|
gated={gated}
|
||||||
{REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
|
sequenceId={sequenceId}
|
||||||
isMobile
|
unitId={unitId}
|
||||||
? <REV1512FlyoverMobile toggleREV1512Flyover={toggleREV1512Flyover} />
|
unitLoadedHandler={handleUnitLoaded}
|
||||||
: <REV1512Flyover toggleREV1512Flyover={toggleREV1512Flyover} />
|
/>
|
||||||
)}
|
{unitHasLoaded && (
|
||||||
</div>
|
<UnitNavigation
|
||||||
|
sequenceId={sequenceId}
|
||||||
|
unitId={unitId}
|
||||||
|
onClickPrevious={() => {
|
||||||
|
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||||
|
handlePrevious();
|
||||||
|
}}
|
||||||
|
onClickNext={() => {
|
||||||
|
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||||
|
handleNext();
|
||||||
|
}}
|
||||||
|
goToCourseExitPage={() => goToCourseExitPage()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col className="ml-3 sequence">
|
||||||
|
<InContextSidebar
|
||||||
|
courseId={courseId}
|
||||||
|
sectionId={sectionId}
|
||||||
|
sequenceId={sequenceId}
|
||||||
|
unitId={unitId}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
{/* This block of code should be reverted post REV1512 experiment */}
|
||||||
|
{REV1512FlyoverEnabled && isREV1512FlyoverVisible() && (
|
||||||
|
isMobile
|
||||||
|
? <REV1512FlyoverMobile toggleREV1512Flyover={toggleREV1512Flyover} />
|
||||||
|
: <REV1512Flyover toggleREV1512Flyover={toggleREV1512Flyover} />
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
<CourseLicense license={course.license || undefined} />
|
<CourseLicense license={course.license || undefined} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -380,6 +396,7 @@ function Sequence({
|
|||||||
Sequence.propTypes = {
|
Sequence.propTypes = {
|
||||||
unitId: PropTypes.string,
|
unitId: PropTypes.string,
|
||||||
sequenceId: PropTypes.string,
|
sequenceId: PropTypes.string,
|
||||||
|
sectionId: PropTypes.string,
|
||||||
courseId: PropTypes.string.isRequired,
|
courseId: PropTypes.string.isRequired,
|
||||||
unitNavigationHandler: PropTypes.func.isRequired,
|
unitNavigationHandler: PropTypes.func.isRequired,
|
||||||
nextSequenceHandler: PropTypes.func.isRequired,
|
nextSequenceHandler: PropTypes.func.isRequired,
|
||||||
@@ -392,6 +409,7 @@ Sequence.propTypes = {
|
|||||||
|
|
||||||
Sequence.defaultProps = {
|
Sequence.defaultProps = {
|
||||||
sequenceId: null,
|
sequenceId: null,
|
||||||
|
sectionId: null,
|
||||||
unitId: null,
|
unitId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import 'regenerator-runtime/runtime';
|
|||||||
import {
|
import {
|
||||||
APP_INIT_ERROR, APP_READY, subscribe, initialize,
|
APP_INIT_ERROR, APP_READY, subscribe, initialize,
|
||||||
mergeConfig,
|
mergeConfig,
|
||||||
|
getConfig,
|
||||||
} from '@edx/frontend-platform';
|
} from '@edx/frontend-platform';
|
||||||
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
|
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
|
|
||||||
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
|
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||||
|
|
||||||
@@ -27,12 +28,16 @@ import { TabContainer } from './tab-page';
|
|||||||
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
|
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
|
||||||
import { fetchCourse } from './courseware/data';
|
import { fetchCourse } from './courseware/data';
|
||||||
import initializeStore from './store';
|
import initializeStore from './store';
|
||||||
|
import PluginTestPage from './plugin-test/PluginTestPage';
|
||||||
|
import { COMPONENT } from './plugin-test/PluginComponent';
|
||||||
|
import { loadDynamicScript, loadScriptComponent } from './plugin-test/utils';
|
||||||
|
|
||||||
subscribe(APP_READY, () => {
|
subscribe(APP_READY, () => {
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<AppProvider store={initializeStore()}>
|
<AppProvider store={initializeStore()}>
|
||||||
<UserMessagesProvider>
|
<UserMessagesProvider>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route exact path="/" component={PluginTestPage} />
|
||||||
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
||||||
<PageRoute path="/course/:courseId/home">
|
<PageRoute path="/course/:courseId/home">
|
||||||
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
||||||
@@ -68,6 +73,13 @@ subscribe(APP_READY, () => {
|
|||||||
</AppProvider>,
|
</AppProvider>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
getConfig().plugins.scripts.forEach((plugin) => {
|
||||||
|
loadDynamicScript(plugin.url).then(() => {
|
||||||
|
const pluginFunction = loadScriptComponent(plugin.scope, plugin.module);
|
||||||
|
pluginFunction();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
subscribe(APP_INIT_ERROR, (error) => {
|
subscribe(APP_INIT_ERROR, (error) => {
|
||||||
@@ -90,6 +102,43 @@ initialize({
|
|||||||
SUPPORT_URL_VERIFIED_CERTIFICATE: process.env.SUPPORT_URL_VERIFIED_CERTIFICATE || null,
|
SUPPORT_URL_VERIFIED_CERTIFICATE: process.env.SUPPORT_URL_VERIFIED_CERTIFICATE || null,
|
||||||
TWITTER_HASHTAG: process.env.TWITTER_HASHTAG || null,
|
TWITTER_HASHTAG: process.env.TWITTER_HASHTAG || null,
|
||||||
TWITTER_URL: process.env.TWITTER_URL || null,
|
TWITTER_URL: process.env.TWITTER_URL || null,
|
||||||
|
plugins: {
|
||||||
|
scripts: [
|
||||||
|
// {
|
||||||
|
// url: 'http://localhost:7331/remoteEntry.js',
|
||||||
|
// scope: 'plugin',
|
||||||
|
// module: './Pomodoro',
|
||||||
|
// type: SCRIPT,
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
slots: {
|
||||||
|
header: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:7331/remoteEntry.js',
|
||||||
|
scope: 'plugin',
|
||||||
|
module: './Banner',
|
||||||
|
type: COMPONENT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
coursewareSidebar: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:7331/remoteEntry.js',
|
||||||
|
scope: 'plugin',
|
||||||
|
module: './Discussions',
|
||||||
|
type: COMPONENT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'http://localhost:7331/remoteEntry.js',
|
||||||
|
scope: 'plugin',
|
||||||
|
module: './Calendly',
|
||||||
|
type: COMPONENT,
|
||||||
|
props: {
|
||||||
|
username: 'djoy',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}, 'LearnerAppConfig');
|
}, 'LearnerAppConfig');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,8 +95,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sequence-container {
|
.sequence-container {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
margin-bottom: 4rem;
|
margin-bottom: 4rem;
|
||||||
|
|
||||||
@@ -111,6 +109,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sequence {
|
.sequence {
|
||||||
|
width: 100%;
|
||||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||||
border: solid 1px #EAEAEA;
|
border: solid 1px #EAEAEA;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -302,9 +301,9 @@
|
|||||||
.unit-iframe-wrapper {
|
.unit-iframe-wrapper {
|
||||||
margin: 0 -20px 2rem;
|
margin: 0 -20px 2rem;
|
||||||
|
|
||||||
@media (min-width: 830px) {
|
// @media (min-width: 830px) {
|
||||||
margin: 0 -40px 2rem;
|
// margin: 0 -20px 2rem;
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
#unit-iframe {
|
#unit-iframe {
|
||||||
|
|||||||
105
src/plugin-test/PluginComponent.jsx
Normal file
105
src/plugin-test/PluginComponent.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
|
import PageLoading from '../generic/PageLoading';
|
||||||
|
|
||||||
|
import messages from './messages';
|
||||||
|
import { loadDynamicScript, loadScriptComponent } from './utils';
|
||||||
|
import PluginErrorBoundary from './PluginErrorBoundary';
|
||||||
|
|
||||||
|
// These are intended to represent three different plugin types. They're not fully used yet.
|
||||||
|
// Different plugins of different types would have different loading functionality.
|
||||||
|
export const COMPONENT = 'component'; // loads JS script then loads react component from its exports
|
||||||
|
export const SCRIPT = 'script'; // loads JS script
|
||||||
|
export const IFRAME = 'iframe'; // loads iframe at the URL, rather than loading a JS file.
|
||||||
|
export const LTI = 'lti'; // loads LTI iframe at the URL, rather than loading a JS file.
|
||||||
|
|
||||||
|
const useDynamicScript = (url) => {
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
const [failed, setFailed] = React.useState(false);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const [element, setElement] = React.useState(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!url) {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setReady(false);
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
loadDynamicScript(url).then((el) => {
|
||||||
|
setElement(el);
|
||||||
|
setReady(true);
|
||||||
|
}).catch(() => {
|
||||||
|
setReady(false);
|
||||||
|
setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// TODO: How to do reference checking on this script to only remove it when the last
|
||||||
|
// component using it has unmounted?
|
||||||
|
// document.head.removeChild(element);
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function PluginComponent({ plugin, intl, ...props }) {
|
||||||
|
const url = plugin ? plugin.url : null;
|
||||||
|
const { ready, failed } = useDynamicScript(url);
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<PageLoading
|
||||||
|
srMessage={intl.formatMessage(messages.loading)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = React.lazy(
|
||||||
|
loadScriptComponent(plugin.scope, plugin.module),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={(
|
||||||
|
<PageLoading
|
||||||
|
srMessage={intl.formatMessage(messages.loading)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PluginErrorBoundary>
|
||||||
|
<Component {...props} {...plugin.props} />
|
||||||
|
</PluginErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginComponent.propTypes = {
|
||||||
|
plugin: PropTypes.shape({
|
||||||
|
scope: PropTypes.string.isRequired,
|
||||||
|
module: PropTypes.string.isRequired,
|
||||||
|
url: PropTypes.string.isRequired,
|
||||||
|
type: PropTypes.oneOf([COMPONENT, SCRIPT, IFRAME]).isRequired,
|
||||||
|
props: PropTypes.object,
|
||||||
|
}),
|
||||||
|
intl: intlShape.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
PluginComponent.defaultProps = {
|
||||||
|
plugin: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default injectIntl(PluginComponent);
|
||||||
36
src/plugin-test/PluginErrorBoundary.jsx
Normal file
36
src/plugin-test/PluginErrorBoundary.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export default class PluginErrorBoundary extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError() {
|
||||||
|
// Update state so the next render will show the fallback UI.
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// You can render any custom fallback UI
|
||||||
|
return <div>Plugin failed to load.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginErrorBoundary.propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
PluginErrorBoundary.defaultProps = {
|
||||||
|
children: null,
|
||||||
|
};
|
||||||
30
src/plugin-test/PluginTestPage.jsx
Normal file
30
src/plugin-test/PluginTestPage.jsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
import PluginComponent from './PluginComponent';
|
||||||
|
|
||||||
|
function PluginTestPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Dynamic Plugin Host</h1>
|
||||||
|
<h2>App 1</h2>
|
||||||
|
<p>
|
||||||
|
The Dynamic Plugin will take advantage Module Federation{' '}
|
||||||
|
<strong>remotes</strong> and <strong>exposes</strong>. It will no load
|
||||||
|
components that have been loaded already.
|
||||||
|
</p>
|
||||||
|
{/* <Button className="mr-3" onClick={setPluginOne}>Load Plugin One</Button>
|
||||||
|
<Button onClick={setPluginTwo}>Load Plugin Two</Button> */}
|
||||||
|
<div style={{ marginTop: '2em' }}>
|
||||||
|
{getConfig().plugins.slots.testPage.map((plugin) => (
|
||||||
|
<PluginComponent
|
||||||
|
key={`plugin-${plugin.url}-${plugin.module}`}
|
||||||
|
plugin={plugin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PluginTestPage;
|
||||||
11
src/plugin-test/messages.js
Normal file
11
src/plugin-test/messages.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
loading: {
|
||||||
|
id: 'plugin.loading',
|
||||||
|
defaultMessage: 'Loading',
|
||||||
|
description: 'Message shown to screen readers while a plugin is loading.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
||||||
37
src/plugin-test/utils.js
Normal file
37
src/plugin-test/utils.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export function loadDynamicScript(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const element = document.createElement('script');
|
||||||
|
|
||||||
|
element.src = url;
|
||||||
|
element.type = 'text/javascript';
|
||||||
|
element.async = true;
|
||||||
|
|
||||||
|
element.onload = () => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`Dynamic Script Loaded: ${url}`);
|
||||||
|
resolve(element, url);
|
||||||
|
};
|
||||||
|
|
||||||
|
element.onerror = () => {
|
||||||
|
reject(new Error(`Failed to load dynamic script with URL: ${url}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadScriptComponent(scope, module) {
|
||||||
|
return async () => {
|
||||||
|
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
await __webpack_init_sharing__('default');
|
||||||
|
|
||||||
|
const container = window[scope]; // or get the container somewhere else
|
||||||
|
// Initialize the container, it may provide shared modules
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
await container.init(__webpack_share_scopes__.default);
|
||||||
|
const factory = await window[scope].get(module);
|
||||||
|
const Module = factory();
|
||||||
|
return Module;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { AlertList } from '../generic/user-messages';
|
|||||||
import InstructorToolbar from '../instructor-toolbar';
|
import InstructorToolbar from '../instructor-toolbar';
|
||||||
import useEnrollmentAlert from '../alerts/enrollment-alert';
|
import useEnrollmentAlert from '../alerts/enrollment-alert';
|
||||||
import useLogistrationAlert from '../alerts/logistration-alert';
|
import useLogistrationAlert from '../alerts/logistration-alert';
|
||||||
|
import PluginComponent from '../plugin-test/PluginComponent';
|
||||||
|
|
||||||
function LoadedTabPage({
|
function LoadedTabPage({
|
||||||
activeTabSlug,
|
activeTabSlug,
|
||||||
@@ -38,6 +39,12 @@ function LoadedTabPage({
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{`${activeTab.title} | ${title} | ${getConfig().SITE_NAME}`}</title>
|
<title>{`${activeTab.title} | ${title} | ${getConfig().SITE_NAME}`}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
{getConfig().plugins.slots.header.map((plugin) => (
|
||||||
|
<PluginComponent
|
||||||
|
key={`plugin-${plugin.url}-${plugin.module}`}
|
||||||
|
plugin={plugin}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<Header
|
<Header
|
||||||
courseOrg={org}
|
courseOrg={org}
|
||||||
courseNumber={number}
|
courseNumber={number}
|
||||||
@@ -49,6 +56,7 @@ function LoadedTabPage({
|
|||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main id="main-content" className="d-flex flex-column flex-grow-1">
|
<main id="main-content" className="d-flex flex-column flex-grow-1">
|
||||||
<AlertList
|
<AlertList
|
||||||
topic="outline"
|
topic="outline"
|
||||||
|
|||||||
28
webpack.dev.config.js
Normal file
28
webpack.dev.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { createConfig } = require('@edx/frontend-build');
|
||||||
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
const { ModuleFederationPlugin } = require('webpack').container;
|
||||||
|
|
||||||
|
module.exports = createConfig('webpack-dev', {
|
||||||
|
plugins: [
|
||||||
|
new ModuleFederationPlugin({
|
||||||
|
name: 'learning',
|
||||||
|
shared: {
|
||||||
|
react: {
|
||||||
|
import: 'react', // the "react" package will be used a provided and fallback module
|
||||||
|
shareKey: 'react', // under this name the shared module will be placed in the share scope
|
||||||
|
shareScope: 'default', // share scope with this name will be used
|
||||||
|
singleton: true, // only a single version of the shared module is allowed
|
||||||
|
eager: true,
|
||||||
|
},
|
||||||
|
'react-dom': {
|
||||||
|
singleton: true, // only a single version of the shared module is allowed
|
||||||
|
eager: true,
|
||||||
|
},
|
||||||
|
'@edx/frontend-platform': {
|
||||||
|
singleton: true,
|
||||||
|
eager: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user