diff --git a/frontend/package.json b/frontend/package.json
index d6aee66ff8..53ba67e157 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -63,7 +63,7 @@
"eslint-config-finn-prettier": "^3.0.2",
"eslint-config-finn-react": "^2.0.2",
"eslint-plugin-react": "^7.11.1",
- "fetch-mock": "^9.4.0",
+ "fetch-mock": "^9.11.0",
"identity-obj-proxy": "^3.0.0",
"immutable": "^3.8.1",
"jest": "^24.9.0",
@@ -112,6 +112,7 @@
],
"snapshotSerializers": [
"enzyme-to-json/serializer"
- ]
+ ],
+ "testPathIgnorePatterns": ["/src/store/addons/__tests__/data.js"]
}
}
diff --git a/frontend/public/jira.svg b/frontend/public/jira.svg
new file mode 100644
index 0000000000..4ace5cc84a
--- /dev/null
+++ b/frontend/public/jira.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/slack.svg b/frontend/public/slack.svg
new file mode 100644
index 0000000000..69a4eb6a21
--- /dev/null
+++ b/frontend/public/slack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/webhooks.svg b/frontend/public/webhooks.svg
new file mode 100644
index 0000000000..ec5cddf369
--- /dev/null
+++ b/frontend/public/webhooks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/component/addons/form-addon-component.jsx b/frontend/src/component/addons/form-addon-component.jsx
new file mode 100644
index 0000000000..4570160cdb
--- /dev/null
+++ b/frontend/src/component/addons/form-addon-component.jsx
@@ -0,0 +1,177 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { Textfield, Card, CardTitle, CardText, CardActions, Switch, Grid, Cell } from 'react-mdl';
+
+import { FormButtons, styles as commonStyles } from '../common';
+import { trim } from '../common/util';
+import AddonParameters from './form-addon-parameters';
+import AddonEvents from './form-addon-events';
+import { cloneDeep } from 'lodash';
+
+const AddonFormComponent = ({ editMode, provider, addon, fetch, cancel, submit }) => {
+ const [config, setConfig] = useState(addon);
+ const [errors, setErrors] = useState({
+ parameters: {},
+ });
+ const submitText = editMode ? 'Update' : 'Create';
+
+ useEffect(() => {
+ if (!provider) {
+ fetch();
+ }
+ }, []); // empty array => fetch only first time
+
+ useEffect(() => {
+ setConfig({ ...addon });
+ }, [addon.id]);
+
+ useEffect(() => {
+ if (provider && !config.provider) {
+ setConfig({ ...addon, provider: provider.name });
+ }
+ }, [provider]);
+
+ const setFieldValue = field => evt => {
+ evt.preventDefault();
+ const newConfig = { ...config };
+ newConfig[field] = evt.target.value;
+ setConfig(newConfig);
+ };
+
+ const onEnabled = evt => {
+ evt.preventDefault();
+ const enabled = !config.enabled;
+ setConfig({ ...config, enabled });
+ };
+
+ const setParameterValue = param => evt => {
+ evt.preventDefault();
+ const newConfig = { ...config };
+ newConfig.parameters[param] = evt.target.value;
+ setConfig(newConfig);
+ };
+
+ const setEventValue = name => evt => {
+ const newConfig = { ...config };
+ if (evt.target.checked) {
+ newConfig.events.push(name);
+ } else {
+ newConfig.events = newConfig.events.filter(e => e !== name);
+ }
+ setConfig(newConfig);
+ setErrors({ ...errors, events: undefined });
+ };
+
+ const onSubmit = async evt => {
+ evt.preventDefault();
+ if (!provider) return;
+
+ const updatedErrors = cloneDeep(errors);
+ updatedErrors.containsErrors = false;
+
+ // Validations
+ if (config.events.length === 0) {
+ updatedErrors.events = 'You must listen to at least one event';
+ updatedErrors.containsErrors = true;
+ }
+
+ provider.parameters.forEach(p => {
+ const value = trim(config.parameters[p.name]);
+ if (p.required && !value) {
+ updatedErrors.parameters[p.name] = 'This field is required';
+ updatedErrors.containsErrors = true;
+ }
+ });
+
+ if (updatedErrors.containsErrors) {
+ setErrors(updatedErrors);
+ return;
+ }
+
+ try {
+ await submit(config);
+ } catch (e) {
+ setErrors({ parameters: {}, general: e.message });
+ }
+ };
+
+ const { name, description, documentationUrl = 'https://unleash.github.io/docs/addons' } = provider ? provider : {};
+
+ return (
+
+
+ Configure {name}
+
+
+ {description}
+
+ Read more
+
+ {errors.general}
+
+
+
+ );
+};
+
+AddonFormComponent.propTypes = {
+ provider: PropTypes.object,
+ addon: PropTypes.object.isRequired,
+ fetch: PropTypes.func.isRequired,
+ submit: PropTypes.func.isRequired,
+ cancel: PropTypes.func.isRequired,
+ editMode: PropTypes.bool.isRequired,
+};
+
+export default AddonFormComponent;
diff --git a/frontend/src/component/addons/form-addon-container.js b/frontend/src/component/addons/form-addon-container.js
new file mode 100644
index 0000000000..01f2e456d5
--- /dev/null
+++ b/frontend/src/component/addons/form-addon-container.js
@@ -0,0 +1,55 @@
+import { connect } from 'react-redux';
+import FormComponent from './form-addon-component';
+import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions';
+import { cloneDeep } from 'lodash';
+
+// Required for to fill the inital form.
+const DEFAULT_DATA = {
+ provider: '',
+ description: '',
+ enabled: true,
+ parameters: {},
+ events: [],
+};
+
+const mapStateToProps = (state, params) => {
+ const defaultAddon = cloneDeep(DEFAULT_DATA);
+ const editMode = !!params.addonId;
+ const addons = state.addons.get('addons').toJS();
+ const providers = state.addons.get('providers').toJS();
+
+ let addon;
+ let provider;
+
+ if (editMode) {
+ addon = addons.find(addon => addon.id === +params.addonId) || defaultAddon;
+ provider = addon ? providers.find(provider => provider.name === addon.provider) : undefined;
+ } else {
+ provider = providers.find(provider => provider.name === params.provider);
+ addon = { ...defaultAddon, provider: provider ? provider.name : '' };
+ }
+
+ return {
+ provider,
+ addon,
+ editMode,
+ };
+};
+
+const mapDispatchToProps = (dispatch, ownProps) => {
+ const { addonId, history } = ownProps;
+ const submit = addonId ? updateAddon : createAddon;
+
+ return {
+ submit: async addonConfig => {
+ await submit(addonConfig)(dispatch);
+ history.push('/addons');
+ },
+ fetch: () => fetchAddons()(dispatch),
+ cancel: () => history.push('/addons'),
+ };
+};
+
+const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(FormComponent);
+
+export default FormAddContainer;
diff --git a/frontend/src/component/addons/form-addon-events.jsx b/frontend/src/component/addons/form-addon-events.jsx
new file mode 100644
index 0000000000..f4790432d1
--- /dev/null
+++ b/frontend/src/component/addons/form-addon-events.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Checkbox, Grid, Cell } from 'react-mdl';
+
+import { styles as commonStyles } from '../common';
+
+const AddonEvents = ({ provider, checkedEvents, setEventValue, error }) => {
+ if (!provider) return null;
+
+ return (
+
+ Events
+ {error}
+
+ {provider.events.map(e => (
+
+
+ |
+ ))}
+
+
+ );
+};
+
+AddonEvents.propTypes = {
+ provider: PropTypes.object,
+ checkedEvents: PropTypes.array.isRequired,
+ setEventValue: PropTypes.func.isRequired,
+ error: PropTypes.string,
+};
+
+export default AddonEvents;
diff --git a/frontend/src/component/addons/form-addon-parameters.jsx b/frontend/src/component/addons/form-addon-parameters.jsx
new file mode 100644
index 0000000000..8f6065126c
--- /dev/null
+++ b/frontend/src/component/addons/form-addon-parameters.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Textfield } from 'react-mdl';
+
+const MASKED_VALUE = '*****';
+
+const resolveType = ({ type = 'text', sensitive = false }, value) => {
+ if (sensitive && value === MASKED_VALUE) {
+ return 'text';
+ }
+ if (type === 'textfield') {
+ return 'text';
+ }
+ return type;
+};
+
+const AddonParameter = ({ definition, config, errors, setParameterValue }) => {
+ const value = config.parameters[definition.name] || '';
+ const type = resolveType(definition, value);
+ const error = errors.parameters[definition.name];
+ const descStyle = { fontSize: '0.8em', color: 'gray', marginTop: error ? '2px' : '-15px' };
+
+ return (
+
+
+
{definition.description}
+
+ );
+};
+
+AddonParameter.propTypes = {
+ definition: PropTypes.object.isRequired,
+ config: PropTypes.object.isRequired,
+ errors: PropTypes.object.isRequired,
+ setParameterValue: PropTypes.func.isRequired,
+};
+
+const AddonParameters = ({ provider, config, errors, setParameterValue, editMode }) => {
+ if (!provider) return null;
+
+ return (
+
+ Parameters
+ {editMode ? (
+
+ Sensitive parameters will be masked with value "*****". If you don't change the value they
+ will not be updated when saving.
+
+ ) : null}
+ {provider.parameters.map(p => (
+
+ ))}
+
+ );
+};
+
+AddonParameters.propTypes = {
+ provider: PropTypes.object,
+ config: PropTypes.object.isRequired,
+ errors: PropTypes.object.isRequired,
+ setParameterValue: PropTypes.func.isRequired,
+ editMode: PropTypes.bool.optional,
+};
+
+export default AddonParameters;
diff --git a/frontend/src/component/addons/list-component.jsx b/frontend/src/component/addons/list-component.jsx
new file mode 100644
index 0000000000..eb7c23c79c
--- /dev/null
+++ b/frontend/src/component/addons/list-component.jsx
@@ -0,0 +1,117 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+
+import { List, ListItem, ListItemAction, IconButton, Card, Button } from 'react-mdl';
+import { HeaderTitle, styles as commonStyles } from '../common';
+import { CREATE_ADDON, DELETE_ADDON, UPDATE_ADDON } from '../../permissions';
+
+const style = { width: '40px', height: '40px', marginRight: '16px', float: 'left' };
+
+const getIcon = name => {
+ switch (name) {
+ case 'slack':
+ return ;
+ case 'jira-comment':
+ return ;
+ case 'webhook':
+ return ;
+ default:
+ return device_hub;
+ }
+};
+
+const AddonListComponent = ({ addons, providers, fetchAddons, removeAddon, toggleAddon, history, hasPermission }) => {
+ useEffect(() => {
+ if (addons.length === 0) {
+ fetchAddons();
+ }
+ }, []);
+
+ const onRemoveAddon = addon => () => removeAddon(addon);
+
+ return (
+
+ {addons.length > 0 ? (
+
+
+
+ {addons.map(addon => (
+
+
+ {getIcon(addon.provider)}
+
+ {hasPermission(UPDATE_ADDON) ? (
+
+ {addon.provider}
+
+ ) : (
+ {addon.provider}
+ )}
+ {addon.enabled ? null : (Disabled)}
+
+ {addon.description}
+
+
+ {hasPermission(UPDATE_ADDON) ? (
+ toggleAddon(addon)}
+ />
+ ) : null}
+ {hasPermission(DELETE_ADDON) ? (
+
+ ) : null}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+ {providers.map((provider, i) => (
+
+
+ {getIcon(provider.name)}
+
+ {provider.displayName}
+
+ {provider.description}
+
+
+ {hasPermission(CREATE_ADDON) ? (
+
+ ) : (
+ ''
+ )}
+
+
+ ))}
+
+
+
+ );
+};
+AddonListComponent.propTypes = {
+ addons: PropTypes.array.isRequired,
+ providers: PropTypes.array.isRequired,
+ fetchAddons: PropTypes.func.isRequired,
+ removeAddon: PropTypes.func.isRequired,
+ toggleAddon: PropTypes.func.isRequired,
+ history: PropTypes.object.isRequired,
+ hasPermission: PropTypes.func.isRequired,
+};
+
+export default AddonListComponent;
diff --git a/frontend/src/component/addons/list-container.jsx b/frontend/src/component/addons/list-container.jsx
new file mode 100644
index 0000000000..ed83e458e6
--- /dev/null
+++ b/frontend/src/component/addons/list-container.jsx
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import AddonsListComponent from './list-component.jsx';
+import { fetchAddons, removeAddon, updateAddon } from '../../store/addons/actions';
+import { hasPermission } from '../../permissions';
+
+const mapStateToProps = state => {
+ const list = state.addons.toJS();
+
+ return {
+ addons: list.addons,
+ providers: list.providers,
+ hasPermission: hasPermission.bind(null, state.user.get('profile')),
+ };
+};
+
+const mapDispatchToProps = dispatch => ({
+ removeAddon: addon => {
+ // eslint-disable-next-line no-alert
+ if (window.confirm('Are you sure you want to remove this addon?')) {
+ removeAddon(addon)(dispatch);
+ }
+ },
+ fetchAddons: () => fetchAddons()(dispatch),
+ toggleAddon: addon => {
+ const updatedAddon = { ...addon, enabled: !addon.enabled };
+ return updateAddon(updatedAddon)(dispatch);
+ },
+});
+
+const AddonsListContainer = connect(mapStateToProps, mapDispatchToProps)(AddonsListComponent);
+
+export default AddonsListContainer;
diff --git a/frontend/src/component/common/common.module.scss b/frontend/src/component/common/common.module.scss
index 6b55a7665d..36b0466ca5 100644
--- a/frontend/src/component/common/common.module.scss
+++ b/frontend/src/component/common/common.module.scss
@@ -98,4 +98,8 @@
top: 56px;
right: 24px;
z-index: 2;
+}
+
+.error {
+ color: #d50000;
}
\ No newline at end of file
diff --git a/frontend/src/component/feature/feature-tag-component.jsx b/frontend/src/component/feature/feature-tag-component.jsx
index 0def7c591d..c8308d597e 100644
--- a/frontend/src/component/feature/feature-tag-component.jsx
+++ b/frontend/src/component/feature/feature-tag-component.jsx
@@ -11,8 +11,19 @@ function FeatureTagComponent({ tags, tagTypes, featureToggleName, untagFeature }
const tagIcon = typeName => {
let tagType = tagTypes.find(type => type.name === typeName);
+ const style = { width: '20px', height: '20px', margin: '0' };
+
if (tagType && tagType.icon) {
- return ;
+ switch (tagType.name) {
+ case 'slack':
+ return ;
+ case 'jira':
+ return ;
+ case 'webhook':
+ return ;
+ default:
+ return ;
+ }
} else {
return {typeName[0].toUpperCase()};
}
@@ -21,12 +32,12 @@ function FeatureTagComponent({ tags, tagTypes, featureToggleName, untagFeature }
const renderTag = t => (
onUntagFeature(t)}
- title={t.value}
+ title={`Type: ${t.type} \nValue: ${t.value}`}
key={`${t.type}:${t.value}`}
style={{ marginRight: '3px', fontSize: '0.8em' }}
>
- {tagIcon(t.type)}
- {t.value}
+ {tagIcon(t.type)}
+ {t.value}
);
diff --git a/frontend/src/component/history/history-list-component.jsx b/frontend/src/component/history/history-list-component.jsx
index 6fa784b703..629e0dce68 100644
--- a/frontend/src/component/history/history-list-component.jsx
+++ b/frontend/src/component/history/history-list-component.jsx
@@ -8,6 +8,19 @@ import { formatFullDateTimeWithLocale } from '../common/util';
import styles from './history.module.scss';
+const getName = name => {
+ if (name) {
+ return (
+
+ Name:
+ {name}
+
+ );
+ } else {
+ return null;
+ }
+};
+
const HistoryMeta = ({ entry, timeFormatted }) => (
@@ -17,8 +30,7 @@ const HistoryMeta = ({ entry, timeFormatted }) => (
- {entry.createdBy}
- Type:
- {entry.type}
- - Name:
- - {entry.data.name}
+ {getName(entry.data.name)}
Change
diff --git a/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap
index 04da1fdcd8..d226f9dee2 100644
--- a/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap
+++ b/frontend/src/component/menu/__tests__/__snapshots__/drawer-test.jsx.snap
@@ -106,6 +106,19 @@ exports[`should render DrawerMenu 1`] = `
Tag types
+
+
+
+ Addons
+
+
+
+
+ Addons
+
Tag types
+
+ Addons
+
Tag types
+
+ Addons
+
{
- expect(routes.length).toEqual(25);
+ expect(routes.length).toEqual(28);
expect(routes).toMatchSnapshot();
});
test('returns all baseRoutes', () => {
- expect(baseRoutes.length).toEqual(7);
+ expect(baseRoutes.length).toEqual(8);
expect(baseRoutes).toMatchSnapshot();
});
diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js
index 35330f48d2..e494ecaa0e 100644
--- a/frontend/src/component/menu/routes.js
+++ b/frontend/src/component/menu/routes.js
@@ -23,7 +23,9 @@ import CreateTagType from '../../page/tag-types/create';
import EditTagType from '../../page/tag-types/edit';
import ListTags from '../../page/tags';
import CreateTag from '../../page/tags/create';
-
+import Addons from '../../page/addons';
+import AddonsCreate from '../../page/addons/create';
+import AddonsEdit from '../../page/addons/edit';
export const routes = [
// Features
{ path: '/features/create', parent: '/features', title: 'Create', component: CreateFeatureToggle },
@@ -70,6 +72,11 @@ export const routes = [
{ path: '/tags/create', parent: '/tags', title: 'Create', component: CreateTag },
{ path: '/tags', title: 'Tags', icon: 'label', component: ListTags, hidden: true },
+ // Addons
+ { path: '/addons/create/:provider', parent: '/addons', title: 'Create', component: AddonsCreate },
+ { path: '/addons/edit/:id', parent: '/addons', title: 'Edit', component: AddonsEdit },
+ { path: '/addons', title: 'Addons', icon: 'device_hub', component: Addons, hidden: false },
+
{ path: '/logout', title: 'Sign out', icon: 'exit_to_app', component: LogoutFeatures },
];
diff --git a/frontend/src/page/addons/create.js b/frontend/src/page/addons/create.js
new file mode 100644
index 0000000000..c9894d3484
--- /dev/null
+++ b/frontend/src/page/addons/create.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import AddonForm from '../../component/addons/form-addon-container';
+import PropTypes from 'prop-types';
+
+const render = ({ match: { params }, history }) => (
+
+);
+
+render.propTypes = {
+ match: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired,
+};
+
+export default render;
diff --git a/frontend/src/page/addons/edit.js b/frontend/src/page/addons/edit.js
new file mode 100644
index 0000000000..ffe64e5387
--- /dev/null
+++ b/frontend/src/page/addons/edit.js
@@ -0,0 +1,14 @@
+import React from 'react';
+import AddonForm from '../../component/addons/form-addon-container';
+import PropTypes from 'prop-types';
+
+const render = ({ match: { params }, history }) => (
+
+);
+
+render.propTypes = {
+ match: PropTypes.object.isRequired,
+ history: PropTypes.object.isRequired,
+};
+
+export default render;
diff --git a/frontend/src/page/addons/index.js b/frontend/src/page/addons/index.js
new file mode 100644
index 0000000000..1b154129d8
--- /dev/null
+++ b/frontend/src/page/addons/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Addons from '../../component/addons/list-container';
+import PropTypes from 'prop-types';
+
+const render = ({ history }) => ;
+
+render.propTypes = {
+ history: PropTypes.object.isRequired,
+};
+
+export default render;
diff --git a/frontend/src/permissions.js b/frontend/src/permissions.js
index 00b5e1f5e1..ac6edb8b0a 100644
--- a/frontend/src/permissions.js
+++ b/frontend/src/permissions.js
@@ -17,6 +17,9 @@ export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
export const CREATE_TAG = 'CREATE_TAG';
export const DELETE_TAG = 'DELETE_TAG';
+export const CREATE_ADDON = 'CREATE_ADDON';
+export const UPDATE_ADDON = 'UPDATE_ADDON';
+export const DELETE_ADDON = 'DELETE_ADDON';
export function hasPermission(user, permission) {
return (
diff --git a/frontend/src/store/addons/__tests__/__snapshots__/addons-store.test.js.snap b/frontend/src/store/addons/__tests__/__snapshots__/addons-store.test.js.snap
new file mode 100644
index 0000000000..a417521d15
--- /dev/null
+++ b/frontend/src/store/addons/__tests__/__snapshots__/addons-store.test.js.snap
@@ -0,0 +1,189 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should add addon-config 1`] = `
+Object {
+ "addons": Array [
+ Object {
+ "description": null,
+ "enabled": true,
+ "events": Array [
+ "feature-updated",
+ "feature-created",
+ ],
+ "id": 1,
+ "parameters": Object {
+ "bodyTemplate": "{'name': '{{event.data.name}}' }",
+ "url": "http://localhost:4242/webhook",
+ },
+ "provider": "webhook",
+ },
+ ],
+ "providers": Array [
+ Object {
+ "displayName": "Webhook",
+ "events": Array [
+ "feature-created",
+ "feature-updated",
+ "feature-archived",
+ "feature-revived",
+ ],
+ "name": "webhook",
+ "parameters": Array [
+ Object {
+ "displayName": "Webhook URL",
+ "name": "url",
+ "type": "string",
+ },
+ Object {
+ "displayName": "Unleash Admin UI url",
+ "name": "unleashUrl",
+ "type": "text",
+ },
+ Object {
+ "description": "You may format the body using a mustache template.",
+ "displayName": "Body template",
+ "name": "bodyTemplate",
+ "type": "text",
+ },
+ ],
+ },
+ ],
+}
+`;
+
+exports[`should be default state 1`] = `
+Object {
+ "addons": Array [],
+ "providers": Array [],
+}
+`;
+
+exports[`should be merged state all 1`] = `
+Object {
+ "addons": Array [],
+ "providers": Array [
+ Object {
+ "displayName": "Webhook",
+ "events": Array [
+ "feature-created",
+ "feature-updated",
+ "feature-archived",
+ "feature-revived",
+ ],
+ "name": "webhook",
+ "parameters": Array [
+ Object {
+ "displayName": "Webhook URL",
+ "name": "url",
+ "type": "string",
+ },
+ Object {
+ "displayName": "Unleash Admin UI url",
+ "name": "unleashUrl",
+ "type": "text",
+ },
+ Object {
+ "description": "You may format the body using a mustache template.",
+ "displayName": "Body template",
+ "name": "bodyTemplate",
+ "type": "text",
+ },
+ ],
+ },
+ ],
+}
+`;
+
+exports[`should clear addon-config on logout 1`] = `
+Object {
+ "addons": Array [],
+ "providers": Array [],
+}
+`;
+
+exports[`should remove addon-config 1`] = `
+Object {
+ "addons": Array [],
+ "providers": Array [
+ Object {
+ "displayName": "Webhook",
+ "events": Array [
+ "feature-created",
+ "feature-updated",
+ "feature-archived",
+ "feature-revived",
+ ],
+ "name": "webhook",
+ "parameters": Array [
+ Object {
+ "displayName": "Webhook URL",
+ "name": "url",
+ "type": "string",
+ },
+ Object {
+ "displayName": "Unleash Admin UI url",
+ "name": "unleashUrl",
+ "type": "text",
+ },
+ Object {
+ "description": "You may format the body using a mustache template.",
+ "displayName": "Body template",
+ "name": "bodyTemplate",
+ "type": "text",
+ },
+ ],
+ },
+ ],
+}
+`;
+
+exports[`should update addon-config 1`] = `
+Object {
+ "addons": Array [
+ Object {
+ "description": "new desc",
+ "enabled": false,
+ "events": Array [
+ "feature-updated",
+ "feature-created",
+ ],
+ "id": 1,
+ "parameters": Object {
+ "bodyTemplate": "{'name': '{{event.data.name}}' }",
+ "url": "http://localhost:4242/webhook",
+ },
+ "provider": "webhook",
+ },
+ ],
+ "providers": Array [
+ Object {
+ "displayName": "Webhook",
+ "events": Array [
+ "feature-created",
+ "feature-updated",
+ "feature-archived",
+ "feature-revived",
+ ],
+ "name": "webhook",
+ "parameters": Array [
+ Object {
+ "displayName": "Webhook URL",
+ "name": "url",
+ "type": "string",
+ },
+ Object {
+ "displayName": "Unleash Admin UI url",
+ "name": "unleashUrl",
+ "type": "text",
+ },
+ Object {
+ "description": "You may format the body using a mustache template.",
+ "displayName": "Body template",
+ "name": "bodyTemplate",
+ "type": "text",
+ },
+ ],
+ },
+ ],
+}
+`;
diff --git a/frontend/src/store/addons/__tests__/addons-actions.test.js b/frontend/src/store/addons/__tests__/addons-actions.test.js
new file mode 100644
index 0000000000..024e241f7c
--- /dev/null
+++ b/frontend/src/store/addons/__tests__/addons-actions.test.js
@@ -0,0 +1,113 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import fetchMock from 'fetch-mock';
+
+import {
+ RECEIVE_ADDON_CONFIG,
+ ERRPR_RECEIVE_ADDON_CONFIG,
+ REMOVE_ADDON_CONFIG,
+ UPDATE_ADDON_CONFIG,
+ ADD_ADDON_CONFIG,
+ fetchAddons,
+ removeAddon,
+ updateAddon,
+ createAddon,
+} from '../actions';
+
+const middlewares = [thunk];
+const mockStore = configureMockStore(middlewares);
+
+afterEach(() => {
+ fetchMock.restore();
+});
+
+test('creates RECEIVE_ADDON_CONFIG when fetching addons has been done', () => {
+ fetchMock.getOnce('api/admin/addons', {
+ body: { addons: { providers: [{ name: 'webhook' }] } },
+ headers: { 'content-type': 'application/json' },
+ });
+
+ const expectedActions = [{ type: RECEIVE_ADDON_CONFIG, value: { addons: { providers: [{ name: 'webhook' }] } } }];
+ const store = mockStore({ addons: [] });
+
+ return store.dispatch(fetchAddons()).then(() => {
+ // return of async actions
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+});
+
+test('creates RECEIVE_ADDON_CONFIG_ when fetching addons has been done', () => {
+ fetchMock.getOnce('api/admin/addons', {
+ body: { message: 'Server error' },
+ headers: { 'content-type': 'application/json' },
+ status: 500,
+ });
+
+ const store = mockStore({ addons: [] });
+
+ return store.dispatch(fetchAddons()).catch(e => {
+ // return of async actions
+ expect(store.getActions()[0].error.type).toEqual(ERRPR_RECEIVE_ADDON_CONFIG);
+ expect(e.message).toEqual('Unexpected exception when talking to unleash-api');
+ });
+});
+
+test('creates REMOVE_ADDON_CONFIG when delete addon has been done', () => {
+ const addon = {
+ id: 1,
+ provider: 'webhook',
+ };
+
+ fetchMock.deleteOnce('api/admin/addons/1', {
+ status: 200,
+ });
+
+ const expectedActions = [{ type: REMOVE_ADDON_CONFIG, value: addon }];
+ const store = mockStore({ addons: [] });
+
+ return store.dispatch(removeAddon(addon)).then(() => {
+ // return of async actions
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+});
+
+test('creates UPDATE_ADDON_CONFIG when delete addon has been done', () => {
+ const addon = {
+ id: 1,
+ provider: 'webhook',
+ };
+
+ fetchMock.putOnce('api/admin/addons/1', {
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ body: addon,
+ });
+
+ const expectedActions = [{ type: UPDATE_ADDON_CONFIG, value: addon }];
+ const store = mockStore({ addons: [] });
+
+ return store.dispatch(updateAddon(addon)).then(() => {
+ // return of async actions
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+});
+
+test('creates ADD_ADDON_CONFIG when delete addon has been done', () => {
+ const addon = {
+ provider: 'webhook',
+ };
+
+ fetchMock.postOnce('api/admin/addons', {
+ headers: { 'content-type': 'application/json' },
+ status: 200,
+ body: addon,
+ });
+
+ const expectedActions = [{ type: ADD_ADDON_CONFIG, value: addon }];
+ const store = mockStore({ addons: [] });
+
+ return store.dispatch(createAddon(addon)).then(() => {
+ // return of async actions
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+});
diff --git a/frontend/src/store/addons/__tests__/addons-store.test.js b/frontend/src/store/addons/__tests__/addons-store.test.js
new file mode 100644
index 0000000000..d98cb34dcb
--- /dev/null
+++ b/frontend/src/store/addons/__tests__/addons-store.test.js
@@ -0,0 +1,54 @@
+import reducer from '../index';
+import { RECEIVE_ADDON_CONFIG, ADD_ADDON_CONFIG, REMOVE_ADDON_CONFIG, UPDATE_ADDON_CONFIG } from '../actions';
+import { addonSimple, addonsWithConfig, addonConfig } from './data';
+import { USER_LOGOUT } from '../../user/actions';
+
+test('should be default state', () => {
+ const state = reducer(undefined, {});
+ expect(state.toJS()).toMatchSnapshot();
+});
+
+test('should be merged state all', () => {
+ const state = reducer(undefined, { type: RECEIVE_ADDON_CONFIG, value: addonSimple });
+ expect(state.toJS()).toMatchSnapshot();
+});
+
+test('should add addon-config', () => {
+ let state = reducer(undefined, { type: RECEIVE_ADDON_CONFIG, value: addonSimple });
+ state = reducer(state, { type: ADD_ADDON_CONFIG, value: addonConfig });
+
+ const data = state.toJS();
+ expect(data).toMatchSnapshot();
+ expect(data.addons.length).toBe(1);
+});
+
+test('should remove addon-config', () => {
+ let state = reducer(undefined, { type: RECEIVE_ADDON_CONFIG, value: addonsWithConfig });
+ state = reducer(state, { type: REMOVE_ADDON_CONFIG, value: addonConfig });
+
+ const data = state.toJS();
+ expect(data).toMatchSnapshot();
+ expect(data.addons.length).toBe(0);
+});
+
+test('should update addon-config', () => {
+ const updateAdddonConfig = { ...addonConfig, description: 'new desc', enabled: false };
+
+ let state = reducer(undefined, { type: RECEIVE_ADDON_CONFIG, value: addonsWithConfig });
+ state = reducer(state, { type: UPDATE_ADDON_CONFIG, value: updateAdddonConfig });
+
+ const data = state.toJS();
+ expect(data).toMatchSnapshot();
+ expect(data.addons.length).toBe(1);
+ expect(data.addons[0].description).toBe('new desc');
+});
+
+test('should clear addon-config on logout', () => {
+ let state = reducer(undefined, { type: RECEIVE_ADDON_CONFIG, value: addonsWithConfig });
+ state = reducer(state, { type: USER_LOGOUT });
+
+ const data = state.toJS();
+ expect(data).toMatchSnapshot();
+ expect(data.addons.length).toBe(0);
+ expect(data.providers.length).toBe(0);
+});
diff --git a/frontend/src/store/addons/__tests__/data.js b/frontend/src/store/addons/__tests__/data.js
new file mode 100644
index 0000000000..5e18859d85
--- /dev/null
+++ b/frontend/src/store/addons/__tests__/data.js
@@ -0,0 +1,69 @@
+export const addonSimple = {
+ addons: [],
+ providers: [
+ {
+ name: 'webhook',
+ displayName: 'Webhook',
+ parameters: [
+ {
+ name: 'url',
+ displayName: 'Webhook URL',
+ type: 'string',
+ },
+ {
+ name: 'unleashUrl',
+ displayName: 'Unleash Admin UI url',
+ type: 'text',
+ },
+ {
+ name: 'bodyTemplate',
+ displayName: 'Body template',
+ description: 'You may format the body using a mustache template.',
+ type: 'text',
+ },
+ ],
+ events: ['feature-created', 'feature-updated', 'feature-archived', 'feature-revived'],
+ },
+ ],
+};
+
+export const addonConfig = {
+ id: 1,
+ provider: 'webhook',
+ enabled: true,
+ description: null,
+ parameters: {
+ url: 'http://localhost:4242/webhook',
+ bodyTemplate: "{'name': '{{event.data.name}}' }",
+ },
+ events: ['feature-updated', 'feature-created'],
+};
+
+export const addonsWithConfig = {
+ addons: [addonConfig],
+ providers: [
+ {
+ name: 'webhook',
+ displayName: 'Webhook',
+ parameters: [
+ {
+ name: 'url',
+ displayName: 'Webhook URL',
+ type: 'string',
+ },
+ {
+ name: 'unleashUrl',
+ displayName: 'Unleash Admin UI url',
+ type: 'text',
+ },
+ {
+ name: 'bodyTemplate',
+ displayName: 'Body template',
+ description: 'You may format the body using a mustache template.',
+ type: 'text',
+ },
+ ],
+ events: ['feature-created', 'feature-updated', 'feature-archived', 'feature-revived'],
+ },
+ ],
+};
diff --git a/frontend/src/store/addons/actions.js b/frontend/src/store/addons/actions.js
new file mode 100644
index 0000000000..83805e5a4e
--- /dev/null
+++ b/frontend/src/store/addons/actions.js
@@ -0,0 +1,51 @@
+import api from './api';
+import { dispatchAndThrow } from '../util';
+
+export const RECEIVE_ADDON_CONFIG = 'RECEIVE_ADDON_CONFIG';
+export const ERROR_RECEIVE_ADDON_CONFIG = 'ERROR_RECEIVE_ADDON_CONFIG';
+export const REMOVE_ADDON_CONFIG = 'REMOVE_ADDON_CONFIG';
+export const ERROR_REMOVING_ADDON_CONFIG = 'ERROR_REMOVING_ADDON_CONFIG';
+export const ADD_ADDON_CONFIG = 'ADD_ADDON_CONFIG';
+export const ERROR_ADD_ADDON_CONFIG = 'ERROR_ADD_ADDON_CONFIG';
+export const UPDATE_ADDON_CONFIG = 'UPDATE_ADDON_CONFIG';
+export const ERROR_UPDATE_ADDON_CONFIG = 'ERROR_UPDATE_ADDON_CONFIG';
+
+// const receiveAddonConfig = value => ({ type: RECEIVE_ADDON_CONFIG, value });
+const addAddonConfig = value => ({ type: ADD_ADDON_CONFIG, value });
+const updateAdddonConfig = value => ({ type: UPDATE_ADDON_CONFIG, value });
+const removeAddonconfig = value => ({ type: REMOVE_ADDON_CONFIG, value });
+
+const success = (dispatch, type) => value => dispatch({ type, value });
+
+export function fetchAddons() {
+ return dispatch =>
+ api
+ .fetchAll()
+ .then(success(dispatch, RECEIVE_ADDON_CONFIG))
+ .catch(dispatchAndThrow(dispatch, ERROR_RECEIVE_ADDON_CONFIG));
+}
+
+export function removeAddon(addon) {
+ return dispatch =>
+ api
+ .remove(addon)
+ .then(() => dispatch(removeAddonconfig(addon)))
+ .catch(dispatchAndThrow(dispatch, ERROR_REMOVING_ADDON_CONFIG));
+}
+
+export function createAddon(addon) {
+ return dispatch =>
+ api
+ .create(addon)
+ .then(res => res.json())
+ .then(value => dispatch(addAddonConfig(value)))
+ .catch(dispatchAndThrow(dispatch, ERROR_ADD_ADDON_CONFIG));
+}
+
+export function updateAddon(addon) {
+ return dispatch =>
+ api
+ .update(addon)
+ .then(() => dispatch(updateAdddonConfig(addon)))
+ .catch(dispatchAndThrow(dispatch, ERROR_UPDATE_ADDON_CONFIG));
+}
diff --git a/frontend/src/store/addons/api.js b/frontend/src/store/addons/api.js
new file mode 100644
index 0000000000..455654b78f
--- /dev/null
+++ b/frontend/src/store/addons/api.js
@@ -0,0 +1,42 @@
+import { throwIfNotSuccess, headers } from '../api-helper';
+
+const URI = 'api/admin/addons';
+
+function fetchAll() {
+ return fetch(URI, { credentials: 'include' })
+ .then(throwIfNotSuccess)
+ .then(response => response.json());
+}
+
+function create(addonConfig) {
+ return fetch(URI, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(addonConfig),
+ credentials: 'include',
+ }).then(throwIfNotSuccess);
+}
+
+function update(addonConfig) {
+ return fetch(`${URI}/${addonConfig.id}`, {
+ method: 'PUT',
+ headers,
+ body: JSON.stringify(addonConfig),
+ credentials: 'include',
+ }).then(throwIfNotSuccess);
+}
+
+function remove(addonConfig) {
+ return fetch(`${URI}/${addonConfig.id}`, {
+ method: 'DELETE',
+ headers,
+ credentials: 'include',
+ }).then(throwIfNotSuccess);
+}
+
+export default {
+ fetchAll,
+ create,
+ update,
+ remove,
+};
diff --git a/frontend/src/store/addons/index.js b/frontend/src/store/addons/index.js
new file mode 100644
index 0000000000..ab31635f7e
--- /dev/null
+++ b/frontend/src/store/addons/index.js
@@ -0,0 +1,33 @@
+import { Map as $Map, List, fromJS } from 'immutable';
+import { RECEIVE_ADDON_CONFIG, ADD_ADDON_CONFIG, REMOVE_ADDON_CONFIG, UPDATE_ADDON_CONFIG } from './actions';
+import { USER_LOGOUT, USER_LOGIN } from '../user/actions';
+
+function getInitState() {
+ return new $Map({
+ providers: new List(),
+ addons: new List(),
+ });
+}
+
+const strategies = (state = getInitState(), action) => {
+ switch (action.type) {
+ case RECEIVE_ADDON_CONFIG:
+ return fromJS(action.value);
+ case ADD_ADDON_CONFIG: {
+ return state.update('addons', arr => arr.push(fromJS(action.value)));
+ }
+ case REMOVE_ADDON_CONFIG:
+ return state.update('addons', arr => arr.filter(a => a.get('id') !== action.value.id));
+ case UPDATE_ADDON_CONFIG: {
+ const index = state.get('addons').findIndex(item => item.get('id') === action.value.id);
+ return state.setIn(['addons', index], fromJS(action.value));
+ }
+ case USER_LOGOUT:
+ case USER_LOGIN:
+ return getInitState();
+ default:
+ return state;
+ }
+};
+
+export default strategies;
diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
index 9aa0b77693..492190c2b5 100644
--- a/frontend/src/store/index.js
+++ b/frontend/src/store/index.js
@@ -15,6 +15,7 @@ import applications from './application';
import uiConfig from './ui-config';
import context from './context';
import projects from './project';
+import addons from './addons';
const unleashStore = combineReducers({
features,
@@ -33,6 +34,7 @@ const unleashStore = combineReducers({
uiConfig,
context,
projects,
+ addons,
});
export default unleashStore;
diff --git a/frontend/src/store/util.js b/frontend/src/store/util.js
index 6d727484b6..713a47ee22 100644
--- a/frontend/src/store/util.js
+++ b/frontend/src/store/util.js
@@ -17,3 +17,5 @@ export function dispatchAndThrow(dispatch, type) {
throw error;
};
}
+
+export const success = (dispatch, type, val) => value => dispatch({ type, value: val ? val : value });
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 526c43d1bb..9c6e8989ff 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -9,6 +9,13 @@
dependencies:
"@babel/highlight" "^7.8.3"
+"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.11":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
+ integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
+ dependencies:
+ "@babel/highlight" "^7.10.4"
+
"@babel/compat-data@^7.8.6", "@babel/compat-data@^7.9.0":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.9.0.tgz#04815556fc90b0c174abd2c0c1bb966faa036a6c"
@@ -18,6 +25,27 @@
invariant "^2.2.4"
semver "^5.5.0"
+"@babel/core@^7.0.0":
+ version "7.12.10"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.10.tgz#b79a2e1b9f70ed3d84bbfb6d8c4ef825f606bccd"
+ integrity sha512-eTAlQKq65zHfkHZV0sIVODCPGVgoo1HdBlbSLi9CqOzuZanMv2ihzY+4paiKr1mH+XmYESMAmJ/dpZ68eN6d8w==
+ dependencies:
+ "@babel/code-frame" "^7.10.4"
+ "@babel/generator" "^7.12.10"
+ "@babel/helper-module-transforms" "^7.12.1"
+ "@babel/helpers" "^7.12.5"
+ "@babel/parser" "^7.12.10"
+ "@babel/template" "^7.12.7"
+ "@babel/traverse" "^7.12.10"
+ "@babel/types" "^7.12.10"
+ convert-source-map "^1.7.0"
+ debug "^4.1.0"
+ gensync "^1.0.0-beta.1"
+ json5 "^2.1.2"
+ lodash "^4.17.19"
+ semver "^5.4.1"
+ source-map "^0.5.0"
+
"@babel/core@^7.1.0", "@babel/core@^7.7.5", "@babel/core@^7.9.0":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.9.0.tgz#ac977b538b77e132ff706f3b8a4dbad09c03c56e"
@@ -40,6 +68,15 @@
semver "^5.4.1"
source-map "^0.5.0"
+"@babel/generator@^7.12.10", "@babel/generator@^7.12.11":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.12.11.tgz#98a7df7b8c358c9a37ab07a24056853016aba3af"
+ integrity sha512-Ggg6WPOJtSi8yYQvLVjG8F/TlpWDlKx0OpS4Kt+xMQPs5OaGYWy+v1A+1TvxI6sAMGZpKWWoAQ1DaeQbImlItA==
+ dependencies:
+ "@babel/types" "^7.12.11"
+ jsesc "^2.5.1"
+ source-map "^0.5.0"
+
"@babel/generator@^7.4.0", "@babel/generator@^7.9.0", "@babel/generator@^7.9.5":
version "7.9.5"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.9.5.tgz#27f0917741acc41e6eaaced6d68f96c3fa9afaf9"
@@ -131,6 +168,15 @@
"@babel/traverse" "^7.8.3"
"@babel/types" "^7.8.3"
+"@babel/helper-function-name@^7.12.11":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.11.tgz#1fd7738aee5dcf53c3ecff24f1da9c511ec47b42"
+ integrity sha512-AtQKjtYNolKNi6nNNVLQ27CP6D9oFR6bq/HPYSizlzbp7uC1M59XJe8L+0uXjbIaZaUJF99ruHqVGiKXU/7ybA==
+ dependencies:
+ "@babel/helper-get-function-arity" "^7.12.10"
+ "@babel/template" "^7.12.7"
+ "@babel/types" "^7.12.11"
+
"@babel/helper-function-name@^7.8.3", "@babel/helper-function-name@^7.9.5":
version "7.9.5"
resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.9.5.tgz#2b53820d35275120e1874a82e5aabe1376920a5c"
@@ -140,6 +186,13 @@
"@babel/template" "^7.8.3"
"@babel/types" "^7.9.5"
+"@babel/helper-get-function-arity@^7.12.10":
+ version "7.12.10"
+ resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf"
+ integrity sha512-mm0n5BPjR06wh9mPQaDdXWDoll/j5UpCAPl1x8fS71GHm7HA6Ua2V4ylG1Ju8lvcTOietbPNNPaSilKj+pj+Ag==
+ dependencies:
+ "@babel/types" "^7.12.10"
+
"@babel/helper-get-function-arity@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5"
@@ -154,6 +207,13 @@
dependencies:
"@babel/types" "^7.8.3"
+"@babel/helper-member-expression-to-functions@^7.12.7":
+ version "7.12.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.12.7.tgz#aa77bd0396ec8114e5e30787efa78599d874a855"
+ integrity sha512-DCsuPyeWxeHgh1Dus7APn7iza42i/qXqiFPWyBDdOFtvS581JQePsc1F/nD+fHrcswhLlRc2UpYS1NwERxZhHw==
+ dependencies:
+ "@babel/types" "^7.12.7"
+
"@babel/helper-member-expression-to-functions@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c"
@@ -168,6 +228,28 @@
dependencies:
"@babel/types" "^7.8.3"
+"@babel/helper-module-imports@^7.12.1":
+ version "7.12.5"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb"
+ integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA==
+ dependencies:
+ "@babel/types" "^7.12.5"
+
+"@babel/helper-module-transforms@^7.12.1":
+ version "7.12.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.12.1.tgz#7954fec71f5b32c48e4b303b437c34453fd7247c"
+ integrity sha512-QQzehgFAZ2bbISiCpmVGfiGux8YVFXQ0abBic2Envhej22DVXV9nCFaS5hIQbkyo1AdGb+gNME2TSh3hYJVV/w==
+ dependencies:
+ "@babel/helper-module-imports" "^7.12.1"
+ "@babel/helper-replace-supers" "^7.12.1"
+ "@babel/helper-simple-access" "^7.12.1"
+ "@babel/helper-split-export-declaration" "^7.11.0"
+ "@babel/helper-validator-identifier" "^7.10.4"
+ "@babel/template" "^7.10.4"
+ "@babel/traverse" "^7.12.1"
+ "@babel/types" "^7.12.1"
+ lodash "^4.17.19"
+
"@babel/helper-module-transforms@^7.9.0":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.9.0.tgz#43b34dfe15961918707d247327431388e9fe96e5"
@@ -181,6 +263,13 @@
"@babel/types" "^7.9.0"
lodash "^4.17.13"
+"@babel/helper-optimise-call-expression@^7.12.10":
+ version "7.12.10"
+ resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.10.tgz#94ca4e306ee11a7dd6e9f42823e2ac6b49881e2d"
+ integrity sha512-4tpbU0SrSTjjt65UMWSrUOPZTsgvPgGG4S8QSTNHacKzpS51IVWGDj0yCwyeZND/i+LSN2g/O63jEXEWm49sYQ==
+ dependencies:
+ "@babel/types" "^7.12.10"
+
"@babel/helper-optimise-call-expression@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9"
@@ -211,6 +300,16 @@
"@babel/traverse" "^7.8.3"
"@babel/types" "^7.8.3"
+"@babel/helper-replace-supers@^7.12.1":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.12.11.tgz#ea511658fc66c7908f923106dd88e08d1997d60d"
+ integrity sha512-q+w1cqmhL7R0FNzth/PLLp2N+scXEK/L2AHbXUyydxp828F4FEa5WcVoqui9vFRiHDQErj9Zof8azP32uGVTRA==
+ dependencies:
+ "@babel/helper-member-expression-to-functions" "^7.12.7"
+ "@babel/helper-optimise-call-expression" "^7.12.10"
+ "@babel/traverse" "^7.12.10"
+ "@babel/types" "^7.12.11"
+
"@babel/helper-replace-supers@^7.8.3", "@babel/helper-replace-supers@^7.8.6":
version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.6.tgz#5ada744fd5ad73203bf1d67459a27dcba67effc8"
@@ -221,6 +320,13 @@
"@babel/traverse" "^7.8.6"
"@babel/types" "^7.8.6"
+"@babel/helper-simple-access@^7.12.1":
+ version "7.12.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.12.1.tgz#32427e5aa61547d38eb1e6eaf5fd1426fdad9136"
+ integrity sha512-OxBp7pMrjVewSSC8fXDFrHrBcJATOOFssZwv16F3/6Xtc138GHybBfPbm9kfiqQHKhYQrlamWILwlDCeyMFEaA==
+ dependencies:
+ "@babel/types" "^7.12.1"
+
"@babel/helper-simple-access@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae"
@@ -229,6 +335,13 @@
"@babel/template" "^7.8.3"
"@babel/types" "^7.8.3"
+"@babel/helper-split-export-declaration@^7.11.0", "@babel/helper-split-export-declaration@^7.12.11":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.11.tgz#1b4cc424458643c47d37022223da33d76ea4603a"
+ integrity sha512-LsIVN8j48gHgwzfocYUSkO/hjYAOJqlpJEc7tGXcIm4cubjVUf8LGW6eWRyxEu7gA25q02p0rQUWoCI33HNS5g==
+ dependencies:
+ "@babel/types" "^7.12.11"
+
"@babel/helper-split-export-declaration@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9"
@@ -236,6 +349,11 @@
dependencies:
"@babel/types" "^7.8.3"
+"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
+ integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
+
"@babel/helper-validator-identifier@^7.9.0", "@babel/helper-validator-identifier@^7.9.5":
version "7.9.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80"
@@ -251,6 +369,15 @@
"@babel/traverse" "^7.8.3"
"@babel/types" "^7.8.3"
+"@babel/helpers@^7.12.5":
+ version "7.12.5"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.12.5.tgz#1a1ba4a768d9b58310eda516c449913fe647116e"
+ integrity sha512-lgKGMQlKqA8meJqKsW6rUnc4MdUk35Ln0ATDqdM1a/UpARODdI4j5Y5lVfUScnSNkJcdCRAaWkspykNoFg9sJA==
+ dependencies:
+ "@babel/template" "^7.10.4"
+ "@babel/traverse" "^7.12.5"
+ "@babel/types" "^7.12.5"
+
"@babel/helpers@^7.9.0":
version "7.9.2"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.9.2.tgz#b42a81a811f1e7313b88cba8adc66b3d9ae6c09f"
@@ -260,6 +387,15 @@
"@babel/traverse" "^7.9.0"
"@babel/types" "^7.9.0"
+"@babel/highlight@^7.10.4":
+ version "7.10.4"
+ resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143"
+ integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.10.4"
+ chalk "^2.0.0"
+ js-tokens "^4.0.0"
+
"@babel/highlight@^7.8.3":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.9.0.tgz#4e9b45ccb82b79607271b2979ad82c7b68163079"
@@ -274,6 +410,11 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.9.4.tgz#68a35e6b0319bbc014465be43828300113f2f2e8"
integrity sha512-bC49otXX6N0/VYhgOMh4gnP26E9xnDZK3TmbNpxYzzz9BQLBosQwfyOe9/cXUU3txYhTzLCbcqd5c8y/OmCjHA==
+"@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7":
+ version "7.12.11"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79"
+ integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg==
+
"@babel/plugin-proposal-async-generator-functions@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f"
@@ -856,6 +997,13 @@
core-js-pure "^3.0.0"
regenerator-runtime "^0.13.4"
+"@babel/runtime@^7.0.0":
+ version "7.12.5"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
+ integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
+ dependencies:
+ regenerator-runtime "^0.13.4"
+
"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4":
version "7.9.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.2.tgz#d90df0583a3a252f09aaa619665367bae518db06"
@@ -870,6 +1018,15 @@
dependencies:
regenerator-runtime "^0.13.4"
+"@babel/template@^7.10.4", "@babel/template@^7.12.7":
+ version "7.12.7"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.7.tgz#c817233696018e39fbb6c491d2fb684e05ed43bc"
+ integrity sha512-GkDzmHS6GV7ZeXfJZ0tLRBhZcMcY0/Lnb+eEbXDBfCAcZCjrZKe6p3J4we/D24O9Y8enxWAg1cWwof59yLh2ow==
+ dependencies:
+ "@babel/code-frame" "^7.10.4"
+ "@babel/parser" "^7.12.7"
+ "@babel/types" "^7.12.7"
+
"@babel/template@^7.4.0", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
@@ -894,6 +1051,21 @@
globals "^11.1.0"
lodash "^4.17.13"
+"@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.5":
+ version "7.12.12"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376"
+ integrity sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w==
+ dependencies:
+ "@babel/code-frame" "^7.12.11"
+ "@babel/generator" "^7.12.11"
+ "@babel/helper-function-name" "^7.12.11"
+ "@babel/helper-split-export-declaration" "^7.12.11"
+ "@babel/parser" "^7.12.11"
+ "@babel/types" "^7.12.12"
+ debug "^4.1.0"
+ globals "^11.1.0"
+ lodash "^4.17.19"
+
"@babel/types@^7.0.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4", "@babel/types@^7.7.0", "@babel/types@^7.8.3", "@babel/types@^7.8.6", "@babel/types@^7.9.0", "@babel/types@^7.9.5":
version "7.9.5"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.9.5.tgz#89231f82915a8a566a703b3b20133f73da6b9444"
@@ -903,6 +1075,15 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
+"@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7":
+ version "7.12.12"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299"
+ integrity sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.12.11"
+ lodash "^4.17.19"
+ to-fast-properties "^2.0.0"
+
"@cnakazawa/watch@^1.0.3":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a"
@@ -2050,14 +2231,6 @@ babel-preset-jest@^25.3.0:
babel-plugin-jest-hoist "^25.2.6"
babel-preset-current-node-syntax "^0.1.2"
-babel-runtime@^6.26.0:
- version "6.26.0"
- resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
- integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
- dependencies:
- core-js "^2.4.0"
- regenerator-runtime "^0.11.0"
-
balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
@@ -2816,11 +2989,6 @@ core-js-pure@^3.0.0:
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA==
-core-js@^2.4.0:
- version "2.6.11"
- resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
- integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
-
core-js@^3.0.0:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
@@ -4042,12 +4210,13 @@ fb-watchman@^2.0.0:
dependencies:
bser "2.1.1"
-fetch-mock@^9.4.0:
- version "9.4.0"
- resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-9.4.0.tgz#9be073577bcfa57af714ca91f7536aff8450ec88"
- integrity sha512-tqnFmcjYheW5Z9zOPRVY+ZXjB/QWCYtPiOrYGEsPgKfpGHco97eaaj7Rv9MjK7PVWG4rWfv6t2IgQAzDQizBZA==
+fetch-mock@^9.11.0:
+ version "9.11.0"
+ resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-9.11.0.tgz#371c6fb7d45584d2ae4a18ee6824e7ad4b637a3f"
+ integrity sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==
dependencies:
- babel-runtime "^6.26.0"
+ "@babel/core" "^7.0.0"
+ "@babel/runtime" "^7.0.0"
core-js "^3.0.0"
debug "^4.1.1"
glob-to-regexp "^0.4.0"
@@ -8133,11 +8302,6 @@ regenerate@^1.4.0:
resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
-regenerator-runtime@^0.11.0:
- version "0.11.1"
- resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
- integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
-
regenerator-runtime@^0.13.4:
version "0.13.5"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697"