Skip to content

Commit 66df89e

Browse files
authored
Merge pull request openedx#16 from openedx/jwesson/add-direct-plugin-framework
feat: add direct plugin framework to library in a separate directory
2 parents 496f198 + 7b0326e commit 66df89e

5 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React from 'react';
2+
3+
/**
4+
* Context which makes the list of all plugin changes (allPluginChanges) available to the <DirectSlot> components
5+
* below it in the React tree
6+
*/
7+
export const DirectPluginContext = React.createContext([]);
8+
9+
/**
10+
* @description DirectPluginOperation defines the changes to be made to either the default widget(s) or to any
11+
* that are inserted
12+
* @property {string} Insert - inserts a new widget into the DirectPluginSlot
13+
* @property {string} Hide - used to hide a default widget based on the widgetId
14+
* @property {string} Modify - used to modify/replace a widget's content
15+
* @property {string} Wrap - wraps a widget with a React element or fragment
16+
*
17+
*/
18+
19+
export const DirectPluginOperations = {
20+
Insert: 'insert',
21+
Hide: 'hide',
22+
Modify: 'modify',
23+
Wrap: 'wrap',
24+
};
25+
26+
/**
27+
This is what the allSlotChanges configuration should look like when passed into DirectPluginContext
28+
{
29+
id: "allDirectPluginChanges",
30+
getDirectSlotChanges() {
31+
return {
32+
"main-nav": [
33+
// Hide the "Drafts" link, except for administrators:
34+
{
35+
op: DirectPluginChangeOperation.Wrap,
36+
widgetId: "drafts",
37+
wrapper: HideExceptForAdmin,
38+
},
39+
// Add a new login link:
40+
{
41+
op: DirectPluginChangeOperation.Insert,
42+
widget: { id: "login", priority: 50, content: {
43+
url: "/login", icon: "person-fill", label: <FormattedMessage defaultMessage="Login" />
44+
}},
45+
},
46+
],
47+
};
48+
},
49+
};
50+
*/
51+
52+
/**
53+
This is what a slotChanges configuration should include depending on the operation:
54+
slotChanges = [
55+
{ op: DirectPluginOperation.Insert; widget: <DirectSlotWidget object> },
56+
{ op: DirectPluginOperation.Hide; widgetId: string },
57+
{ op: DirectPluginOperation.Modify; widgetId: string, fn: (widget: <DirectSlotWidget>) => <DirectSlotWidget> },
58+
{ op: DirectPluginOperation.Wrap; widgetId: string, wrapper: React.FC<{widget: React.ReactElement }> },
59+
]
60+
*/
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
import { DirectPluginContext } from './DirectPlugin';
5+
import organizePlugins from './utils';
6+
7+
const DirectPluginSlot = ({ defaultContents, slotId, renderWidget }) => {
8+
const allPluginChanges = React.useContext(DirectPluginContext);
9+
10+
const preparedWidgets = React.useMemo(() => {
11+
const slotChanges = allPluginChanges.getDirectSlotChanges()[slotId] ?? [];
12+
return organizePlugins(defaultContents, slotChanges);
13+
}, [allPluginChanges, defaultContents, slotId]);
14+
15+
return (
16+
<>
17+
{preparedWidgets.map((preppedWidget) => {
18+
if (preppedWidget.hidden) {
19+
return null;
20+
}
21+
if (preppedWidget.wrappers) {
22+
// TODO: define how the reduce logic is able to wrap widgets and make it testable
23+
// eslint-disable-next-line max-len
24+
return preppedWidget.wrappers.reduce((widget, wrapper) => React.createElement(wrapper, { widget, key: preppedWidget.id }), renderWidget(preppedWidget));
25+
}
26+
return renderWidget(preppedWidget);
27+
})}
28+
</>
29+
);
30+
};
31+
32+
DirectPluginSlot.propTypes = {
33+
defaultContents: PropTypes.shape([]),
34+
slotId: PropTypes.string.isRequired,
35+
renderWidget: PropTypes.func.isRequired,
36+
};
37+
38+
DirectPluginSlot.defaultProps = {
39+
defaultContents: [],
40+
};
41+
42+
export default DirectPluginSlot;

