Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support merging base component props, custom props, and pluginProps via slotOptions.mergeProps #84

Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions example-plugin-app/src/DefaultIframe.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Plugin } from '@openedx/frontend-plugin-framework';
function DefaultComponent() {
return (
<section className="bg-light p-3 h-100">
<h3>Default iFrame Widget</h3>
<h4>Default iFrame Widget</h4>
<p>
This is a component that lives in the example-plugins-app and is provided in this host MFE via iFrame.
</p>
Expand All @@ -17,7 +17,7 @@ function DefaultComponent() {
function ErrorFallback(error) {
return (
<div className="text-center">
<p className="h3 text-muted">
<p className="h4 text-muted">
Oops! An error occurred. Please refresh the screen to try again.
</p>
<br />
Expand Down
2 changes: 1 addition & 1 deletion example-plugin-app/src/PluginIframe.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default function PluginIframe() {
return (
<Plugin>
<section className="bg-light p-3 h-100">
<h3>Inserted iFrame Plugin</h3>
<h4>Inserted iFrame Plugin</h4>
<p>
This is a component that lives in the example-plugins-app and is provided in this host MFE via iFrame plugin.
</p>
Expand Down
54 changes: 47 additions & 7 deletions example/env.config.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
IFRAME_PLUGIN,
PLUGIN_OPERATIONS,
} from '@openedx/frontend-plugin-framework';
import DefaultDirectWidget from './src/components/DefaultDirectWidget';
import PluginDirect from './src/components/PluginDirect';
import ModularComponent from './src/components/ModularComponent';

Expand All @@ -18,12 +17,33 @@ const modifyWidget = (widget) => {
return modifiedWidget;
};

const wrapWidget = ({ component, idx }) => (
<div className="bg-warning" data-testid={`wrapper${idx + 1}`} key={idx}>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform/context] Passing the key here does not resolve the React console warning; The key needs to be applied to the parent wrapWidget function as opposed to a node within wrapWidget, given its acting as a component (e.g., wrapWidget is the display name of the rendered component).

<p>This is a wrapper component that is placed around the default content.</p>
const modifyWidgetDefaultContents = (widget) => {
adamstankiewicz marked this conversation as resolved.
Show resolved Hide resolved
const newContent = {
'data-custom-attr': 'customValue',
'data-another-custom-attr': '',
className: 'font-weight-bold',
style: { color: 'blue' },
onClick: (e) => { console.log('Username clicked!', 'custom', e); },
};
widget.content = newContent;
adamstankiewicz marked this conversation as resolved.
Show resolved Hide resolved
return widget;
};

const modifyWidgetDefaultContentsLink = (widget) => {
widget.content.href = 'https://openedx.org';
return widget;
};

const wrapWidget = ({ component }) => (
<div className="bg-warning" data-testid="wrapper">
<div className="px-3">
<p className="mb-0">This is a wrapper component that is placed around the default content.</p>
</div>
{component}
<p>With this wrapper, you can add anything before or after a component.</p>
<p>Note in the JS config that an iFrame plugin was Inserted, but a Hide operation was also used to hide it!</p>
<div className="px-3">
<p>With this wrapper, you can add anything before or after a component.</p>
<p className="mb-0">Note in the JS config that an iFrame plugin was Inserted, but a Hide operation was also used to hide it!</p>
</div>
</div>
);

Expand Down Expand Up @@ -179,7 +199,27 @@ const config = {
},
},
],
}
},
slot_with_username_pii: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyWidgetDefaultContents,
},
],
},
slot_with_hyperlink: {
keepDefault: true,
plugins: [
{
op: PLUGIN_OPERATIONS.Modify,
widgetId: 'default_contents',
fn: modifyWidgetDefaultContentsLink,
},
],
},
},
};

