Skip to content

Commit

Permalink
Merge pull request #16 from openedx/jwesson/add-direct-plugin-framework
Browse files Browse the repository at this point in the history
feat: add direct plugin framework to library in a separate directory
  • Loading branch information
jsnwesson authored Jan 18, 2024
2 parents 496f198 + 7b0326e commit 66df89e
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 0 deletions.
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
{
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);

const preparedWidgets = React.useMemo(() => {
const slotChanges = allPluginChanges.getDirectSlotChanges()[slotId] ?? [];
return organizePlugins(defaultContents, slotChanges);
}, [allPluginChanges, defaultContents, slotId]);

return (
<>
{preparedWidgets.map((preppedWidget) => {
if (preppedWidget.hidden) {
return null;
}
if (preppedWidget.wrappers) {
// TODO: define how the reduce logic is able to wrap widgets and make it testable
// eslint-disable-next-line max-len
return preppedWidget.wrappers.reduce((widget, wrapper) => React.createElement(wrapper, { widget, key: preppedWidget.id }), renderWidget(preppedWidget));
}
return renderWidget(preppedWidget);
})}
</>
);
};

DirectPluginSlot.propTypes = {
defaultContents: PropTypes.shape([]),
slotId: PropTypes.string.isRequired,
renderWidget: PropTypes.func.isRequired,
};

DirectPluginSlot.defaultProps = {
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';
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;
173 changes: 173 additions & 0 deletions src/plugins/directPlugins/utils.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import '@testing-library/jest-dom';

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

const mockModifyWidget = (widget) => {
const newContent = {
url: '/search',
label: 'Search',
};
const modifiedWidget = widget;
modifiedWidget.content = newContent;
return modifiedWidget;
};

function mockWrapWidget({ widget }) {
const isAdmin = true;
return isAdmin ? widget : null;
}

const mockSlotChanges = [
{
op: DirectPluginOperations.Wrap,
widgetId: 'drafts',
wrapper: mockWrapWidget,
},
{
op: DirectPluginOperations.Hide,
widgetId: 'home',
},
{
op: DirectPluginOperations.Modify,
widgetId: 'lookUp',
fn: mockModifyWidget,
},
{
op: DirectPluginOperations.Insert,
widget: {
id: 'login',
priority: 50,
content: {
url: '/login', label: 'Login',
},
},
},
];

const mockDefaultContent = [
{
id: 'home',
priority: 5,
content: { url: '/', label: 'Home' },
},
{
id: 'lookUp',
priority: 25,
content: { url: '/lookup', label: 'Lookup' },
},
{
id: 'drafts',
priority: 35,
content: { url: '/drafts', label: 'Drafts' },
},
];

describe('organizePlugins', () => {
describe('when there is no defaultContent', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should return an empty array when there are no changes or additions to slot', () => {
const plugins = organizePlugins([], []);
expect(plugins.length).toBe(0);
expect(plugins).toEqual([]);
});

it('should return an array of changes for non-default plugins', () => {
const plugins = organizePlugins([], mockSlotChanges);
expect(plugins.length).toEqual(1);
expect(plugins[0].id).toEqual('login');
});
});

describe('when there is defaultContent', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should return an array of defaultContent if no changes for plugins in slot', () => {
const plugins = organizePlugins(mockDefaultContent, []);
expect(plugins.length).toEqual(3);
expect(plugins).toEqual(mockDefaultContent);
});

it('should remove plugins with DirectOperation.Hide', () => {
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
const widget = plugins.find((w) => w.id === 'home');
expect(plugins.length).toEqual(4);
expect(widget.hidden).toBe(true);
});

it('should modify plugins with DirectOperation.Modify', () => {
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
const widget = plugins.find((w) => w.id === 'lookUp');

expect(plugins.length).toEqual(4);
expect(widget.content.url).toEqual('/search');
});

it('should wrap plugins with DirectOperation.Wrap', () => {
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
const widget = plugins.find((w) => w.id === 'drafts');
expect(plugins.length).toEqual(4);
expect(widget.wrappers.length).toEqual(1);
});

it('should accept several wrappers for a single plugin with DirectOperation.Wrap', () => {
const newMockWrapComponent = ({ widget }) => {
const isStudent = false;
return isStudent ? null : widget;
};
const newPluginChange = {
op: DirectPluginOperations.Wrap,
widgetId: 'drafts',
wrapper: newMockWrapComponent,
};
mockSlotChanges.push(newPluginChange);
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
const widget = plugins.find((w) => w.id === 'drafts');
expect(plugins.length).toEqual(4);
expect(widget.wrappers.length).toEqual(2);
expect(widget.wrappers[0]).toEqual(mockWrapWidget);
expect(widget.wrappers[1]).toEqual(newMockWrapComponent);
});

it('should return plugins arranged by priority', () => {
const newPluginChange = {
op: DirectPluginOperations.Insert,
widget: {
id: 'profile',
priority: 1,
content: {
url: '/profile', label: 'Profile',
},
},
};
mockSlotChanges.push(newPluginChange);
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
expect(plugins.length).toEqual(5);
expect(plugins[0].id).toBe('profile');
expect(plugins[1].id).toBe('home');
expect(plugins[2].id).toBe('lookUp');
expect(plugins[3].id).toBe('drafts');
expect(plugins[4].id).toBe('login');
});

it('should raise an error for an operation that does not exist', async () => {
const badPluginChange = {
op: DirectPluginOperations.Destroy,
widgetId: 'drafts',
};
mockSlotChanges.push(badPluginChange);

expect.assertions(1);
try {
await organizePlugins(mockDefaultContent, mockSlotChanges);
} catch (error) {
expect(error.message).toBe('unknown direct plugin change operation');
}
});
});
});

0 comments on commit 66df89e

Please sign in to comment.