src/plugins/directPlugins/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export {
2+
default as DirectPluginSlot,
3+
} from './DirectPluginSlot';
4+
export {
5+
DirectPluginContext,
6+
DirectPluginOperations,
7+
} from './DirectPlugin';
8+
export {
9+
default as organizePlugins,
10+
} from './utils';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { DirectPluginOperations } from './DirectPlugin';
2+
3+
/**
4+
* Called by DirectPluginSlot to prepare the plugin changes for the given slot
5+
*
6+
* @param {Array} defaultContents - The default widgets where the plugin slot exists.
7+
* @param {Array} slotChanges - All of the changes assigned to the specific plugin slot
8+
* @returns {Array} - A sorted list of widgets with any additional properties needed to render them in the plugin slot
9+
*/
10+
const organizePlugins = (defaultContents, slotChanges) => {
11+
const newContents = [...defaultContents];
12+
13+
slotChanges.forEach(change => {
14+
if (change.op === DirectPluginOperations.Insert) {
15+
newContents.push(change.widget);
16+
} else if (change.op === DirectPluginOperations.Hide) {
17+
const widget = newContents.find((w) => w.id === change.widgetId);
18+
if (widget) { widget.hidden = true; }
19+
} else if (change.op === DirectPluginOperations.Modify) {
20+
const widgetIdx = newContents.findIndex((w) => w.id === change.widgetId);
21+
if (widgetIdx >= 0) {
22+
const widget = { ...newContents[widgetIdx] };
23+
newContents[widgetIdx] = change.fn(widget);
24+
}
25+
} else if (change.op === DirectPluginOperations.Wrap) {
26+
const widgetIdx = newContents.findIndex((w) => w.id === change.widgetId);
27+
if (widgetIdx >= 0) {
28+
const newWidget = { wrappers: [], ...newContents[widgetIdx] };
29+
newWidget.wrappers.push(change.wrapper);
30+
newContents[widgetIdx] = newWidget;
31+
}
32+
} else {
33+
throw new Error('unknown direct plugin change operation');
34+
}
35+
});
36+
37+
newContents.sort((a, b) => (a.priority - b.priority) * 10_000 + a.id.localeCompare(b.id));
38+
return newContents;
39+
};
40+
41+
export default organizePlugins;
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import '@testing-library/jest-dom';
2+
3+
import organizePlugins from './utils';
4+
import { DirectPluginOperations } from './DirectPlugin';
5+
6+
const mockModifyWidget = (widget) => {
7+
const newContent = {
8+
url: '/search',
9+
label: 'Search',
10+
};
11+
const modifiedWidget = widget;
12+
modifiedWidget.content = newContent;
13+
return modifiedWidget;
14+
};
15+
16+
function mockWrapWidget({ widget }) {
17+
const isAdmin = true;
18+
return isAdmin ? widget : null;
19+
}
20+
21+
const mockSlotChanges = [
22+
{
23+
op: DirectPluginOperations.Wrap,
24+
widgetId: 'drafts',
25+
wrapper: mockWrapWidget,
26+
},
27+
{
28+
op: DirectPluginOperations.Hide,
29+
widgetId: 'home',
30+
},
31+
{
32+
op: DirectPluginOperations.Modify,
33+
widgetId: 'lookUp',
34+
fn: mockModifyWidget,
35+
},
36+
{
37+
op: DirectPluginOperations.Insert,
38+
widget: {
39+
id: 'login',
40+
priority: 50,
41+
content: {
42+
url: '/login', label: 'Login',
43+
},
44+
},
45+
},
46+
];
47+
48+
const mockDefaultContent = [
49+
{
50+
id: 'home',
51+
priority: 5,
52+
content: { url: '/', label: 'Home' },
53+
},
54+
{
55+
id: 'lookUp',
56+
priority: 25,
57+
content: { url: '/lookup', label: 'Lookup' },
58+
},
59+
{
60+
id: 'drafts',
61+
priority: 35,
62+
content: { url: '/drafts', label: 'Drafts' },
63+
},
64+
];
65+
66+
describe('organizePlugins', () => {
67+
describe('when there is no defaultContent', () => {
68+
afterEach(() => {
69+
jest.clearAllMocks();
70+
});
71+
72+
it('should return an empty array when there are no changes or additions to slot', () => {
73+
const plugins = organizePlugins([], []);
74+
expect(plugins.length).toBe(0);
75+
expect(plugins).toEqual([]);
76+
});
77+
78+
it('should return an array of changes for non-default plugins', () => {
79+
const plugins = organizePlugins([], mockSlotChanges);
80+
expect(plugins.length).toEqual(1);
81+
expect(plugins[0].id).toEqual('login');
82+
});
83+
});
84+
85+
describe('when there is defaultContent', () => {
86+
afterEach(() => {
87+
jest.clearAllMocks();
88+
});
89+
90+
it('should return an array of defaultContent if no changes for plugins in slot', () => {
91+
const plugins = organizePlugins(mockDefaultContent, []);
92+
expect(plugins.length).toEqual(3);
93+
expect(plugins).toEqual(mockDefaultContent);
94+
});
95+
96+
it('should remove plugins with DirectOperation.Hide', () => {
97+
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
98+
const widget = plugins.find((w) => w.id === 'home');
99+
expect(plugins.length).toEqual(4);
100+
expect(widget.hidden).toBe(true);
101+
});
102+
103+
it('should modify plugins with DirectOperation.Modify', () => {
104+
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
105+
const widget = plugins.find((w) => w.id === 'lookUp');
106+
107+
expect(plugins.length).toEqual(4);
108+
expect(widget.content.url).toEqual('/search');
109+
});
110+
111+
it('should wrap plugins with DirectOperation.Wrap', () => {
112+
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
113+
const widget = plugins.find((w) => w.id === 'drafts');
114+
expect(plugins.length).toEqual(4);
115+
expect(widget.wrappers.length).toEqual(1);
116+
});
117+
118+
it('should accept several wrappers for a single plugin with DirectOperation.Wrap', () => {
119+
const newMockWrapComponent = ({ widget }) => {
120+
const isStudent = false;
121+
return isStudent ? null : widget;
122+
};
123+
const newPluginChange = {
124+
op: DirectPluginOperations.Wrap,
125+
widgetId: 'drafts',
126+
wrapper: newMockWrapComponent,
127+
};
128+
mockSlotChanges.push(newPluginChange);
129+
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
130+
const widget = plugins.find((w) => w.id === 'drafts');
131+
expect(plugins.length).toEqual(4);
132+
expect(widget.wrappers.length).toEqual(2);
133+
expect(widget.wrappers[0]).toEqual(mockWrapWidget);
134+
expect(widget.wrappers[1]).toEqual(newMockWrapComponent);
135+
});
136+
137+
it('should return plugins arranged by priority', () => {
138+
const newPluginChange = {
139+
op: DirectPluginOperations.Insert,
140+
widget: {
141+
id: 'profile',
142+
priority: 1,
143+
content: {
144+
url: '/profile', label: 'Profile',
145+
},
146+
},
147+
};
148+
mockSlotChanges.push(newPluginChange);
149+
const plugins = organizePlugins(mockDefaultContent, mockSlotChanges);
150+
expect(plugins.length).toEqual(5);
151+
expect(plugins[0].id).toBe('profile');
152+
expect(plugins[1].id).toBe('home');
153+
expect(plugins[2].id).toBe('lookUp');
154+
expect(plugins[3].id).toBe('drafts');
155+
expect(plugins[4].id).toBe('login');
156+
});
157+
158+
it('should raise an error for an operation that does not exist', async () => {
159+
const badPluginChange = {
160+
op: DirectPluginOperations.Destroy,
161+
widgetId: 'drafts',
162+
};
163+
mockSlotChanges.push(badPluginChange);
164+
165+
expect.assertions(1);
166+
try {
167+
await organizePlugins(mockDefaultContent, mockSlotChanges);
168+
} catch (error) {
169+
expect(error.message).toBe('unknown direct plugin change operation');
170+
}
171+
});
172+
});
173+
});

0 commit comments

Comments
 (0)