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: slot component for dynamic plugins #184

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2756e3d
feat: slot component for dynamic plugins
johnvente Dec 13, 2023
e9f29a6
temp: using the component wrapper with the plugins
johnvente Dec 13, 2023
b59d783
fix: conflicts
johnvente Dec 13, 2023
5fcc77f
fix: conflicts
johnvente Dec 13, 2023
c548d45
fix: linter problems
johnvente Dec 13, 2023
d6b09c5
fix: unit test
johnvente Dec 13, 2023
7fee38c
fix: children and props wrapper dependecies
johnvente Dec 16, 2023
55468d6
fix: unit test for pluggable component
johnvente Dec 16, 2023
cd64f47
test: update tests for pluggable component
johnvente Dec 17, 2023
dd365d5
feat: build form email full pluggable
johnvente Dec 20, 2023
5c16343
test: update test for pluggable component
johnvente Dec 22, 2023
52f6dad
feat: context factory util and build email form extensible with context
johnvente Dec 22, 2023
ee2dffa
refactor: update plugins with context data
johnvente Dec 22, 2023
adbf942
test: reducer test for build email form extensible
johnvente Dec 22, 2023
13003f1
refactor: remove unnecessary comments
johnvente Dec 26, 2023
ed8a010
refactor: addressing some some improvements
johnvente Dec 27, 2023
0d94489
feat: adding course id for plugins
johnvente Dec 29, 2023
7e64b99
fix: solve conflicts
johnvente Jan 30, 2024
6b1e8ec
fix: dependencies problems
johnvente Jan 30, 2024
4d7a58d
feat: allow multiple plugins for pluggable component
johnvente Feb 2, 2024
5f013fc
refactor: change plugins hook to a component
johnvente Feb 5, 2024
fc0b1bf
docs: ui slot external config
johnvente Feb 7, 2024
f2716f0
fix: solve conflicts
johnvente Mar 7, 2024
ffa1797
refactor: removing unnecessary bulk email form and changing paragon d…
johnvente Mar 7, 2024
45cedc9
refactor: removing unnecessary code
johnvente Mar 8, 2024
39b9941
fix: check box form test
johnvente Mar 8, 2024
fccfd57
refactor: changing BuildEmailFormExtensible to BulkEmailForm
johnvente Mar 20, 2024
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
21 changes: 20 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
const path = require('path');
/* eslint-disable import/no-extraneous-dependencies */

const { createConfig } = require('@edx/frontend-build');

module.exports = createConfig('eslint', {
settings: {
'import/resolver': {
webpack: {
config: [
path.resolve(__dirname, 'webpack.dev.config.js'),
path.resolve(__dirname, 'webpack.prod.config.js'),
],
},
},
},
rules: {
'react/function-component-definition': 'off',
'import/no-extraneous-dependencies': [
'error', {
devDependencies: false,
optionalDependencies: false,
peerDependencies: false,
packageDir: __dirname,
},
],

},
});
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ module.exports = createConfig('jest', {
'src/setupTest.js',
'src/i18n',
],
moduleNameMapper: {
'@node_modules/(.*)': '<rootDir>/node_modules/$1'
},
});
10,521 changes: 5,073 additions & 5,448 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@loadable/component": "^5.15.3",
"@openedx-plugins/communications-app-check-box-form": "file:plugins/communications-app/CheckBoxForm",
"@openedx-plugins/communications-app-input-form": "file:plugins/communications-app/InputForm",
"@tinymce/tinymce-react": "3.14.0",
"axios": "0.27.2",
"classnames": "2.3.2",
Expand All @@ -69,6 +72,7 @@
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "12.1.5",
"axios-mock-adapter": "1.21.2",
"eslint-import-resolver-webpack": "^0.13.8",
"glob": "7.2.3",
"husky": "7.0.4",
"jest": "27.5.1",
Expand Down
18 changes: 18 additions & 0 deletions plugins/communications-app/CheckBoxForm/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import { Form, Container } from '@edx/paragon';

const CheckBoxForm = ({ isChecked, handleChange, label }) => (
<Container className="my-4 border border-success-300 p-4">
<Form.Checkbox checked={isChecked} onChange={handleChange}>
{label}
</Form.Checkbox>
</Container>
);

CheckBoxForm.propTypes = {
isChecked: PropTypes.bool.isRequired,
handleChange: PropTypes.func.isRequired,
label: PropTypes.string.isRequired,
};

export default CheckBoxForm;
20 changes: 20 additions & 0 deletions plugins/communications-app/CheckBoxForm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/communications-app-check-box-form",
"version": "1.0.0",
"description": "edx input type checkbox form to use it in this mfe",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"peerDependencies": {
"@edx/frontend-app-communications": "*",
"@edx/frontend-platform": "*",
"@edx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-communications": {
"optional": true
}
}
}
29 changes: 29 additions & 0 deletions plugins/communications-app/InputForm/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import PropTypes from 'prop-types';
import { Form, Container } from '@edx/paragon';

const InputForm = ({
isValid, controlId, label, feedbackText,
}) => {
const feedbackType = isValid ? 'valid' : 'invalid';
return (
<Form.Group isValid={isValid} controlId={controlId} data-testid="plugin-input" className="p-3 border border-success-300">
<Form.Label className="h3 text-primary-500">{label}</Form.Label>
<Container className="row">
<Form.Control className="col-3" />
<p className="col-8">@openedx-plugins/communications-app-input-form</p>
</Container>
<Form.Control.Feedback type={feedbackType}>
{feedbackText}
</Form.Control.Feedback>
</Form.Group>
);
};