Expand Down
6 changes: 6 additions & 0 deletions example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"author": "edX",
"license": "AAGPL-3.0",
"dependencies": {
"classnames": "^2.5.1",
"core-js": "^3.29.1",
"prop-types": "^15.8.1",
"react": "^17.0.0",
Expand Down
89 changes: 69 additions & 20 deletions example/src/ExamplePage.jsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,80 @@
import React from 'react';
import {
Container, Row, Col, Stack,
} from '@openedx/paragon';

import PluginSlotWithModifyDefaultContentsLink from './pluginSlots/PluginSlotWithModifyDefaultContentsLink';
import PluginSlotWithModifyDefaultContents from './pluginSlots/PluginSlotWithModifyDefaultContents';
import PluginSlotWithInsert from './pluginSlots/PluginSlotWithInsert';
import PluginSlotWithModifyWrapHide from './pluginSlots/PluginSlotWithModifyWrapHide';
import PluginSlotWithModularPlugins from './pluginSlots/PluginSlotWithModularPlugins';
import PluginSlotWithoutDefault from './pluginSlots/PluginSlotWithoutDefault';

const pluginExamples = [
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform/context] Refactored ExamplePage to iterate through pluginExamples to ensure the generated Table of Content links (below) are created in the same order as the plugin examples themselves.

{
id: 'plugin-operation-insert',
label: 'Plugin Operation: Insert',
Component: PluginSlotWithInsert,
},
{
id: 'plugin-operation-modify-wrap-hide',
label: 'Plugin Operation: Modify, Wrap, and Hide',
Component: PluginSlotWithModifyWrapHide,
},
{
id: 'plugin-operation-modify-default-content',
label: 'Plugin Operation: Modify Default Content',
Component: PluginSlotWithModifyDefaultContents,
},
{
id: 'plugin-operation-modify-default-content-link',
label: 'Plugin Operation: Modify Default Content (Link)',
Component: PluginSlotWithModifyDefaultContentsLink,
},
{
id: 'direct-plugins-modular-components',
label: 'Direct Plugins Using Modular Components',
Component: PluginSlotWithModularPlugins,
},
{
id: 'no-default-content',
label: 'Default Content Set to False',
Component: PluginSlotWithoutDefault,
},
];

