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: add direct plugin framework #16

Merged
merged 12 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from 11 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
60 changes: 60 additions & 0 deletions src/plugins/directPlugins/DirectPlugin.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React from 'react';

/**
* Context which makes the list of all plugin changes (allPluginChanges) available to the <DirectSlot> components
* below it in the React tree
*/
export const DirectPluginContext = React.createContext([]);

/**
* @description DirectPluginOperation defines the changes to be made to either the default widget(s) or to any
* that are inserted
* @property {string} Insert - inserts a new widget into the DirectPluginSlot
* @property {string} Hide - used to hide a default widget based on the widgetId
* @property {string} Modify - used to modify/replace a widget's content
* @property {string} Wrap - wraps a widget with a React element or fragment
*
*/

export const DirectPluginOperations = {
Insert: 'insert',
Hide: 'hide',
Modify: 'modify',
Wrap: 'wrap',
};

/**
This is what the allSlotChanges configuration should look like when passed into DirectPluginContext
{
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The one thing that I decided to change in order to steer away from Braden's approach and move closer to eventually using a JS-based config is that his configuration assumed that there'd be multiple allSlotChanges for a Host MFE, and I think whatever benefit that had could've eventually led to confusion/oversight.
I'd argue that keeping all known changes in this one object is easier to track, especially as they'll ideally live in a single JS config anyways.

id: "allDirectPluginChanges",
getDirectSlotChanges() {
return {
"main-nav": [
// Hide the "Drafts" link, except for administrators:
{
op: DirectPluginChangeOperation.Wrap,
widgetId: "drafts",
wrapper: HideExceptForAdmin,
},
// Add a new login link:
{
op: DirectPluginChangeOperation.Insert,
widget: { id: "login", priority: 50, content: {
url: "/login", icon: "person-fill", label: <FormattedMessage defaultMessage="Login" />
}},
},
],
};
},
};
*/

/**
This is what a slotChanges configuration should include depending on the operation:
slotChanges = [
{ op: DirectPluginOperation.Insert; widget: <DirectSlotWidget object> },
{ op: DirectPluginOperation.Hide; widgetId: string },
{ op: DirectPluginOperation.Modify; widgetId: string, fn: (widget: <DirectSlotWidget>) => <DirectSlotWidget> },
{ op: DirectPluginOperation.Wrap; widgetId: string, wrapper: React.FC<{widget: React.ReactElement }> },
]
*/
42 changes: 42 additions & 0 deletions src/plugins/directPlugins/DirectPluginSlot.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';

import { DirectPluginContext } from './DirectPlugin';
import organizePlugins from './utils';

const DirectPluginSlot = ({ defaultContents, slotId, renderWidget }) => {
const allPluginChanges = React.useContext(DirectPluginContext);

Check warning on line 8 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L7-L8

Added lines #L7 - L8 were not covered by tests

const contents = React.useMemo(() => {

Check warning on line 10 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L10

Added line #L10 was not covered by tests
const slotChanges = allPluginChanges.getDirectSlotChanges()[slotId] ?? [];
return organizePlugins(defaultContents, slotChanges);

Check warning on line 12 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L12

Added line #L12 was not covered by tests
}, [allPluginChanges, defaultContents, slotId]);

return (

Check warning on line 15 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L15

Added line #L15 was not covered by tests
<>
{contents.map((c) => {

Check warning on line 17 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L17

Added line #L17 was not covered by tests
jsnwesson marked this conversation as resolved.
Show resolved Hide resolved
if (c.hidden) {
return null;

Check warning on line 19 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L19

Added line #L19 was not covered by tests
}
if (c.wrappers) {
// TODO: define how the reduce logic is able to wrap widgets and make it testable
// eslint-disable-next-line max-len
return c.wrappers.reduce((widget, wrapper) => React.createElement(wrapper, { widget, key: c.id }), renderWidget(c));

Check warning on line 24 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L24

Added line #L24 was not covered by tests
}
return renderWidget(c);

Check warning on line 26 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L26

Added line #L26 was not covered by tests
})}
</>
);
};

DirectPluginSlot.propTypes = {

Check warning on line 32 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L32

Added line #L32 was not covered by tests
defaultContents: PropTypes.shape([]),
slotId: PropTypes.string.isRequired,
renderWidget: PropTypes.func.isRequired,
};

DirectPluginSlot.defaultProps = {

Check warning on line 38 in src/plugins/directPlugins/DirectPluginSlot.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/DirectPluginSlot.jsx#L38

Added line #L38 was not covered by tests
defaultContents: [],
};

export default DirectPluginSlot;
10 changes: 10 additions & 0 deletions src/plugins/directPlugins/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export {
default as DirectPluginSlot,
} from './DirectPluginSlot';
export {
DirectPluginContext,
DirectPluginOperations,
} from './DirectPlugin';
export {
default as organizePlugins,
} from './utils';
33 changes: 33 additions & 0 deletions src/plugins/directPlugins/mocks/DefaultComponentMock.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { Icon } from '@edx/paragon';
import { DirectPluginSlot, DirectPluginContext } from '..';
import { navLinksPlugin } from './PluginComponentsMock';

// TODO: remove DirectPluginsContext and enabledPlugins from here once we have an example app
// these simply demonstrate how the root App would have needed this setup in order to pass in the plugin config
// and make it available to a given DirectPluginSlot

