Compare commits
12 Commits
zhancock/r
...
kdmccormic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17b027a8db | ||
|
|
74e86fd4e8 | ||
|
|
40bcbb60bf | ||
|
|
b1b0a00bd1 | ||
|
|
ae105afe4c | ||
|
|
cd4f8d3e01 | ||
|
|
a56ba1a3d4 | ||
|
|
5796b87341 | ||
|
|
7ecc0e7929 | ||
|
|
6d35d33559 | ||
|
|
d61fe28e4e | ||
|
|
76a85224b8 |
1
.env
1
.env
@@ -29,3 +29,4 @@ SUPPORT_URL_VERIFIED_CERTIFICATE=null
|
||||
TWITTER_HASHTAG=null
|
||||
TWITTER_URL=null
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
NEXBLOCK_API_PATH=null
|
||||
|
||||
@@ -29,3 +29,5 @@ SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/2065
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
NEXBLOCK_INSTANCE_DATA_API_PATH=http://localhost:18000/api/nexblock/v0/instance-data
|
||||
NEXBLOCK_LEARNER_DATA_API_PATH=http://localhost:18000/api/nexblock/v0/learner-data
|
||||
|
||||
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"
|
||||
},
|
||||
"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/jest-dom": "5.10.1",
|
||||
"@testing-library/react": "10.3.0",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { AppProvider, ErrorPage, PageRoute } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
@@ -27,43 +27,49 @@ import { TabContainer } from './tab-page';
|
||||
import { fetchDatesTab, fetchOutlineTab, fetchProgressTab } from './course-home/data';
|
||||
import { fetchCourse } from './courseware/data';
|
||||
import initializeStore from './store';
|
||||
import NexBlockContainer from './nex-runtime/NexContainer';
|
||||
import PluginTestPage from './plugin-test/PluginTestPage';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
<AppProvider store={initializeStore()}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
||||
<PageRoute path="/course/:courseId/home">
|
||||
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
||||
<OutlineTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/progress">
|
||||
<TabContainer tab="progress" fetch={fetchProgressTab} slice="courseHome">
|
||||
<ProgressTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/course-end">
|
||||
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
|
||||
<CourseExit />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
<Route exact path="/nexblock" component={NexBlockContainer} />
|
||||
<Switch>
|
||||
<Route exact path="/plugin-test" component={PluginTestPage} />
|
||||
<PageRoute path="/redirect" component={CoursewareRedirectLandingPage} />
|
||||
<PageRoute path="/course/:courseId/home">
|
||||
<TabContainer tab="outline" fetch={fetchOutlineTab} slice="courseHome">
|
||||
<OutlineTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/dates">
|
||||
<TabContainer tab="dates" fetch={fetchDatesTab} slice="courseHome">
|
||||
<DatesTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/progress">
|
||||
<TabContainer tab="progress" fetch={fetchProgressTab} slice="courseHome">
|
||||
<ProgressTab />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute path="/course/:courseId/course-end">
|
||||
<TabContainer tab="courseware" fetch={fetchCourse} slice="courseware">
|
||||
<CourseExit />
|
||||
</TabContainer>
|
||||
</PageRoute>
|
||||
<PageRoute
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
]}
|
||||
component={CoursewareContainer}
|
||||
/>
|
||||
</Switch>
|
||||
<Footer />
|
||||
</Switch>
|
||||
<Footer />
|
||||
</UserMessagesProvider>
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
@@ -90,6 +96,7 @@ initialize({
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE: process.env.SUPPORT_URL_VERIFIED_CERTIFICATE || null,
|
||||
TWITTER_HASHTAG: process.env.TWITTER_HASHTAG || null,
|
||||
TWITTER_URL: process.env.TWITTER_URL || null,
|
||||
NEXBLOCK_API_PATH: process.env.NEXBLOCK_API_PATH || null,
|
||||
}, 'LearnerAppConfig');
|
||||
},
|
||||
},
|
||||
|
||||
36
src/nex-runtime/NexContainer.jsx
Normal file
36
src/nex-runtime/NexContainer.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import Plugin, { COMPONENT } from '../plugin-test/Plugin';
|
||||
|
||||
const FALLBACK_URL = 'http://localhost:7331/remoteEntry.js';
|
||||
const FALLBACK_VIEW = 'PluginOne';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
export default function NexBlockContainer() {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
|
||||
const plugin = {
|
||||
url: query.get('url') ?? FALLBACK_URL,
|
||||
scope: 'plugin',
|
||||
module: `./${query.get('view') ?? FALLBACK_VIEW}`,
|
||||
type: COMPONENT,
|
||||
};
|
||||
|
||||
const [instanceData, setInstanceData] = useState({ title: 'Loading...', body: '' });
|
||||
const usageId = query.get('usage_id');
|
||||
const DATA_URL = `http://localhost:18000/api/nexblocks/v0/instance-data/${usageId}`;
|
||||
const httpClient = getAuthenticatedHttpClient();
|
||||
|
||||
useEffect(() => {
|
||||
httpClient.get(DATA_URL, { params: {} }).then(({ data }) => {
|
||||
setInstanceData(data);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '2em' }}>
|
||||
<Plugin plugin={plugin} instanceData={instanceData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/nex-runtime/NexLmsDataProvider.js
Normal file
25
src/nex-runtime/NexLmsDataProvider.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { NexDataProvider } from '../nex';
|
||||
|
||||
export default class NexLmsDataProvider extends NexDataProvider {
|
||||
constructor(instanceKey, learningContextKey) {
|
||||
super(instanceKey, learningContextKey);
|
||||
this.client = getAuthenticatedHttpClient();
|
||||
const nexApiRoot = getConfig().NEX_LMS_API_ROOT;
|
||||
this.instanceDataRoot = `${nexApiRoot}/instances/${instanceKey}`;
|
||||
this.learnerDataRoot = `${nexApiRoot}/instances/${instanceKey}/contexts/${learningContextKey}`;
|
||||
}
|
||||
|
||||
async fetchInstanceData(dataKey) {
|
||||
return this.client.get(`${this.instanceDataRoot}/data/${dataKey || ''}`);
|
||||
}
|
||||
|
||||
async fetchLearnerData(dataKey) {
|
||||
return this.client.get(`${this.learnerDataRoot}/data/${dataKey}`);
|
||||
}
|
||||
|
||||
async emitLearnerEvent(eventData) {
|
||||
return this.client.post(`${this.learnerDataRoot}/events`, eventData);
|
||||
}
|
||||
}
|
||||
9
src/nex/FallbackAuthoringUi.js
Normal file
9
src/nex/FallbackAuthoringUi.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function FallbackAuthoringUi({ instanceDataSchema }) { // eslint-disable-line no-unused-vars
|
||||
return 'Fallback Authoring UI not yet implemented';
|
||||
}
|
||||
|
||||
FallbackAuthoringUi.propTypes = {
|
||||
instanceDataSchema: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
22
src/nex/NexBlock.js
Normal file
22
src/nex/NexBlock.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import NexCore from './NexCore';
|
||||
import FallbackAuthoringUi from './FallbackAuthoringUi';
|
||||
|
||||
export default function NexBlock({
|
||||
core, learnerUi, instructorUi, authorUi, instanceDataSchema,
|
||||
}) {
|
||||
const injectCoreIntoProps = ui => (props => ui({ core, ...props }));
|
||||
|
||||
this.learnerUi = injectCoreIntoProps(learnerUi);
|
||||
this.instructorUi = instructorUi ? injectCoreIntoProps(instructorUi) : this.learnerUi;
|
||||
this.authorUi = authorUi || FallbackAuthoringUi(instanceDataSchema || null);
|
||||
}
|
||||
|
||||
NexBlock.propTypes = {
|
||||
core: PropTypes.instanceOf(NexCore).isRequired,
|
||||
learnerUi: PropTypes.elementType.isRequired,
|
||||
instructorUi: PropTypes.elementType,
|
||||
authorUi: PropTypes.elementType,
|
||||
instanceDataSchema: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
};
|
||||
9
src/nex/NexCore.js
Normal file
9
src/nex/NexCore.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class NexCore {
|
||||
constructor(dataProvider) {
|
||||
this.dataProvider = dataProvider;
|
||||
}
|
||||
|
||||
async query(queryData) { // eslint-disable-line no-unused-vars
|
||||
throw new Error('NexCore.query must be implemented.');
|
||||
}
|
||||
}
|
||||
18
src/nex/NexDataProvider.js
Normal file
18
src/nex/NexDataProvider.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export default class NexDataProvider {
|
||||
constructor(instanceKey, learningContextKey) {
|
||||
this.instanceKey = instanceKey;
|
||||
this.learningContextKey = learningContextKey;
|
||||
}
|
||||
|
||||
async fetchInstanceData(dataKey) { // eslint-disable-line no-unused-vars
|
||||
throw new Error('NexDataProvider.fetchInstanceData must be implemented.');
|
||||
}
|
||||
|
||||
async fetchLearnerData(dataKey) { // eslint-disable-line no-unused-vars
|
||||
throw new Error('NexDataProvider.fetchLearnerData must be implemented.');
|
||||
}
|
||||
|
||||
async emitLearnerEvent(eventData) { // eslint-disable-line no-unused-vars
|
||||
throw new Error('NexDataProvider.emitLearnerEvent must be implemented.');
|
||||
}
|
||||
}
|
||||
3
src/nex/index.js
Normal file
3
src/nex/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export NexBlock from './NexBlock';
|
||||
export NexCore from './NexCore';
|
||||
export NexDataProvider from './NexDataProvider';
|
||||
99
src/plugin-test/Plugin.jsx
Normal file
99
src/plugin-test/Plugin.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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';
|
||||
|
||||
// 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);
|
||||
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 () => {
|
||||
document.head.removeChild(element);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
function Plugin({ 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 PluginComponent = React.lazy(
|
||||
loadScriptComponent(plugin.scope, plugin.module),
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loading)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<PluginComponent {...props} {...plugin.props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Plugin.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,
|
||||
};
|
||||
|
||||
Plugin.defaultProps = {
|
||||
plugin: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Plugin);
|
||||
46
src/plugin-test/PluginTestPage.jsx
Normal file
46
src/plugin-test/PluginTestPage.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import Plugin, { SCRIPT, COMPONENT } from './Plugin';
|
||||
|
||||
function PluginTestPage() {
|
||||
const [plugin, setPlugin] = React.useState(undefined);
|
||||
|
||||
function setPluginOne() {
|
||||
setPlugin({
|
||||
url: 'http://localhost:7331/remoteEntry.js',
|
||||
scope: 'plugin',
|
||||
module: './PluginOne',
|
||||
type: SCRIPT,
|
||||
});
|
||||
}
|
||||
|
||||
function setPluginTwo() {
|
||||
setPlugin({
|
||||
url: 'http://localhost:7331/remoteEntry.js',
|
||||
scope: 'plugin',
|
||||
module: './PluginTwo',
|
||||
type: COMPONENT,
|
||||
});
|
||||
}
|
||||
|
||||
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' }}>
|
||||
<Plugin 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;
|
||||
};
|
||||
}
|
||||
32
webpack.dev.config.js
Normal file
32
webpack.dev.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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,
|
||||
},
|
||||
'@edx/frontend-platform/auth': {
|
||||
singleton: true,
|
||||
eager: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user