export default function ExamplePage() {
return (
<main className="center m-5">
<h1>Plugins Page</h1>

<p>
This page is here to help test the plugins module. A plugin configuration can be added in
index.jsx and this page will display that plugin.
</p>
<p>
To do this, a plugin MFE must be running on some other port.
To make it a more realistic test, you may also want to edit your
/etc/hosts file (or your system&apos;s equivalent) to provide an alternate domain for
127.0.0.1 at which you can load the plugin.
</p>
<div className="d-flex flex-column">
<PluginSlotWithInsert />
<PluginSlotWithModifyWrapHide />
<PluginSlotWithModularPlugins />
<PluginSlotWithoutDefault />
</div>
<main>
<Container size="lg" className="py-3">
<Row>
<Col>
<h1>Plugins Page</h1>
<p>
This page is here to help test the plugins module. A plugin configuration can be added in
index.jsx and this page will display that plugin.
</p>
<p>
To do this, a plugin MFE must be running on some other port.
To make it a more realistic test, you may also want to edit your
/etc/hosts file (or your system&apos;s equivalent) to provide an alternate domain for
127.0.0.1 at which you can load the plugin.
</p>
<h2>Examples</h2>
<Stack gap={3}>
<ul>
{pluginExamples.map(({ id, label }) => (
<li key={id}>
<a href={`#${id}`}>{label}</a>
</li>
))}
</ul>
Comment on lines +58 to +64
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform/context] Includes a small list of skip links to navigate to specific example(s), without necessarily needing to scroll to find a specific example.

<Stack gap={5}>
{pluginExamples.map(({ Component, id, label }) => <Component key={id} id={id} label={label} />)}
</Stack>
</Stack>
</Col>
</Row>
</Container>
</main>
);
}
2 changes: 1 addition & 1 deletion example/src/components/DefaultDirectWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react';
export default function DefaultDirectWidget() {
return (
<section className="bg-success p-3 text-light">
<h3>Default Direct Widget</h3>
<h4>Default Direct Widget</h4>
<p>
This widget is a default component that lives in the example app and is directly inserted via JS configuration.
Note that this default widget appears after the Inserted Direct Plugin. This is because this component&apos;s
Expand Down
4 changes: 2 additions & 2 deletions example/src/components/ModularComponent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
export default function ModularComponent({ content }) {
return (
<section className="bg-light p-3">
<h3>{ content.title }</h3>
<h4>{content.title}</h4>
<p>
This is a modular component that lives in the example app.
</p>
<p>
<p className="mb-0">
<em>{content.uniqueText}</em>
</p>
</section>
Expand Down
2 changes: 1 addition & 1 deletion example/src/components/PluginDirect.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react';
export default function PluginDirect() {
return (
<section className="bg-warning p-3">
<h3>Inserted Direct Plugin</h3>
<h4>Inserted Direct Plugin</h4>
<p>
This plugin is a component that lives in the example app and is directly inserted via JS configuration.
What makes this unique is that it isn&apos;t part of the default content defined for this slot, but is instead
Expand Down
8 changes: 4 additions & 4 deletions example/src/pluginSlots/PluginSlotWithInsert.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import React from 'react';

import { PluginSlot } from '@openedx/frontend-plugin-framework';

function PluginSlotWithInsert() {
function PluginSlotWithInsert({ id, label }) {
return (
<div className="border border-primary mb-2">
<h2 className="pl-3">Plugin Operation: Insert</h2>
<div className="border border-primary">
<h3 id={id} className="pl-3">{label}</h3>
<PluginSlot
id="slot_with_insert_operation"
>
<section className="bg-success p-3 text-light">
<h3>Default Content</h3>
<h4>Default Content</h4>
<p>
This widget represents a component that is wrapped by the Plugin Slot.

Expand Down
107 changes: 107 additions & 0 deletions example/src/pluginSlots/PluginSlotWithModifyDefaultContents.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';

import { PluginSlot } from '@openedx/frontend-plugin-framework';
import classNames from 'classnames';


// Example component used as the default childen within a PluginSlot
const Username = ({ className, onClick, ...rest }) => {
const authenticatedUser = { username: 'testuser' };
const { username } = authenticatedUser;
return (
<span
className={classNames('default-classname', className)}
onClick={(e) => {
console.log('Username clicked!', 'default', e);
onClick?.(e);
}}
{...rest}
>
{username}
</span>
);
};

const UsernameWithPluginContent = ({ className, onClick, content = {} }) => {
const {
className: classNameFromPluginContent,
onClick: onClickFromPluginContent,
...contentRest
} = content;
const updatedProps = {
className: classNames(className, classNameFromPluginContent),
onClick: (e) => {
onClick?.(e);
onClickFromPluginContent?.(e);
},
};
return <Username {...updatedProps} {...contentRest} />;
};

function PluginSlotWithModifyDefaultContents({ id, label }) {
return (
<div className="border border-primary px-3">
<h3 id={id}>{label}</h3>
<p>
The following <code>PluginSlot</code> examples demonstrate the <code>PLUGIN_OPERATIONS.Modify</code> operation, when
the <code>widgetId</code> is <code>default_contents</code>. Any configured, custom plugin <code>content</code> is
merged with any existing props passed to the component(s) represented by <code>default_contents</code>.
</p>
<ul>
<li>Custom <code>className</code> overrides are concatenated with the <code>className</code> prop passed to the <code>default_contents</code> component(s), if any.</li>
<li>Custom <code>style</code> overrides are shallow merged with the <code>style</code> prop passed to the <code>default_contents</code> component(s), if any.</li>
<li>Custom event handlers (e.g., <code>onClick</code>) are executed in sequence, after any event handlers passed to the <code>default_contents</code> component(s), if any.</li>
</ul>
<PluginSlot
id="slot_with_username_pii"
as="div"
// Default slotOptions
slotOptions={{
mergeProps: false,
}}
adamstankiewicz marked this conversation as resolved.
Show resolved Hide resolved
>
<UsernameWithPluginContent
className="tetsing"
onClick={(e) => {
console.log('Username clicked!', 'prop', e);
}}
/>
</PluginSlot>
<PluginSlot
id="slot_with_username_pii"
as="div"
slotOptions={{
mergeProps: true,
}}
>
<Username
className="d-block abc123"
onClick={(e) => {
console.log('Username clicked!', 'prop', e);
}}
/>
{/* <Username className="ghi789" /> // Note: uncomment to see example of incorrectly using multiple `children` elements within `PluginSlot` */}
</PluginSlot>
<PluginSlot
id="slot_with_username_pii"
as="div"
pluginProps={{
className: 'bg-accent-b',
onClick: (e) => { console.log('Username clicked!', 'pluginProps', e); },
}}
slotOptions={{
mergeProps: true,
}}
>
<Username
className="ghi789"
onClick={(e) => {
console.log('Username clicked!', 'prop', e);
}}
/>
</PluginSlot>
</div>
);
}

export default PluginSlotWithModifyDefaultContents;
Loading