const enabledPlugins = [

Check warning on line 10 in src/plugins/directPlugins/mocks/DefaultComponentMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/DefaultComponentMock.jsx#L10

Added line #L10 was not covered by tests
navLinksPlugin,
];

const MyApp = () => (
<DirectPluginContext value={enabledPlugins}>

Check warning on line 15 in src/plugins/directPlugins/mocks/DefaultComponentMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/DefaultComponentMock.jsx#L14-L15

Added lines #L14 - L15 were not covered by tests
<div>
<DirectPluginSlot
slotId="side-bar-nav"
defaultContents={navLinksPlugin.defaultLinks}
jsnwesson marked this conversation as resolved.
Show resolved Hide resolved
renderWidget={(link) => (
<a

Check warning on line 21 in src/plugins/directPlugins/mocks/DefaultComponentMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/DefaultComponentMock.jsx#L21

Added line #L21 was not covered by tests
href={link.content.url}
key={link.id}
>
<Icon src={link.content.icon} /> {link.content.label}
</a>
)}
/>
</div>
</DirectPluginContext>
);

export default MyApp;
65 changes: 65 additions & 0 deletions src/plugins/directPlugins/mocks/PluginComponentsMock.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-disable react/prop-types */
// eslint-disable-next-line import/no-extraneous-dependencies
import React from 'react';
import {
House, Star, InsertDriveFile, Login,
} from '@edx/paragon/icons';
import { DirectPluginOperations } from '..';

/** This is for us to be able to mock in tests */
const isAdminHelper = () => true;

Check warning on line 10 in src/plugins/directPlugins/mocks/PluginComponentsMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/PluginComponentsMock.jsx#L10

Added line #L10 was not covered by tests
jsnwesson marked this conversation as resolved.
Show resolved Hide resolved

/** This is a React widget that wraps its children and makes them visible only to administrators */
const HideExceptForAdmin = ({ widget }) => {
const isAdmin = isAdminHelper();

Check warning on line 14 in src/plugins/directPlugins/mocks/PluginComponentsMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/PluginComponentsMock.jsx#L13-L14

Added lines #L13 - L14 were not covered by tests
return <React.Fragment key={widget.key}>{isAdmin ? widget : null}</React.Fragment>;
};

const navLinksPlugin = {

Check warning on line 18 in src/plugins/directPlugins/mocks/PluginComponentsMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/PluginComponentsMock.jsx#L18

Added line #L18 was not covered by tests
id: 'links-demo', // id isn't used anywhere, but can be extended to
defaultComponentProps: [
{
id: 'home',
priority: 5,
content: { url: '/', icon: House, label: 'Home' },
},
{
id: 'lookup',
priority: 25,
content: { url: '/lookup', icon: Star, label: 'Lookup' },
},
{
id: 'drafts',
priority: 35,
content: { url: '/drafts', icon: InsertDriveFile, label: 'Drafts' },
},
],
getDirectSlotChanges() {
return {

Check warning on line 38 in src/plugins/directPlugins/mocks/PluginComponentsMock.jsx

View check run for this annotation

Codecov / codecov/patch

src/plugins/directPlugins/mocks/PluginComponentsMock.jsx#L37-L38

Added lines #L37 - L38 were not covered by tests
'side-bar-nav': [ // slot id that is used by directpluginslot
// Hide the "Drafts" link, except for administrators:
{
op: DirectPluginOperations.Wrap,
widgetId: 'drafts',
wrapper: HideExceptForAdmin,
},
// Add a new login link after the rest of default plugins:
{
op: DirectPluginOperations.Insert,
widget: {
id: 'login',
priority: 50,
content: {
url: '/login', icon: Login, label: 'Login',
},
},
},
],
};
},
};

export {
isAdminHelper,
navLinksPlugin,
};
41 changes: 41 additions & 0 deletions src/plugins/directPlugins/utils.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { DirectPluginOperations } from './DirectPlugin';

/**
* Called by DirectPluginSlot to prepare the plugin changes for the given slot
*
* @param {Array} defaultContents - The default widgets where the plugin slot exists.
* @param {Array} slotChanges - All of the changes assigned to the specific plugin slot
* @returns {Array} - A sorted list of widgets with any additional properties needed to render them in the plugin slot
*/
const organizePlugins = (defaultContents, slotChanges) => {
const newContents = [...defaultContents];

slotChanges.forEach(change => {
if (change.op === DirectPluginOperations.Insert) {
newContents.push(change.widget);
} else if (change.op === DirectPluginOperations.Hide) {
const widget = newContents.find((w) => w.id === change.widgetId);
if (widget) { widget.hidden = true; }
} else if (change.op === DirectPluginOperations.Modify) {
const widgetIdx = newContents.findIndex((w) => w.id === change.widgetId);
if (widgetIdx >= 0) {
const widget = { ...newContents[widgetIdx] };
newContents[widgetIdx] = change.fn(widget);
}
} else if (change.op === DirectPluginOperations.Wrap) {
const widgetIdx = newContents.findIndex((w) => w.id === change.widgetId);
if (widgetIdx >= 0) {
const newWidget = { wrappers: [], ...newContents[widgetIdx] };
newWidget.wrappers.push(change.wrapper);
newContents[widgetIdx] = newWidget;
}
} else {
throw new Error('unknown direct plugin change operation');
}
});

newContents.sort((a, b) => (a.priority - b.priority) * 10_000 + a.id.localeCompare(b.id));
return newContents;
};

export default organizePlugins;
Loading