feat: Notification Plugin Slots (#1368)
* feat: add plugin slot for fbe lock paywall (#1347) * feat: Added PluginSlot wrapping UpgradeNotification components (#1366) * chore: Updated PluginSlot mock to support children and test ids * chore: Updated mocked PluginSlot * chore: Added unit test for MockedPluginSlot * fix: Updated slot name ids * feat: Added Plugin Slot wrapping UpgradeNotification in NotificationTray (#1367) * fix: Removed PluginSlot prop scoping for UpgradeNotification (#1369) * feat: generic sidebar notification plugin (#1379) * feat: make notification plugin api generic * feat: include new sidebar and tests * feat: tweak sidebar toggle * style: fix extra space from merge * feat: rename plugin slots --------- Co-authored-by: Alison Langston <46360176+alangsto@users.noreply.github.com> Co-authored-by: Zachary Hancock <zhancock@edx.org>
This commit is contained in:
@@ -5,6 +5,7 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import CourseDates from './widgets/CourseDates';
|
||||
@@ -194,19 +195,24 @@ const OutlineTab = ({ intl }) => {
|
||||
/>
|
||||
)}
|
||||
<CourseTools />
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
<PluginSlot
|
||||
id="outline_tab_notifications_plugin"
|
||||
pluginProps={{ courseId }}
|
||||
>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={datesBannerInfo.contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="course_home"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
/>
|
||||
</PluginSlot>
|
||||
<CourseDates />
|
||||
<CourseHandouts />
|
||||
</div>
|
||||
|
||||
@@ -132,6 +132,16 @@ describe('Outline Tab', () => {
|
||||
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('includes outline_tab_notifications_plugin slot', async () => {
|
||||
const { courseBlocks } = await buildMinimalCourseBlocks(courseId, 'Title', { resumeBlock: true });
|
||||
setTabData({
|
||||
course_blocks: { blocks: courseBlocks.blocks },
|
||||
});
|
||||
await fetchAndRender();
|
||||
|
||||
expect(screen.getByTestId('outline_tab_notifications_plugin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles expand/collapse all button click', async () => {
|
||||
await fetchAndRender();
|
||||
// Button renders as "Expand All"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useModel } from '../../../../../../generic/model-store';
|
||||
import UpgradeNotification from '../../../../../../generic/upgrade-notification/UpgradeNotification';
|
||||
import { WIDGETS } from '../../../../../../constants';
|
||||
@@ -58,6 +59,10 @@ const NotificationsWidget = () => {
|
||||
verification_status: verificationStatus,
|
||||
};
|
||||
|
||||
const onToggleSidebar = () => {
|
||||
toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS);
|
||||
};
|
||||
|
||||
// After three seconds, update notificationSeen (to hide red dot)
|
||||
useEffect(() => {
|
||||
setTimeout(onNotificationSeen, 3000);
|
||||
@@ -68,22 +73,32 @@ const NotificationsWidget = () => {
|
||||
|
||||
return (
|
||||
<div className="border border-light-400 rounded-sm" data-testid="notification-widget">
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
toggleSidebar={() => toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS)}
|
||||
/>
|
||||
<PluginSlot
|
||||
id="notification_widget_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
notificationCurrentState: upgradeNotificationCurrentState,
|
||||
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
||||
toggleSidebar: onToggleSidebar,
|
||||
}}
|
||||
>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
toggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
</PluginSlot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -78,6 +78,21 @@ describe('NotificationsWidget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('includes notification_widget_slot', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
hideNotificationbar: false,
|
||||
isNotificationbarAvailable: true,
|
||||
}}
|
||||
>
|
||||
<NotificationsWidget />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(screen.getByTestId('notification_widget_slot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
@@ -90,9 +105,11 @@ describe('NotificationsWidget', () => {
|
||||
<NotificationsWidget />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
|
||||
// The Upgrade Notification should be inside the PluginSlot.
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
expect(UpgradeNotification).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { Suspense } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
@@ -24,19 +25,24 @@ const UnitSuspense = ({
|
||||
meta.contentTypeGatingEnabled && unit.containsContentTypeGatedContent
|
||||
);
|
||||
|
||||
const suspenseComponent = (message, Component) => (
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(message)} />}>
|
||||
<Component courseId={courseId} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayContentGating && (
|
||||
suspenseComponent(messages.loadingLockedContent, LockPaywall)
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />}>
|
||||
<PluginSlot
|
||||
id="gated_unit_content_message_slot"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<LockPaywall courseId={courseId} />
|
||||
</PluginSlot>
|
||||
</Suspense>
|
||||
)}
|
||||
{shouldDisplayHonorCode && (
|
||||
suspenseComponent(messages.loadingHonorCode, HonorCode)
|
||||
<Suspense fallback={<PageLoading srMessage={formatMessage(messages.loadingHonorCode)} />}>
|
||||
<HonorCode courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('UnitSuspense component', () => {
|
||||
describe('output', () => {
|
||||
describe('LockPaywall', () => {
|
||||
const testNoPaywall = () => {
|
||||
it('does not display LockPaywal', () => {
|
||||
it('does not display LockPaywall', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
expect(el.instance.findByType(LockPaywall).length).toEqual(0);
|
||||
});
|
||||
@@ -79,8 +79,9 @@ describe('UnitSuspense component', () => {
|
||||
it('displays LockPaywall in Suspense wrapper with PageLoading fallback', () => {
|
||||
el = shallow(<UnitSuspense {...props} />);
|
||||
const [component] = el.instance.findByType(LockPaywall);
|
||||
expect(component.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.props.fallback)
|
||||
expect(component.parent.type).toEqual('PluginSlot');
|
||||
expect(component.parent.parent.type).toEqual('Suspense');
|
||||
expect(component.parent.parent.props.fallback)
|
||||
.toEqual(<PageLoading srMessage={formatMessage(messages.loadingLockedContent)} />);
|
||||
expect(component.props.courseId).toEqual(props.courseId);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import UpgradeNotification from '@src/generic/upgrade-notification/UpgradeNotification';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import UpgradeNotification from '../../../../../generic/upgrade-notification/UpgradeNotification';
|
||||
|
||||
import messages from '../../../messages';
|
||||
import SidebarBase from '../../common/SidebarBase';
|
||||
@@ -77,21 +78,30 @@ const NotificationTray = ({ intl }) => {
|
||||
>
|
||||
<div>{verifiedMode
|
||||
? (
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
/>
|
||||
<PluginSlot
|
||||
id="notification_tray_plugin"
|
||||
pluginProps={{
|
||||
courseId,
|
||||
notificationCurrentState: upgradeNotificationCurrentState,
|
||||
setNotificationCurrentState: setUpgradeNotificationCurrentState,
|
||||
}}
|
||||
>
|
||||
<UpgradeNotification
|
||||
offer={offer}
|
||||
verifiedMode={verifiedMode}
|
||||
accessExpiration={accessExpiration}
|
||||
contentTypeGatingEnabled={contentTypeGatingEnabled}
|
||||
marketingUrl={marketingUrl}
|
||||
upsellPageName="in_course"
|
||||
userTimezone={userTimezone}
|
||||
shouldDisplayBorder={false}
|
||||
timeOffsetMillis={timeOffsetMillis}
|
||||
courseId={courseId}
|
||||
org={org}
|
||||
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
|
||||
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
|
||||
/>
|
||||
</PluginSlot>
|
||||
) : (
|
||||
<p className="p-3 small">{intl.formatMessage(messages.noNotificationsMessage)}</p>
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,19 @@ describe('NotificationTray', () => {
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes notification_tray_plugin slot', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
currentSidebar: ID,
|
||||
courseId,
|
||||
}}
|
||||
>
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
expect(screen.getByTestId('notification_tray_plugin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upgrade card', async () => {
|
||||
await fetchAndRender(
|
||||
<SidebarContext.Provider value={{
|
||||
@@ -91,10 +104,9 @@ describe('NotificationTray', () => {
|
||||
<NotificationTray />
|
||||
</SidebarContext.Provider>,
|
||||
);
|
||||
const UpgradeNotification = document.querySelector('.upgrade-notification');
|
||||
|
||||
expect(UpgradeNotification)
|
||||
.toBeInTheDocument();
|
||||
expect(document.querySelector('.upgrade-notification')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Upgrade for $149' }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText('You have no new notifications at this time.'))
|
||||
|
||||
@@ -29,11 +29,12 @@ import { getCourseOutlineStructure } from './courseware/data/thunks';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from './utils';
|
||||
import buildSimpleCourseAndSequenceMetadata from './courseware/data/__factories__/sequenceMetadata.factory';
|
||||
import { buildOutlineFromBlocks } from './courseware/data/__factories__/learningSequencesOutline.factory';
|
||||
import MockedPluginSlot from './tests/MockedPluginSlot';
|
||||
|
||||
jest.mock('@openedx/frontend-plugin-framework', () => ({
|
||||
...jest.requireActual('@openedx/frontend-plugin-framework'),
|
||||
Plugin: () => 'Plugin',
|
||||
PluginSlot: () => 'PluginSlot',
|
||||
PluginSlot: MockedPluginSlot,
|
||||
}));
|
||||
|
||||
jest.mock('@src/generic/plugin-store', () => ({
|
||||
|
||||
26
src/tests/MockedPluginSlot.jsx
Normal file
26
src/tests/MockedPluginSlot.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const MockedPluginSlot = ({ children, id }) => (
|
||||
<div data-testid={id}>
|
||||
PluginSlot_{id}
|
||||
{ children && <div>{children}</div> }
|
||||
</div>
|
||||
);
|
||||
|
||||
MockedPluginSlot.displayName = 'PluginSlot';
|
||||
|
||||
MockedPluginSlot.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node,
|
||||
]),
|
||||
id: PropTypes.string,
|
||||
};
|
||||
|
||||
MockedPluginSlot.defaultProps = {
|
||||
children: undefined,
|
||||
id: undefined,
|
||||
};
|
||||
|
||||
export default MockedPluginSlot;
|
||||
43
src/tests/MockedPluginSlot.test.jsx
Normal file
43
src/tests/MockedPluginSlot.test.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import MockedPluginSlot from './MockedPluginSlot';
|
||||
|
||||
describe('MockedPluginSlot', () => {
|
||||
it('renders mock plugin with "PluginSlot" text', () => {
|
||||
render(<MockedPluginSlot id="test_plugin" />);
|
||||
|
||||
const component = screen.getByText('PluginSlot_test_plugin');
|
||||
expect(component).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as the slot children directly if there is content within', () => {
|
||||
render(
|
||||
<div role="article">
|
||||
<MockedPluginSlot>
|
||||
<q role="note">How much wood could a woodchuck chuck if a woodchuck could chuck wood?</q>
|
||||
</MockedPluginSlot>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const component = screen.getByRole('article');
|
||||
expect(component).toBeInTheDocument();
|
||||
|
||||
// Direct children
|
||||
const quote = component.querySelector(':scope > q');
|
||||
expect(quote.getAttribute('role')).toBe('note');
|
||||
});
|
||||
|
||||
it('renders mock plugin with a data-testid ', () => {
|
||||
render(
|
||||
<MockedPluginSlot id="guybrush">
|
||||
<q role="note">I am selling these fine leather jackets.</q>
|
||||
</MockedPluginSlot>,
|
||||
);
|
||||
|
||||
const component = screen.getByTestId('guybrush');
|
||||
expect(component).toBeInTheDocument();
|
||||
|
||||
const quote = component.querySelector('[role=note]');
|
||||
expect(quote).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user