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:
Marcos Rigoli
2024-05-13 17:59:20 -03:00
committed by GitHub
parent 1c3610e9af
commit 1857b86c7e
11 changed files with 209 additions and 62 deletions

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
);
};

View File

@@ -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();
});

View File

@@ -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>
)}
</>
);

View File

@@ -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);
});

View File

@@ -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>
)}

View File

@@ -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.'))

View File

@@ -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', () => ({

View 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;

View 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();
});
});