Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ TWITTER_HASHTAG=''
TWITTER_URL=''
USER_INFO_COOKIE_NAME=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
RENDER_XBLOCKS_DEFAULT=true
RENDER_XBLOCKS_EXPERIMENTAL=false
2 changes: 2 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ SESSION_COOKIE_DOMAIN='localhost'
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
RENDER_XBLOCKS_DEFAULT=true
RENDER_XBLOCKS_EXPERIMENTAL=false
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,5 @@ TWITTER_HASHTAG='myedxjourney'
TWITTER_URL='https://twitter.com/edXOnline'
USER_INFO_COOKIE_NAME='edx-user-info'
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
RENDER_XBLOCKS_DEFAULT=true
RENDER_XBLOCKS_EXPERIMENTAL=false
8 changes: 8 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ TWITTER_URL

Example: https://twitter.com/edXOnline

RENDER_XBLOCKS_EXPERIMENTAL
Enables the experimental rendering of XBlocks directly in the MFE. This
feature is not yet ready for production use. The default value is ``false``.
Note: if you enable this feature (by setting this variable to ``true``), you
can disable the default rendering of XBlocks by setting
``RENDER_XBLOCKS_DEFAULT`` to ``false``.
Optional.

Getting Help
===========

Expand Down
4 changes: 4 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ coverage:
default:
target: auto
threshold: 0%
ignore:
# This is an experimental approach, which should be extracted into a separate repository, so that it can
# be reused between frontend-app-learning, frontend-app-course-authoring and frontend-app-library-authoring.
- src/courseware/course/sequence/XBlock/*
1 change: 1 addition & 0 deletions src/courseware/course/sequence/Unit/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default as useExamAccess } from './useExamAccess';
export { default as useIFrameBehavior } from './useIFrameBehavior';
export { default as useLoadBearingHook } from './useLoadBearingHook';
export { default as useLoadUnitChildren } from './useLoadUnitChildren';
export { default as useModalIFrameData } from './useModalIFrameData';
export { default as useShouldDisplayHonorCode } from './useShouldDisplayHonorCode';
30 changes: 30 additions & 0 deletions src/courseware/course/sequence/Unit/hooks/useLoadUnitChildren.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';

import { getConfig } from '@edx/frontend-platform';
import { StrictDict, useKeyedState } from '@edx/react-unit-test-utils';
import { logError } from '@edx/frontend-platform/logging';
import { getBlockMetadataWithChildren } from '../../../../data/api';

export const stateKeys = StrictDict({
unitChildren: 'unitChildren',
});
const useLoadUnitChildren = (usageId) => {
const [unitChildren, setUnitChildren] = useKeyedState(stateKeys.unitChildren, []);

if (getConfig().RENDER_XBLOCKS_EXPERIMENTAL === true || getConfig().RENDER_XBLOCKS_EXPERIMENTAL === 'true') {
React.useEffect(() => {
(async () => {
try {
const response = await getBlockMetadataWithChildren(usageId);
setUnitChildren(response.children);
} catch (error) {
logError(error);
}
})();
}, [usageId]);
}

return unitChildren;
};

export default useLoadUnitChildren;
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { getConfig } from '@edx/frontend-platform';
import { mockUseKeyedState } from '@edx/react-unit-test-utils';
import React from 'react';
import { isEqual } from 'lodash';
import { logError } from '@edx/frontend-platform/logging';
import { waitFor } from '@testing-library/dom';
import { getBlockMetadataWithChildren } from '../../../../data/api';
import useLoadUnitChildren, { stateKeys } from './useLoadUnitChildren';

const getEffect = (prereqs) => {
const { calls } = React.useEffect.mock;
const match = calls.filter(call => isEqual(call[1], prereqs));
return match.length ? match[0][0] : null;
};

jest.mock('react', () => ({
...jest.requireActual('react'),
useEffect: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
}));
getConfig.mockReturnValue({ RENDER_XBLOCKS_EXPERIMENTAL: false });

jest.mock('../../../../data/api', () => ({
getBlockMetadataWithChildren: jest.fn(),
}));
const mockChildren = ['child1', 'child2'];
getBlockMetadataWithChildren.mockResolvedValue({ children: mockChildren });

const state = mockUseKeyedState(stateKeys);

describe('useLoadUnitChildren hook', () => {
beforeEach(() => {
jest.clearAllMocks();
state.mock();
});
describe('behavior', () => {
const usageId = 'testUsageId';

it('initializes children with an empty array', () => {
useLoadUnitChildren(usageId);
state.expectInitializedWith(stateKeys.unitChildren, []);
});

it('does not fetch children when RENDER_XBLOCKS_EXPERIMENTAL is false (default)', () => {
getBlockMetadataWithChildren.mockResolvedValue({ children: mockChildren });

useLoadUnitChildren(usageId);
state.expectInitializedWith(stateKeys.unitChildren, []);

const useEffectCb = getEffect([usageId], React);
expect(useEffectCb).toBeNull();

expect(getBlockMetadataWithChildren).not.toHaveBeenCalled();
expect(state.setState[stateKeys.unitChildren]).not.toHaveBeenCalled();
});

it('fetches children when RENDER_XBLOCKS_EXPERIMENTAL is true', async () => {
getConfig.mockReturnValueOnce({ RENDER_XBLOCKS_EXPERIMENTAL: true });
getBlockMetadataWithChildren.mockResolvedValue({ children: mockChildren });

useLoadUnitChildren(usageId);
state.expectInitializedWith(stateKeys.unitChildren, []);

getEffect([usageId], React)();

await waitFor(() => expect(getBlockMetadataWithChildren).toHaveBeenCalled());
state.expectSetStateCalledWith(stateKeys.unitChildren, mockChildren);
});

it('logs an error when fetching children fails', async () => {
const testError = 'test-error';
getConfig.mockReturnValueOnce({ RENDER_XBLOCKS_EXPERIMENTAL: true });
getBlockMetadataWithChildren.mockRejectedValue(testError);

useLoadUnitChildren(usageId);
state.expectInitializedWith(stateKeys.unitChildren, []);

getEffect([usageId], React)();

await waitFor(() => expect(getBlockMetadataWithChildren).toHaveBeenCalled());
expect(state.setState[stateKeys.unitChildren]).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledWith(testError);
});
});
});
31 changes: 21 additions & 10 deletions src/courseware/course/sequence/Unit/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';

import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../../generic/model-store';

import BookmarkButton from '../../bookmark/BookmarkButton';
import messages from '../messages';
import ContentIFrame from './ContentIFrame';
import UnitSuspense from './UnitSuspense';
import { modelKeys, views } from './constants';
import { useExamAccess, useShouldDisplayHonorCode } from './hooks';
import { useExamAccess, useShouldDisplayHonorCode, useLoadUnitChildren } from './hooks';
import { getIFrameUrl } from './urls';
import { XBlock } from '../XBlock';

ensureConfig(['RENDER_XBLOCKS_EXPERIMENTAL', 'RENDER_XBLOCKS_DEFAULT']);

const Unit = ({
courseId,
Expand All @@ -27,6 +31,7 @@ const Unit = ({
const unit = useModel(modelKeys.units, id);
const isProcessing = unit.bookmarkedUpdateState === 'loading';
const view = authenticatedUser ? views.student : views.public;
const unitChildren = useLoadUnitChildren(id);

const iframeUrl = getIFrameUrl({
id,
Expand All @@ -45,15 +50,21 @@ const Unit = ({
isProcessing={isProcessing}
/>
<UnitSuspense {...{ courseId, id }} />
<ContentIFrame
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
loadingMessage={formatMessage(messages.loadingSequence)}
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
{getConfig().RENDER_XBLOCKS_DEFAULT !== false && getConfig().RENDER_XBLOCKS_DEFAULT !== 'false' && (
<ContentIFrame
elementId="unit-iframe"
id={id}
iframeUrl={iframeUrl}
loadingMessage={formatMessage(messages.loadingSequence)}
onLoaded={onLoaded}
shouldShowContent={!shouldDisplayHonorCode && !examAccess.blockAccess}
title={unit.title}
/>
)}
{(getConfig().RENDER_XBLOCKS_EXPERIMENTAL === true || getConfig().RENDER_XBLOCKS_EXPERIMENTAL === 'true')
&& unitChildren && unitChildren.map((child) => (
<XBlock key={child} usageId={child} />
))}
</div>
);
};
Expand Down
58 changes: 58 additions & 0 deletions src/courseware/course/sequence/Unit/index.test.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { formatMessage, shallow } from '@edx/react-unit-test-utils/dist';

import { getConfig } from '@edx/frontend-platform';
import { useModel } from '../../../../generic/model-store';

import BookmarkButton from '../../bookmark/BookmarkButton';
Expand Down Expand Up @@ -28,6 +29,7 @@ jest.mock('./ContentIFrame', () => 'ContentIFrame');
jest.mock('./UnitSuspense', () => 'UnitSuspense');
jest.mock('../honor-code', () => 'HonorCode');
jest.mock('../lock-paywall', () => 'LockPaywall');
jest.mock('../XBlock/XBlock', () => 'XBlock');

jest.mock('../../../../generic/model-store', () => ({
useModel: jest.fn(),
Expand All @@ -41,6 +43,7 @@ jest.mock('react', () => ({
jest.mock('./hooks', () => ({
useExamAccess: jest.fn(),
useShouldDisplayHonorCode: jest.fn(),
useLoadUnitChildren: jest.fn(),
}));

jest.mock('./urls', () => ({
Expand All @@ -64,6 +67,9 @@ const examAccess = {
hooks.useExamAccess.mockReturnValue(examAccess);
hooks.useShouldDisplayHonorCode.mockReturnValue(false);

const unitChildren = [];
hooks.useLoadUnitChildren.mockReturnValue(unitChildren);

const unit = {
id: 'unit-id',
title: 'unit-title',
Expand All @@ -72,6 +78,12 @@ const unit = {
};
useModel.mockReturnValue(unit);

jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(),
}));
getConfig.mockReturnValue({ RENDER_XBLOCKS_EXPERIMENTAL: false, RENDER_XBLOCKS_DEFAULT: true });

let el;
describe('Unit component', () => {
beforeEach(() => {
Expand All @@ -84,6 +96,7 @@ describe('Unit component', () => {
courseId: props.courseId,
id: props.id,
});
expect(hooks.useLoadUnitChildren).toHaveBeenCalledWith(props.id);
});
});
describe('output', () => {
Expand Down Expand Up @@ -187,5 +200,50 @@ describe('Unit component', () => {
});
});
});
describe('Experimental XBlock Rendering', () => {
const defaultProps = {
courseId: 'course-id',
format: 'format',
onLoaded: jest.fn(),
id: 'unit-id',
};

beforeEach(() => {
jest.clearAllMocks();
});

const configurations = [
{ experimental: true, default: true, description: 'both true' },
{ experimental: true, default: false, description: 'experimental true, default false' },
{ experimental: false, default: true, description: 'experimental false, default true' },
{ experimental: false, default: false, description: 'both false' },
];

configurations.forEach(({ experimental, default: defaultConfig, description }) => {
it(`renders with RENDER_XBLOCKS_EXPERIMENTAL=${experimental} and RENDER_XBLOCKS_DEFAULT=${defaultConfig} (${description})`, async () => {
getConfig.mockReturnValue({
RENDER_XBLOCKS_EXPERIMENTAL: experimental,
RENDER_XBLOCKS_DEFAULT: defaultConfig,
});

if (experimental) {
hooks.useLoadUnitChildren.mockReturnValueOnce(['child1', 'child2']);
}

component = shallow(<Unit {...defaultProps} />);

if (experimental) {
expect(component.instance.findByType('XBlock').length).toEqual(2);
} else {
expect(component.instance.findByType('XBlock').length).toEqual(0);
}
if (defaultConfig) {
expect(component.instance.findByType('ContentIFrame').length).toEqual(1);
} else {
expect(component.instance.findByType('ContentIFrame').length).toEqual(0);
}
});
});
});
});
});
Loading