Compare commits
5 Commits
pact-stub-
...
PowerPlant
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd931338d8 | ||
|
|
f03d5afa0d | ||
|
|
910e17f75d | ||
|
|
2fa5cadf22 | ||
|
|
bd8221997e |
@@ -28,4 +28,3 @@ APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
ENABLE_SKILLS_BUILDER_PROFILE=''
|
||||
ENABLE_PACT_STUBS=true
|
||||
|
||||
134
package-lock.json
generated
134
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.16.0",
|
||||
@@ -5071,11 +5072,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@restart/hooks": {
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.9.tgz",
|
||||
"integrity": "sha512-3BekqcwB6Umeya+16XPooARn4qEPW6vNvwYnlofIYe6h9qG1/VeD7UvShCWx11eFz5ELYmwIEshz+MkPX3wjcQ==",
|
||||
"version": "0.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.11.tgz",
|
||||
"integrity": "sha512-Ft/ncTULZN6ldGHiF/k5qt72O8JyRMOeg0tApvCni8LkoiEahO+z3TNxfXIVGy890YtWVDvJAl662dVJSJXvMw==",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.2"
|
||||
"dequal": "^2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
@@ -5709,9 +5710,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-transition-group": {
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz",
|
||||
"integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==",
|
||||
"version": "4.4.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.7.tgz",
|
||||
"integrity": "sha512-ICCyBl5mvyqYp8Qeq9B5G/fyBSRC0zx3XM3sCC6KkcMsNeAHqXBKkmat4GqdJET5jtYUpZXrxI5flve5qhi2Eg==",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
@@ -5775,9 +5776,9 @@
|
||||
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw=="
|
||||
},
|
||||
"node_modules/@types/warning": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.0.tgz",
|
||||
"integrity": "sha512-t/Tvs5qR47OLOr+4E9ckN8AmP2Tf16gWq+/qA4iUGS/OOyHVO8wv2vjJuX8SNOUTJyWb+2t7wJm6cXILFnOROA=="
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.1.tgz",
|
||||
"integrity": "sha512-ywJmriP+nvjBKNBEMaNZgj2irZHoxcKeYcyMLbqhYKbDVn8yCIULy2Ol/tvIb37O3IBeZj3RU4tXqQTtGwoAMg=="
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.5.5",
|
||||
@@ -10357,6 +10358,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/filter-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
|
||||
@@ -14628,9 +14637,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jquery": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.0.tgz",
|
||||
"integrity": "sha512-umpJ0/k8X0MvD1ds0P9SfowREz2LenHsQaxSohMZ5OMNEU2r0tf8pdeEFTHMFxWVxKNyU9rTtK3CWzUCTKJUeQ==",
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/js-base64": {
|
||||
@@ -15149,39 +15158,6 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link/node_modules/filter-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link/node_modules/query-string": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz",
|
||||
"integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.2.0",
|
||||
"filter-obj": "^1.1.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link/node_modules/split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
|
||||
@@ -15856,9 +15832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/object-code": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.2.4.tgz",
|
||||
"integrity": "sha512-uGq4ETUuWe+GA586NXEriiaozNuff+YNFXlpD8cVrM1GoiuTZpCABP+bZCWDrvQDoCiSTyiWAFHD/HF/iwhb2w=="
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.3.0.tgz",
|
||||
"integrity": "sha512-PLplgvzuFhSPBuTX/mtaXEnU3c6g7qKflVVQbV9VWEnV/34iKeAX1jeDNCKq1OgGlsnkA/NjldCzTbHxa7Wj4A=="
|
||||
},
|
||||
"node_modules/object-copy": {
|
||||
"version": "0.1.0",
|
||||
@@ -17573,6 +17549,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz",
|
||||
"integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.2.0",
|
||||
"filter-obj": "^1.1.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
@@ -17890,6 +17883,17 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "4.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.11.tgz",
|
||||
"integrity": "sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-overlay": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
|
||||
@@ -17901,9 +17905,9 @@
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
||||
},
|
||||
"node_modules/react-focus-lock": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.9.4.tgz",
|
||||
"integrity": "sha512-7pEdXyMseqm3kVjhdVH18sovparAzLg5h6WvIx7/Ck3ekjhrrDMEegHSa3swwC8wgfdd7DIdUVRGeiHT9/7Sgg==",
|
||||
"version": "2.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.9.5.tgz",
|
||||
"integrity": "sha512-h6vrdgUbsH2HeD5I7I3Cx1PPrmwGuKYICS+kB9m+32X/9xHRrAbxgvaBpG7BFBN9h3tO+C3qX1QAVESmi4CiIA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"focus-lock": "^0.11.6",
|
||||
@@ -17923,12 +17927,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-focus-on": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.8.1.tgz",
|
||||
"integrity": "sha512-fQcBx+SZMgXoRL+69r5+ic4bdVgqaCeKeoFPra8yhcSuL/3unWavfdirEFBGgH71K+RiocMTS6DETHcX0SlOZg==",
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.9.1.tgz",
|
||||
"integrity": "sha512-IYo2j4mgNpZEJNv+/XzZs3S3xhJbR+AFop092h4OMW7sbFpIMVWxp/Z61V/gfpsgOi7VnoSFXP2bfOWWkjjtOw==",
|
||||
"dependencies": {
|
||||
"aria-hidden": "^1.2.2",
|
||||
"react-focus-lock": "^2.9.2",
|
||||
"react-focus-lock": "^2.9.4",
|
||||
"react-remove-scroll": "^2.5.6",
|
||||
"react-style-singleton": "^2.2.0",
|
||||
"tslib": "^2.3.1",
|
||||
@@ -20181,6 +20185,14 @@
|
||||
"wbuf": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/split-string": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
||||
@@ -21628,9 +21640,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
|
||||
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"stubs": "pact-stub-service ./src/pacts/frontend-app-profile-edx-platform.json --port 18000 --cors"
|
||||
"stubs": "pact-stub-service ./src/pacts/frontend-app-profile-edx-platform.json --port 18000"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-profile/issues"
|
||||
@@ -49,6 +49,7 @@
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-error-boundary": "^4.0.11",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.16.0",
|
||||
|
||||
93
plugins/Plugin.jsx
Normal file
93
plugins/Plugin.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
dispatchMountedEvent, dispatchReadyEvent, dispatchUnmountedEvent, useHostEvent,
|
||||
} from './data/hooks';
|
||||
import { PLUGIN_RESIZE } from './data/constants';
|
||||
|
||||
// see example-plugin-app/src/PluginOne.jsx for example of customizing errorFallback
|
||||
function errorFallbackDefault() {
|
||||
return (
|
||||
<div>
|
||||
<h2>
|
||||
Oops! An error occurred. Please refresh the screen to try again.
|
||||
</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function Plugin({
|
||||
children, className, style, ready, errorFallbackProp,
|
||||
}) {
|
||||
const [dimensions, setDimensions] = useState({
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
|
||||
const finalStyle = useMemo(() => ({
|
||||
...dimensions,
|
||||
...style,
|
||||
}), [dimensions, style]);
|
||||
|
||||
const errorFallback = errorFallbackProp || errorFallbackDefault;
|
||||
|
||||
// Error logging function
|
||||
// Need to confirm: When an error is caught here, the logging will be sent to the child MFE's logging service
|
||||
const logErrorToService = (error, info) => {
|
||||
logError(error, { stack: info.componentStack });
|
||||
};
|
||||
|
||||
useHostEvent(PLUGIN_RESIZE, ({ payload }) => {
|
||||
setDimensions({
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
dispatchMountedEvent();
|
||||
|
||||
return () => {
|
||||
dispatchUnmountedEvent();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) {
|
||||
dispatchReadyEvent();
|
||||
}
|
||||
}, [ready]);
|
||||
|
||||
return (
|
||||
<div className={className} style={finalStyle}>
|
||||
<ErrorBoundary
|
||||
FallbackComponent={errorFallback}
|
||||
onError={logErrorToService}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Plugin.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
className: PropTypes.string,
|
||||
errorFallbackProp: PropTypes.func,
|
||||
ready: PropTypes.bool,
|
||||
style: PropTypes.object, // eslint-disable-line
|
||||
};
|
||||
|
||||
Plugin.defaultProps = {
|
||||
className: null,
|
||||
errorFallbackProp: null,
|
||||
style: {},
|
||||
ready: true,
|
||||
};
|
||||
42
plugins/PluginContainer.jsx
Normal file
42
plugins/PluginContainer.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import PluginContainerIframe from './PluginContainerIframe';
|
||||
|
||||
import {
|
||||
IFRAME_PLUGIN,
|
||||
} from './data/constants';
|
||||
import { pluginConfigShape } from './data/shapes';
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function PluginContainer({ config, ...props }) {
|
||||
if (config === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// this will allow for future plugin types to be inserted in the PluginErrorBoundary
|
||||
let renderer = null;
|
||||
switch (config.type) {
|
||||
case IFRAME_PLUGIN:
|
||||
renderer = (
|
||||
<PluginContainerIframe config={config} {...props} />
|
||||
);
|
||||
break;
|
||||
// istanbul ignore next: default isn't meaningful, just satisfying linter
|
||||
default:
|
||||
}
|
||||
|
||||
return (
|
||||
renderer
|
||||
);
|
||||
}
|
||||
|
||||
PluginContainer.propTypes = {
|
||||
config: pluginConfigShape,
|
||||
};
|
||||
|
||||
PluginContainer.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
99
plugins/PluginContainerIframe.jsx
Normal file
99
plugins/PluginContainerIframe.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, {
|
||||
useEffect, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
PLUGIN_MOUNTED,
|
||||
PLUGIN_READY,
|
||||
PLUGIN_RESIZE,
|
||||
} from './data/constants';
|
||||
import {
|
||||
dispatchPluginEvent,
|
||||
useElementSize,
|
||||
usePluginEvent,
|
||||
} from './data/hooks';
|
||||
import { pluginConfigShape } from './data/shapes';
|
||||
|
||||
/**
|
||||
* Feature policy for iframe, allowing access to certain courseware-related media.
|
||||
*
|
||||
* We must use the wildcard (*) origin for each feature, as courseware content
|
||||
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
|
||||
* block that iframes external course content.
|
||||
|
||||
* This policy was selected in conference with the edX Security Working Group.
|
||||
* Changes to it should be vetted by them (security@edx.org).
|
||||
*/
|
||||
export const IFRAME_FEATURE_POLICY = (
|
||||
'fullscreen; microphone *; camera *; midi *; geolocation *; encrypted-media *'
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
export default function PluginContainerIframe({
|
||||
config, fallback, className, ...props
|
||||
}) {
|
||||
const { url } = config;
|
||||
const { title, scrolling } = props;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [iframeRef, iframeElement, width, height] = useElementSize();
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
dispatchPluginEvent(iframeElement, {
|
||||
type: PLUGIN_RESIZE,
|
||||
payload: {
|
||||
width,
|
||||
height,
|
||||
},
|
||||
}, url);
|
||||
}
|
||||
}, [iframeElement, mounted, width, height, url]);
|
||||
|
||||
usePluginEvent(iframeElement, PLUGIN_MOUNTED, () => {
|
||||
setMounted(true);
|
||||
});
|
||||
|
||||
usePluginEvent(iframeElement, PLUGIN_READY, () => {
|
||||
setReady(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={title}
|
||||
src={url}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
scrolling={scrolling}
|
||||
referrerPolicy="origin" // The sent referrer will be limited to the origin of the referring page: its scheme, host, and port.
|
||||
className={classNames(
|
||||
'border border-0',
|
||||
{ 'd-none': !ready },
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{!ready && fallback}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PluginContainerIframe.propTypes = {
|
||||
config: pluginConfigShape,
|
||||
fallback: PropTypes.node,
|
||||
scrolling: PropTypes.oneOf(['auto', 'yes', 'no']),
|
||||
title: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
PluginContainerIframe.defaultProps = {
|
||||
config: null,
|
||||
fallback: null,
|
||||
scrolling: 'auto',
|
||||
title: null,
|
||||
className: null,
|
||||
};
|
||||
45
plugins/PluginErrorBoundary.jsx
Normal file
45
plugins/PluginErrorBoundary.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
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, info) {
|
||||
logError(error, { stack: info.componentStack });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="plugin.load.failure.text"
|
||||
defaultMessage="This content failed to load."
|
||||
description="error message when an unexpected error occurs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
PluginErrorBoundary.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
PluginErrorBoundary.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
75
plugins/PluginSlot.jsx
Normal file
75
plugins/PluginSlot.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable no-unused-vars */
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Spinner } from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
// import { usePluginSlot } from './data/hooks';
|
||||
import PluginContainer from './PluginContainer';
|
||||
|
||||
const PluginSlot = forwardRef(({
|
||||
as, id, intl, pluginProps, children, ...props
|
||||
}, ref) => {
|
||||
/* the plugins below are obtained by the id passed into PluginSlot by the Host MFE. See example/src/PluginsPage.jsx
|
||||
for an example of how PluginSlot is populated, and example/src/index.jsx for a dummy JS config that holds all plugins
|
||||
*/
|
||||
// const { plugins, keepDefault } = usePluginSlot(id);
|
||||
|
||||
const { fallback } = pluginProps;
|
||||
|
||||
// TODO: Add internationalization to the "Loading" text on the spinner.
|
||||
let finalFallback = (
|
||||
<div className={classNames(pluginProps.className, 'd-flex justify-content-center align-items-center')}>
|
||||
<Spinner animation="border" screenReaderText="Loading" />
|
||||
</div>
|
||||
);
|
||||
if (fallback !== undefined) {
|
||||
finalFallback = fallback;
|
||||
}
|
||||
|
||||
let finalChildren = [];
|
||||
// if (plugins.length > 0) {
|
||||
// if (keepDefault) {
|
||||
// finalChildren.push(children);
|
||||
// }
|
||||
// plugins.forEach((pluginConfig) => {
|
||||
// finalChildren.push(
|
||||
// <PluginContainer
|
||||
// key={pluginConfig.url}
|
||||
// config={pluginConfig}
|
||||
// fallback={finalFallback}
|
||||
// {...pluginProps}
|
||||
// />,
|
||||
// );
|
||||
// });
|
||||
// } else {
|
||||
finalChildren = children;
|
||||
// }
|
||||
|
||||
return React.createElement(
|
||||
as,
|
||||
{
|
||||
...props,
|
||||
ref,
|
||||
},
|
||||
finalChildren,
|
||||
);
|
||||
});
|
||||
|
||||
export default injectIntl(PluginSlot);
|
||||
|
||||
PluginSlot.propTypes = {
|
||||
as: PropTypes.elementType,
|
||||
children: PropTypes.node,
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
pluginProps: PropTypes.object, // eslint-disable-line
|
||||
};
|
||||
|
||||
PluginSlot.defaultProps = {
|
||||
as: 'div',
|
||||
children: null,
|
||||
pluginProps: {},
|
||||
};
|
||||
8
plugins/data/constants.js
Normal file
8
plugins/data/constants.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// TODO: We expect other plugin types to be added here, such as LTI_PLUGIN and BUILD_TIME_PLUGIN.
|
||||
export const IFRAME_PLUGIN = 'IFRAME_PLUGIN'; // loads iframe at the URL, rather than loading a JS file.
|
||||
|
||||
// Plugin lifecycle events
|
||||
export const PLUGIN_MOUNTED = 'PLUGIN_MOUNTED';
|
||||
export const PLUGIN_READY = 'PLUGIN_READY';
|
||||
export const PLUGIN_UNMOUNTED = 'PLUGIN_UNMOUNTED';
|
||||
export const PLUGIN_RESIZE = 'PLUGIN_RESIZE';
|
||||
96
plugins/data/hooks.js
Normal file
96
plugins/data/hooks.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import { PLUGIN_MOUNTED, PLUGIN_READY, PLUGIN_UNMOUNTED } from './constants';
|
||||
|
||||
export function useMessageEvent(srcWindow, type, callback) {
|
||||
useLayoutEffect(() => {
|
||||
const listener = (event) => {
|
||||
// Filter messages to those from our source window.
|
||||
if (event.source === srcWindow) {
|
||||
if (event.data.type === type) {
|
||||
callback({ type, payload: event.data.payload });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (srcWindow !== null) {
|
||||
global.addEventListener('message', listener);
|
||||
}
|
||||
return () => {
|
||||
global.removeEventListener('message', listener);
|
||||
};
|
||||
}, [srcWindow, type, callback]);
|
||||
}
|
||||
|
||||
export function useHostEvent(type, callback) {
|
||||
useMessageEvent(global.parent, type, callback);
|
||||
}
|
||||
|
||||
export function usePluginEvent(iframeElement, type, callback) {
|
||||
const contentWindow = iframeElement ? iframeElement.contentWindow : null;
|
||||
useMessageEvent(contentWindow, type, callback);
|
||||
}
|
||||
|
||||
export function dispatchMessageEvent(targetWindow, message, targetOrigin) {
|
||||
// Checking targetOrigin falsiness here since '', null or undefined would all be reasons not to
|
||||
// try to post a message to the origin.
|
||||
if (targetOrigin) {
|
||||
targetWindow.postMessage(message, targetOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchPluginEvent(iframeElement, message, targetOrigin) {
|
||||
dispatchMessageEvent(iframeElement.contentWindow, message, targetOrigin);
|
||||
}
|
||||
|
||||
export function dispatchHostEvent(message) {
|
||||
dispatchMessageEvent(global.parent, message, global.document.referrer);
|
||||
}
|
||||
|
||||
export function dispatchReadyEvent() {
|
||||
dispatchHostEvent({ type: PLUGIN_READY });
|
||||
}
|
||||
|
||||
export function dispatchMountedEvent() {
|
||||
dispatchHostEvent({ type: PLUGIN_MOUNTED });
|
||||
}
|
||||
|
||||
export function dispatchUnmountedEvent() {
|
||||
dispatchHostEvent({ type: PLUGIN_UNMOUNTED });
|
||||
}
|
||||
|
||||
export function useElementSize() {
|
||||
const observerRef = useRef();
|
||||
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const [element, setElement] = useState(null);
|
||||
|
||||
const measuredRef = useCallback(_element => {
|
||||
setElement(_element);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new ResizeObserver(() => {
|
||||
if (element) {
|
||||
setDimensions({
|
||||
width: element.clientWidth,
|
||||
height: element.clientHeight,
|
||||
});
|
||||
setOffset({
|
||||
x: element.offsetLeft,
|
||||
y: element.offsetTop,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (element) {
|
||||
observerRef.current.observe(element);
|
||||
}
|
||||
}, [element]);
|
||||
|
||||
return useMemo(
|
||||
() => ([measuredRef, element, dimensions.width, dimensions.height, offset.x, offset.y]),
|
||||
[measuredRef, element, dimensions, offset],
|
||||
);
|
||||
}
|
||||
10
plugins/data/shapes.js
Normal file
10
plugins/data/shapes.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import PropTypes from 'prop-types';
|
||||
import { IFRAME_PLUGIN } from './constants';
|
||||
|
||||
export const pluginConfigShape = PropTypes.shape({
|
||||
url: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOf([IFRAME_PLUGIN]).isRequired,
|
||||
// This is a place for us to put any generic props we want to pass to the component. We need it.
|
||||
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
|
||||
});
|
||||
18
plugins/index.js
Normal file
18
plugins/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// export {
|
||||
// usePluginSlot,
|
||||
// } from './data/hooks';
|
||||
export {
|
||||
default as Plugin,
|
||||
} from './Plugin';
|
||||
export {
|
||||
default as PluginContainer,
|
||||
} from './PluginContainer';
|
||||
export {
|
||||
default as PluginSlot,
|
||||
} from './PluginSlot';
|
||||
export {
|
||||
IFRAME_PLUGIN,
|
||||
} from './data/constants';
|
||||
export {
|
||||
default as PluginErrorBoundary,
|
||||
} from './PluginErrorBoundary';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import Header from '@edx/frontend-component-header';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
@@ -27,15 +28,25 @@ import Head from './head/Head';
|
||||
|
||||
import AppRoutes from './routes/AppRoutes';
|
||||
|
||||
const RenderFooter = () => {
|
||||
const location = useLocation();
|
||||
return ['/u/edx/plugin'].includes(location.pathname) ? null : <Footer />;
|
||||
};
|
||||
|
||||
const RenderHeader = () => {
|
||||
const location = useLocation();
|
||||
return ['/u/edx/plugin'].includes(location.pathname) ? null : <Header />;
|
||||
};
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
<AppProvider store={configureStore()}>
|
||||
<Head />
|
||||
<Header />
|
||||
<RenderHeader />
|
||||
<main id="main">
|
||||
<AppRoutes />
|
||||
</main>
|
||||
<Footer />
|
||||
<RenderFooter />
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
@@ -48,7 +59,6 @@ subscribe(APP_INIT_ERROR, (error) => {
|
||||
initialize({
|
||||
messages,
|
||||
hydrateAuthenticatedUser: true,
|
||||
isPactStubEnabled: process.env.ENABLE_PACT_STUBS,
|
||||
handlers: {
|
||||
config: () => {
|
||||
mergeConfig({
|
||||
|
||||
@@ -57,208 +57,6 @@
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A request for user's basic information",
|
||||
"providerStates": [
|
||||
{
|
||||
"name": "I have a user's basic information"
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/user/v1/accounts/edx"
|
||||
},
|
||||
"response": {
|
||||
"body": {
|
||||
"bio": "This is my bio",
|
||||
"country": "ME",
|
||||
"dateJoined": "2017-06-07T00:44:23Z",
|
||||
"email": "edx@example.com",
|
||||
"isActive": true,
|
||||
"name": "Lemon Seltzer",
|
||||
"username": "edx",
|
||||
"yearOfBirth": 1901,
|
||||
"language_proficiencies": [{"code": "en"}]
|
||||
},
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"matchingRules": {
|
||||
"body": {
|
||||
"$": {
|
||||
"combine": "AND",
|
||||
"matchers": [
|
||||
{
|
||||
"match": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A request for user's basic information",
|
||||
"providerStates": [
|
||||
{
|
||||
"name": "I have a user's basic information"
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"path": "/login_refresh"
|
||||
},
|
||||
"response": {
|
||||
"body": {
|
||||
"login": "This is my login_refresh"
|
||||
},
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"matchingRules": {
|
||||
"body": {
|
||||
"$": {
|
||||
"combine": "AND",
|
||||
"matchers": [
|
||||
{
|
||||
"match": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A request for user's basic information",
|
||||
"providerStates": [
|
||||
{
|
||||
"name": "I have a user's basic information"
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/csrf/api/v1/token"
|
||||
},
|
||||
"response": {
|
||||
"body": {
|
||||
"csrfToken": "UYVGuFndy1sJPtAFtXPNQTnkZxZPKSYbvd3KKoKTyk6Vq9k01lg4vSsgjidWbnpt"
|
||||
},
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"matchingRules": {
|
||||
"body": {
|
||||
"$": {
|
||||
"combine": "AND",
|
||||
"matchers": [
|
||||
{
|
||||
"match": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A request for user's basic information",
|
||||
"providerStates": [
|
||||
{
|
||||
"name": "I have a user's basic information"
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/user/v1/preferences/edx"
|
||||
},
|
||||
"response": {
|
||||
"body": {
|
||||
"pref-lang": "en"
|
||||
},
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"matchingRules": {
|
||||
"body": {
|
||||
"$": {
|
||||
"combine": "AND",
|
||||
"matchers": [
|
||||
{
|
||||
"match": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A request for user's basic information",
|
||||
"providerStates": [
|
||||
{
|
||||
"name": "I have a user's basic information"
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"path": "/api/certificates/v0/certificates/edx/"
|
||||
},
|
||||
"response": {
|
||||
"body": [],
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"matchingRules": {
|
||||
"body": {
|
||||
"$": {
|
||||
"combine": "AND",
|
||||
"matchers": [
|
||||
{
|
||||
"match": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "A request for user's basic information",
|
||||
"providerStates": [
|
||||
{
|
||||
"name": "I have a user's basic information"
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"path": "/event"
|
||||
},
|
||||
"response": {
|
||||
"body": [],
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"matchingRules": {
|
||||
"body": {
|
||||
"$": {
|
||||
"combine": "AND",
|
||||
"matchers": [
|
||||
{
|
||||
"match": "type"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
||||
@@ -6,7 +6,6 @@ const DateJoined = ({ date }) => {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="mb-0">
|
||||
<FormattedMessage
|
||||
|
||||
@@ -212,6 +212,7 @@ class ProfilePage extends React.Component {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>PluginPOC</div>
|
||||
<div className="col pl-0">
|
||||
<div className="d-md-none">
|
||||
{this.renderHeadingLockup()}
|
||||
|
||||
217
src/profile/ProfilePluginPage.jsx
Normal file
217
src/profile/ProfilePluginPage.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { ensureConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { injectIntl, intlShape, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||
import {
|
||||
ActionRow, Avatar, Card, Hyperlink, Icon,
|
||||
} from '@edx/paragon';
|
||||
import { HistoryEdu, VerifiedUser } from '@edx/paragon/icons';
|
||||
|
||||
import get from 'lodash.get';
|
||||
|
||||
import PluginCountry from './forms/PluginCountry';
|
||||
import { Plugin } from '../../plugins';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
fetchProfile,
|
||||
} from './data/actions';
|
||||
|
||||
// Components
|
||||
import PageLoading from './PageLoading';
|
||||
|
||||
// Selectors
|
||||
import { profilePageSelector } from './data/selectors';
|
||||
|
||||
// i18n
|
||||
import messages from './ProfilePage.messages';
|
||||
import eduMessages from './forms/Education.messages';
|
||||
|
||||
import withParams from '../utils/hoc';
|
||||
|
||||
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
function Fallback() {
|
||||
return (
|
||||
<div>this is broken as all get</div>
|
||||
);
|
||||
}
|
||||
|
||||
const platformDisplayInfo = {
|
||||
facebook: {
|
||||
icon: faFacebook,
|
||||
name: '',
|
||||
},
|
||||
twitter: {
|
||||
icon: faTwitter,
|
||||
name: '',
|
||||
},
|
||||
linkedin: {
|
||||
icon: faLinkedin,
|
||||
name: '',
|
||||
},
|
||||
};
|
||||
|
||||
class ProfilePluginPage extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.fetchProfile(this.props.params.username);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const {
|
||||
profileImage,
|
||||
country,
|
||||
levelOfEducation,
|
||||
socialLinks,
|
||||
isLoadingProfile,
|
||||
dateJoined,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return <PageLoading srMessage={this.props.intl.formatMessage(messages['profile.loading'])} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Plugin fallbackComponent={<Fallback />}>
|
||||
<Card className="mb-2">
|
||||
<Card.Header
|
||||
className="pb-5"
|
||||
subtitle={(
|
||||
<Hyperlink destination={`http://localhost:1995/u/${this.props.params.username}`}>
|
||||
View public profile
|
||||
</Hyperlink>
|
||||
)}
|
||||
actions={
|
||||
(
|
||||
<ActionRow className="mt-3">
|
||||
{socialLinks
|
||||
.filter(({ socialLink }) => Boolean(socialLink))
|
||||
.map(({ platform, socialLink }) => (
|
||||
<StaticListItem
|
||||
key={platform}
|
||||
name={platformDisplayInfo[platform].name}
|
||||
url={socialLink}
|
||||
platform={platform}
|
||||
/>
|
||||
))}
|
||||
</ActionRow>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Card.Section className="text-center" muted>
|
||||
<Avatar
|
||||
size="xl"
|
||||
className="profile-plugin-avatar"
|
||||
src={profileImage.src}
|
||||
alt="Profile image"
|
||||
/>
|
||||
<h1 className="h2 mb-0 font-weight-bold">{this.props.params.username}</h1>
|
||||
<PluginCountry
|
||||
country={country}
|
||||
/>
|
||||
</Card.Section>
|
||||
<Card.Footer className="p-0">
|
||||
<Card.Section className="pgn-icons-cell-vertical">
|
||||
<Icon src={VerifiedUser} />
|
||||
<p>
|
||||
since <FormattedDate value={new Date(dateJoined)} year="numeric" />
|
||||
</p>
|
||||
</Card.Section>
|
||||
<Card.Section className="pgn-icons-cell-vertical">
|
||||
<Icon src={HistoryEdu} />
|
||||
<p>
|
||||
{intl.formatMessage(get(
|
||||
eduMessages,
|
||||
`profile.education.levels.${levelOfEducation}`,
|
||||
eduMessages['profile.education.levels.o'],
|
||||
))}
|
||||
</p>
|
||||
</Card.Section>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</Plugin>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SocialLink = ({ url, name, platform }) => (
|
||||
<a href={url} className="font-weight-bold">
|
||||
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
|
||||
const StaticListItem = ({ url, name, platform }) => (
|
||||
<ul className="list-inline">
|
||||
<SocialLink name={name} url={url} platform={platform} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
ProfilePluginPage.contextType = AppContext;
|
||||
|
||||
ProfilePluginPage.propTypes = {
|
||||
// Account data
|
||||
dateJoined: PropTypes.string,
|
||||
|
||||
// Country form data
|
||||
country: PropTypes.string,
|
||||
|
||||
// Education form data
|
||||
levelOfEducation: PropTypes.string,
|
||||
|
||||
// Social links form data
|
||||
socialLinks: PropTypes.arrayOf(PropTypes.shape({
|
||||
platform: PropTypes.string,
|
||||
socialLink: PropTypes.string,
|
||||
})),
|
||||
|
||||
// Other data we need
|
||||
profileImage: PropTypes.shape({
|
||||
src: PropTypes.string,
|
||||
isDefault: PropTypes.bool,
|
||||
}),
|
||||
isLoadingProfile: PropTypes.bool.isRequired,
|
||||
|
||||
// Actions
|
||||
fetchProfile: PropTypes.func.isRequired,
|
||||
|
||||
// Router
|
||||
params: PropTypes.shape({
|
||||
username: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ProfilePluginPage.defaultProps = {
|
||||
profileImage: {},
|
||||
levelOfEducation: null,
|
||||
country: null,
|
||||
socialLinks: [],
|
||||
dateJoined: null,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
profilePageSelector,
|
||||
{
|
||||
fetchProfile,
|
||||
},
|
||||
)(injectIntl(withParams(ProfilePluginPage)));
|
||||
@@ -103,6 +103,9 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -2486,6 +2489,9 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -3663,6 +3669,9 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -5692,6 +5701,9 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -5947,6 +5959,9 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -6997,6 +7012,9 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -8114,6 +8132,9 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
@@ -9239,6 +9260,9 @@ exports[`<ProfilePage /> Renders correctly in various states without credentials
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
PluginPOC
|
||||
</div>
|
||||
<div
|
||||
className="col pl-0"
|
||||
>
|
||||
|
||||
40
src/profile/forms/PluginCountry.jsx
Normal file
40
src/profile/forms/PluginCountry.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { LocationOn } from '@edx/paragon/icons';
|
||||
|
||||
// Selectors
|
||||
import { countrySelector } from '../data/selectors';
|
||||
|
||||
// eslint-disable-next-line react/prefer-stateless-function
|
||||
class PluginCountry extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
country,
|
||||
countryMessages,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className="pgn-icons-cell-horizontal">
|
||||
<Icon src={LocationOn} />
|
||||
<p className="h5 mt-1 ml-1">{countryMessages[country]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PluginCountry.propTypes = {
|
||||
country: PropTypes.string,
|
||||
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
PluginCountry.defaultProps = {
|
||||
country: null,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
countrySelector,
|
||||
{},
|
||||
)(injectIntl(PluginCountry));
|
||||
@@ -3,3 +3,4 @@ export { default as saga } from './data/sagas';
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
export { default as NotFoundPage } from './NotFoundPage';
|
||||
export { default as messages } from './ProfilePage.messages';
|
||||
export { default as ProfilePluginPage } from './ProfilePluginPage';
|
||||
|
||||
@@ -162,4 +162,28 @@
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.pgn-icons-cell-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 1px;
|
||||
}
|
||||
.pgn-icons-cell-horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.profile-plugin-avatar {
|
||||
@include media-breakpoint-up(xs) {
|
||||
max-width: 12rem;
|
||||
margin-right: 0;
|
||||
margin-top: -4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
PageWrap,
|
||||
} from '@edx/frontend-platform/react';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { ProfilePage, NotFoundPage } from '../profile';
|
||||
import { ProfilePage, NotFoundPage, ProfilePluginPage } from '../profile';
|
||||
|
||||
const AppRoutes = () => (
|
||||
<Routes>
|
||||
<Route path="/u/:username" element={<AuthenticatedPageRoute><ProfilePage /></AuthenticatedPageRoute>} />
|
||||
<Route path="/u/:username/plugin" element={<AuthenticatedPageRoute><ProfilePluginPage /></AuthenticatedPageRoute>} />
|
||||
<Route path="/notfound" element={<PageWrap><NotFoundPage /></PageWrap>} />
|
||||
<Route path="*" element={<PageWrap><NotFoundPage /></PageWrap>} />
|
||||
</Routes>
|
||||
|
||||
@@ -15,6 +15,7 @@ jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
jest.mock('../profile', () => ({
|
||||
ProfilePage: () => (<div>Profile page</div>),
|
||||
NotFoundPage: () => (<div>Not found page</div>),
|
||||
ProfilePluginPage: () => (<div>Plugin page</div>),
|
||||
}));
|
||||
|
||||
const RoutesWithProvider = (context, path) => (
|
||||
@@ -54,6 +55,22 @@ describe('routes', () => {
|
||||
expect(screen.getByText('Profile page')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Profile Plugin page should be accessible for authenticated users', () => {
|
||||
render(
|
||||
RoutesWithProvider(
|
||||
{
|
||||
authenticatedUser: {
|
||||
username: 'edx',
|
||||
email: 'edx@example.com',
|
||||
},
|
||||
config: getConfig(),
|
||||
},
|
||||
'/u/edx/plugin',
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('Plugin page')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should show NotFound page for a bad route', () => {
|
||||
render(
|
||||
RoutesWithProvider(unauthenticatedUser, '/nonMatchingRoute'),
|
||||
|
||||
Reference in New Issue
Block a user