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}

+
+
+
+ + + + + + + {config.enabled ? 'Enabled' : 'Disabled'} + + + + + +
+
+ +
+
+ +
+ + + +
+
+ ); +}; + +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"