diff --git a/.env b/.env index c4e0195..889b922 100644 --- a/.env +++ b/.env @@ -37,3 +37,4 @@ HOTJAR_VERSION='6' HOTJAR_DEBUG='' ACCOUNT_SETTINGS_URL='' ACCOUNT_PROFILE_URL='' +ENABLE_NOTICES='' diff --git a/.env.development b/.env.development index ed25984..4abfc76 100644 --- a/.env.development +++ b/.env.development @@ -44,3 +44,4 @@ HOTJAR_VERSION='6' HOTJAR_DEBUG='' ACCOUNT_SETTINGS_URL='http://localhost:1997' ACCOUNT_PROFILE_URL='http://localhost:1995' +ENABLE_NOTICES='' diff --git a/.env.test b/.env.test index 4116383..4b53e6b 100644 --- a/.env.test +++ b/.env.test @@ -43,3 +43,4 @@ HOTJAR_VERSION='6' HOTJAR_DEBUG='' ACCOUNT_SETTINGS_URL='http://account-settings-url.test' ACCOUNT_PROFILE_URL='http://account-profile-url.test' +ENABLE_NOTICES='' diff --git a/src/__snapshots__/index.test.jsx.snap b/src/__snapshots__/index.test.jsx.snap index 69bd824..8ffdbfb 100644 --- a/src/__snapshots__/index.test.jsx.snap +++ b/src/__snapshots__/index.test.jsx.snap @@ -14,15 +14,17 @@ exports[`app registry subscribe: APP_READY. links App to root element 1`] = ` } } > - - - - - - + + + + + + + + `; diff --git a/src/components/NoticesWrapper/api.js b/src/components/NoticesWrapper/api.js new file mode 100644 index 0000000..1b505c3 --- /dev/null +++ b/src/components/NoticesWrapper/api.js @@ -0,0 +1,26 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; + +export const noticesUrl = `${getConfig().LMS_BASE_URL}/notices/api/v1/unacknowledged`; +export const error404Message = 'This probably happened because the notices plugin is not installed on platform.'; + +export const getNotices = ({ onLoad }) => { + const authenticatedUser = getAuthenticatedUser(); + + const handleError = async (e) => { + // Error probably means that notices is not installed, which is fine. + const { customAttributes: { httpErrorStatus } } = e; + if (httpErrorStatus === 404) { + logInfo(`${e}. ${error404Message}`); + } else { + logError(e); + } + }; + if (authenticatedUser) { + return getAuthenticatedHttpClient().get(noticesUrl, {}).then(onLoad).catch(handleError); + } + return null; +}; + +export default { getNotices }; diff --git a/src/components/NoticesWrapper/api.test.js b/src/components/NoticesWrapper/api.test.js new file mode 100644 index 0000000..4470f6e --- /dev/null +++ b/src/components/NoticesWrapper/api.test.js @@ -0,0 +1,65 @@ +import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { logError, logInfo } from '@edx/frontend-platform/logging'; + +import * as api from './api'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(() => ({ + LMS_BASE_URL: 'test-lms-url', + })), +})); + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), + getAuthenticatedUser: jest.fn(), +})); + +jest.mock('@edx/frontend-platform/logging', () => ({ + logError: jest.fn(), + logInfo: jest.fn(), +})); + +const testData = 'test-data'; +const successfulGet = () => Promise.resolve(testData); +const error404 = { customAttributes: { httpErrorStatus: 404 }, test: 'error' }; +const error404Get = () => Promise.reject(error404); +const error500 = { customAttributes: { httpErrorStatus: 500 }, test: 'error' }; +const error500Get = () => Promise.reject(error500); + +const get = jest.fn().mockImplementation(successfulGet); +getAuthenticatedHttpClient.mockReturnValue({ get }); +const authenticatedUser = { fake: 'user' }; +getAuthenticatedUser.mockReturnValue(authenticatedUser); + +const onLoad = jest.fn(); +describe('getNotices api method', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('behavior', () => { + describe('not authenticated', () => { + it('does not fetch anything', () => { + getAuthenticatedUser.mockReturnValueOnce(null); + api.getNotices({ onLoad }); + expect(get).not.toHaveBeenCalled(); + }); + }); + describe('authenticated', () => { + it('fetches noticesUrl with onLoad behavior', async () => { + await api.getNotices({ onLoad }); + expect(get).toHaveBeenCalledWith(api.noticesUrl, {}); + expect(onLoad).toHaveBeenCalledWith(testData); + }); + it('calls logInfo if fetch fails with 404', async () => { + get.mockImplementation(error404Get); + await api.getNotices({ onLoad }); + expect(logInfo).toHaveBeenCalledWith(`${error404}. ${api.error404Message}`); + }); + it('calls logError if fetch fails with non-404 error', async () => { + get.mockImplementation(error500Get); + await api.getNotices({ onLoad }); + expect(logError).toHaveBeenCalledWith(error500); + }); + }); + }); +}); diff --git a/src/components/NoticesWrapper/hooks.js b/src/components/NoticesWrapper/hooks.js new file mode 100644 index 0000000..275c2ab --- /dev/null +++ b/src/components/NoticesWrapper/hooks.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { getConfig } from '@edx/frontend-platform'; + +import { StrictDict } from 'utils'; +import { getNotices } from './api'; +import * as module from './hooks'; + +/** + * This component uses the platform-plugin-notices plugin to function. + * If the user has an unacknowledged notice, they will be rerouted off + * course home and onto a full-screen notice page. If the plugin is not + * installed, or there are no notices, we just passthrough this component. + */ +export const state = StrictDict({ + isRedirected: (val) => React.useState(val), // eslint-disable-line +}); + +export const useNoticesWrapperData = () => { + const [isRedirected, setIsRedirected] = module.state.isRedirected(); + React.useEffect(() => { + if (getConfig().ENABLE_NOTICES) { + getNotices({ + onLoad: (data) => { + if (data?.results?.length > 0) { + setIsRedirected(true); + window.location.replace(`${data.results[0]}?next=${window.location.href}`); + } + }, + }); + } + }, [setIsRedirected]); + return { isRedirected }; +}; + +export default useNoticesWrapperData; diff --git a/src/components/NoticesWrapper/hooks.test.js b/src/components/NoticesWrapper/hooks.test.js new file mode 100644 index 0000000..78f5434 --- /dev/null +++ b/src/components/NoticesWrapper/hooks.test.js @@ -0,0 +1,83 @@ +import React from 'react'; + +import { MockUseState } from 'testUtils'; + +import { getConfig } from '@edx/frontend-platform'; +import { getNotices } from './api'; +import * as hooks from './hooks'; + +jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() })); +jest.mock('./api', () => ({ getNotices: jest.fn() })); + +getConfig.mockReturnValue({ ENABLE_NOTICES: true }); +const state = new MockUseState(hooks); + +let hook; +describe('NoticesWrapper hooks', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('state hooks', () => { + state.testGetter(state.keys.isRedirected); + }); + describe('useNoticesWrapperData', () => { + beforeEach(() => { + state.mock(); + }); + describe('behavior', () => { + it('initializes state hooks', () => { + hooks.useNoticesWrapperData(); + expect(hooks.state.isRedirected).toHaveBeenCalledWith(); + }); + describe('effects', () => { + it('does not call notices if not enabled', () => { + getConfig.mockReturnValueOnce({ ENABLE_NOTICES: false }); + hooks.useNoticesWrapperData(); + const [cb, prereqs] = React.useEffect.mock.calls[0]; + expect(prereqs).toEqual([state.setState.isRedirected]); + cb(); + expect(getNotices).not.toHaveBeenCalled(); + }); + describe('getNotices call (if enabled) onLoad behavior', () => { + it('does not redirect if there are no results', () => { + hooks.useNoticesWrapperData(); + expect(React.useEffect).toHaveBeenCalled(); + const [cb, prereqs] = React.useEffect.mock.calls[0]; + expect(prereqs).toEqual([state.setState.isRedirected]); + cb(); + expect(getNotices).toHaveBeenCalled(); + const { onLoad } = getNotices.mock.calls[0][0]; + onLoad({}); + expect(state.setState.isRedirected).not.toHaveBeenCalled(); + onLoad({ data: {} }); + expect(state.setState.isRedirected).not.toHaveBeenCalled(); + onLoad({ data: { results: [] } }); + expect(state.setState.isRedirected).not.toHaveBeenCalled(); + }); + it('redirects and set isRedirected if results are returned', () => { + delete window.location; + window.location = { replace: jest.fn(), href: 'test-old-href' }; + hooks.useNoticesWrapperData(); + const [cb, prereqs] = React.useEffect.mock.calls[0]; + expect(prereqs).toEqual([state.setState.isRedirected]); + cb(); + expect(getNotices).toHaveBeenCalled(); + const { onLoad } = getNotices.mock.calls[0][0]; + const target = 'url-target'; + onLoad({ results: [target] }); + expect(state.setState.isRedirected).toHaveBeenCalledWith(true); + expect(window.location.replace).toHaveBeenCalledWith( + `${target}?next=${window.location.href}`, + ); + }); + }); + }); + }); + describe('output', () => { + it('forwards isRedirected from state call', () => { + hook = hooks.useNoticesWrapperData(); + expect(hook.isRedirected).toEqual(state.stateVals.isRedirected); + }); + }); + }); +}); diff --git a/src/components/NoticesWrapper/index.jsx b/src/components/NoticesWrapper/index.jsx new file mode 100644 index 0000000..faf9b9f --- /dev/null +++ b/src/components/NoticesWrapper/index.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import useNoticesWrapperData from './hooks'; + +/** + * This component uses the platform-plugin-notices plugin to function. + * If the user has an unacknowledged notice, they will be rerouted off + * course home and onto a full-screen notice page. If the plugin is not + * installed, or there are no notices, we just passthrough this component. + */ +const NoticesWrapper = ({ children }) => { + const { isRedirected } = useNoticesWrapperData(); + return ( +
+ {isRedirected === true ? null : children} +
+ ); +}; + +NoticesWrapper.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default NoticesWrapper; diff --git a/src/components/NoticesWrapper/index.test.jsx b/src/components/NoticesWrapper/index.test.jsx new file mode 100644 index 0000000..f7f8b81 --- /dev/null +++ b/src/components/NoticesWrapper/index.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import useNoticesWrapperData from './hooks'; +import NoticesWrapper from '.'; + +jest.mock('./hooks', () => jest.fn()); + +const hookProps = { isRedirected: false }; +useNoticesWrapperData.mockReturnValue(hookProps); + +let el; +const children = [some, children]; +describe('NoticesWrapper component', () => { + describe('behavior', () => { + it('initializes hooks', () => { + el = shallow({children}); + expect(useNoticesWrapperData).toHaveBeenCalledWith(); + }); + }); + describe('output', () => { + it('does not show children if redirected', () => { + useNoticesWrapperData.mockReturnValueOnce({ isRedirected: true }); + el = shallow({children}); + expect(el.children().length).toEqual(0); + }); + it('shows children if not redirected', () => { + el = shallow({children}); + expect(el.children().length).toEqual(2); + expect(el.children().at(0).matchesElement(children[0])).toEqual(true); + expect(el.children().at(1).matchesElement(children[1])).toEqual(true); + }); + }); +}); diff --git a/src/config/index.js b/src/config/index.js index 47cf334..5b39721 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -13,6 +13,7 @@ const configuration = { SESSION_COOKIE_DOMAIN: process.env.SESSION_COOKIE_DOMAIN || '', ZENDESK_KEY: process.env.ZENDESK_KEY, SUPPORT_URL: process.env.SUPPORT_URL || null, + ENABLE_NOTICES: process.env.ENABLE_NOTICES || null, }; const features = {}; diff --git a/src/index.jsx b/src/index.jsx index 02b5f87..6078eca 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -26,16 +26,19 @@ import { configuration } from './config'; import messages from './i18n'; import App from './App'; +import NoticesWrapper from './components/NoticesWrapper'; subscribe(APP_READY, () => { ReactDOM.render( - - - - - - + + + + + + + + , document.getElementById('root'), ); diff --git a/src/index.test.jsx b/src/index.test.jsx index 364e278..11e999a 100644 --- a/src/index.test.jsx +++ b/src/index.test.jsx @@ -34,6 +34,7 @@ jest.mock('@edx/frontend-component-footer', () => ({ })); jest.mock('data/store', () => ({ redux: 'store' })); jest.mock('./App', () => 'App'); +jest.mock('components/NoticesWrapper', () => 'NoticesWrapper'); describe('app registry', () => { let getElement; diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index 5de1011..2b51b81 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -44,6 +44,7 @@ jest.unmock('hooks'); jest.mock('containers/WidgetContainers/LoadedSidebar', () => 'loaded-widget-sidebar'); jest.mock('containers/WidgetContainers/NoCoursesSidebar', () => 'no-courses-widget-sidebar'); +jest.mock('components/NoticesWrapper', () => 'notices-wrapper'); jest.mock('@edx/frontend-platform', () => ({ ...jest.requireActual('@edx/frontend-platform'),