InputForm.propTypes = {
isValid: PropTypes.bool.isRequired,
controlId: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
feedbackText: PropTypes.string.isRequired,
};

export default InputForm;
20 changes: 20 additions & 0 deletions plugins/communications-app/InputForm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@openedx-plugins/communications-app-input-form",
"version": "1.0.0",
"description": "edx input form to use it in this mfe",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"peerDependencies": {
"@edx/frontend-app-communications": "*",
"@edx/frontend-platform": "*",
"@edx/paragon": "*",
"prop-types": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@edx/frontend-app-communications": {
"optional": true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`PluggableComponent renders correctly 1`] = `
<div>
<div
class="pgn__form-group"
data-testid="plugin-input"
>
<label
class="pgn__form-label"
for="randomID"
>
Hello
</label>
<div
class="pgn__form-control-decorator-group"
>
<input
class="form-control is-valid"
id="randomID"
/>
</div>
<div
class="pgn__form-control-description pgn__form-text pgn__form-text-valid"
>
<span
class="pgn__icon"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2Z"
fill="currentColor"
/>
</svg>
</span>
<div>
You are correct
</div>
</div>
</div>
</div>
`;
61 changes: 61 additions & 0 deletions src/components/PluggableComponent/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect } from 'react';
import loadable from '@loadable/component';
import PropTypes from 'prop-types';

import { isPluginAvailable } from './utils';

/**
* PluggableComponent - A component that allows dynamic loading and replacement of child components.
*
* @param {object} props - Component props
* @param {React.ReactNode} props.children - Child elements to be passed to the plugin component
* @param {string} props.as - String indicating the module to import dynamically
* @param {string} props.id - Identifier for the plugin
* @param {object} props.pluggableComponentProps - Additional props to be passed to the component
* @returns {React.ReactNode} - Rendered component
*/
const PluggableComponent = ({
children,
as,
id,
...pluggableComponentProps
}) => {
const [newComponent, setNewComponent] = useState(children);

useEffect(() => {
const loadPluginComponent = async () => {
try {
const hasModuleInstalled = await isPluginAvailable(as);

if (hasModuleInstalled) {
const PluginComponent = loadable(() => import(`@node_modules/@openedx-plugins/${as}`));

const component = children ? (
<PluginComponent key={id} {...pluggableComponentProps}>
{children}
Copy link
Contributor

Choose a reason for hiding this comment

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

I like that the children are passed to the plugin, so it can optionally wrap the default contents 👍🏻

</PluginComponent>
) : (
<PluginComponent key={id} {...pluggableComponentProps} />
);

setNewComponent(component);
}
} catch (error) {
console.error(`Failed to load plugin ${as}:`, error);
}
};

loadPluginComponent();
}, [id, as, children]);

return newComponent;
};

PluggableComponent.propTypes = {
children: PropTypes.node,
as: PropTypes.string,
id: PropTypes.string,
};

export default PluggableComponent;
62 changes: 62 additions & 0 deletions src/components/PluggableComponent/index.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import PluggableComponent from '.';

describe('PluggableComponent', () => {
it('renders correctly', async () => {
const props = {
isValid: true,
controlId: 'randomID',
label: 'Hello',
feedbackText: 'You are correct',
};
const { container } = render(
<PluggableComponent
id="pluggableComponent"
as="communications-app-input-form"
{...props}
>
<h1>Hi this is the original component</h1>
</PluggableComponent>,
);

await waitFor(() => {
const inputForm = screen.getByTestId('plugin-input');
expect(inputForm).toBeInTheDocument();
expect(screen.getByText(props.label)).toBeInTheDocument();
expect(screen.getByText(props.feedbackText)).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
});

it('loads children component when import is invalid', async () => {
render(
<PluggableComponent id="est-pluggable" as="invalid import">
<div data-testid="plugin">Plugin Loaded</div>
</PluggableComponent>,
);

await waitFor(() => {
const defaultComponent = screen.getByTestId('plugin');
expect(screen.getByText('Plugin Loaded')).toBeInTheDocument();
expect(defaultComponent).toBeInTheDocument();
});
});

it('loads children component when import object', async () => {
render(
<PluggableComponent
id="test-pluggable"
as=""
>
<div data-testid="plugin">Plugin Loaded</div>
</PluggableComponent>,
);

await waitFor(() => {
const defaultComponent = screen.getByTestId('plugin');
expect(screen.getByText('Plugin Loaded')).toBeInTheDocument();
expect(defaultComponent).toBeInTheDocument();
});
});
});
10 changes: 10 additions & 0 deletions src/components/PluggableComponent/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const isPluginAvailable = async (pluginName) => {
if (!pluginName) { return false; }

try {
await import(`@node_modules/@openedx-plugins/${pluginName}`);
return true;
} catch (error) {
return false;
}
};
23 changes: 23 additions & 0 deletions src/components/PluggableComponent/utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { isPluginAvailable } from './utils';

describe('isPluginAvailable util', () => {
it('returns true if a plugin is installed', async () => {
const checkBoxPlugin = await isPluginAvailable('communications-app-check-box-form');
expect(checkBoxPlugin).toBe(true);
});

it('returns false if a plugin is not installed', async () => {
const nonexistentPlugin = await isPluginAvailable('nonexistentPlugin');
expect(nonexistentPlugin).toBe(false);
});

it('returns false if an empty string is provided as plugin name', async () => {
const emptyPlugin = await isPluginAvailable('');
expect(emptyPlugin).toBe(false);
});

it('returns false if null is provided as plugin name', async () => {
const nullPLugin = await isPluginAvailable(null);
expect(nullPLugin).toBe(false);
});
});
Loading