1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

refactor: refactor addons to TSX and remove unused files (#676)

* refactor: refactor addons to TSX and remove unused files

* refactor: change AddonIcon to getAddonIcon

* refactor: add PermissionButton instead of conditional render

* refactor: wrap icon buttons inside PermissionIconButtons

* feat: add confirm delete dialog

* fix: create addon form

* fix: refactor addons

* fix: remove addon store folder

* fix: update index

* fix: rebase

* fix: update exports

* fix: update snapshot

* fix: add dev dep

Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Youssef Khedher 2022-02-09 23:05:15 +01:00 committed by GitHub
parent 500d405fa5
commit 2a9a3ac569
36 changed files with 452 additions and 1002 deletions

View File

@ -67,6 +67,7 @@
"fetch-mock": "9.11.0",
"http-proxy-middleware": "2.0.2",
"immutable": "4.0.0",
"@types/lodash.clonedeep": "^4.5.6",
"lodash.clonedeep": "4.5.0",
"lodash.flow": "3.5.0",
"node-fetch": "2.6.7",

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Grid, FormControlLabel, Checkbox } from '@material-ui/core';
import { styles as commonStyles } from '../../../common';
import { IAddonProvider } from '../../../../interfaces/addons';
interface IAddonProps {
provider: IAddonProvider;
checkedEvents: string[];
setEventValue: (name: string) => void;
error: Record<string, string>;
}
export const AddonEvents = ({
provider,
checkedEvents,
setEventValue,
error,
}: IAddonProps) => {
if (!provider) return null;
return (
<React.Fragment>
<h4>Events</h4>
<span className={commonStyles.error}>{error}</span>
<Grid container spacing={0}>
{provider.events.map(e => (
<Grid item xs={4} key={e}>
<FormControlLabel
control={
<Checkbox
checked={checkedEvents.includes(e)}
onChange={setEventValue(e)}
/>
}
label={e}
/>
</Grid>
))}
</Grid>
</React.Fragment>
);
};

View File

@ -1,23 +1,29 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { TextField, FormControlLabel, Switch } from '@material-ui/core';
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 { FormButtons, styles as commonStyles } from '../../common';
import { trim } from '../../common/util';
import { AddonParameters } from './AddonParameters/AddonParameters';
import { AddonEvents } from './AddonEvents/AddonEvents';
import cloneDeep from 'lodash.clonedeep';
import styles from './form-addon-component.module.scss';
import PageContent from '../common/PageContent/PageContent';
import useAddonsApi from '../../hooks/api/actions/useAddonsApi/useAddonsApi';
import useToast from '../../hooks/useToast';
import PageContent from '../../common/PageContent/PageContent';
import { useHistory } from 'react-router-dom';
import useAddonsApi from '../../../hooks/api/actions/useAddonsApi/useAddonsApi';
import useToast from '../../../hooks/useToast';
import { makeStyles } from '@material-ui/styles';
const AddonFormComponent = ({ editMode, provider, addon, fetch }) => {
const useStyles = makeStyles(theme => ({
nameInput: {
marginRight: '1.5rem',
},
formSection: { padding: '10px 28px' },
}));
export const AddonForm = ({ editMode, provider, addon, fetch }) => {
const { createAddon, updateAddon } = useAddonsApi();
const { setToastData, setToastApiError } = useToast();
const history = useHistory();
const styles = useStyles();
const [config, setConfig] = useState(addon);
const [errors, setErrors] = useState({
@ -116,6 +122,7 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch }) => {
history.push('/addons');
setToastData({
type: 'success',
confetti: true,
title: 'Addon created successfully',
});
}
@ -196,14 +203,17 @@ const AddonFormComponent = ({ editMode, provider, addon, fetch }) => {
/>
</section>
<section className={styles.formSection}>
<FormButtons submitText={submitText} onCancel={handleCancel} />
<FormButtons
submitText={submitText}
onCancel={handleCancel}
/>
</section>
</form>
</PageContent>
);
};
AddonFormComponent.propTypes = {
AddonForm.propTypes = {
provider: PropTypes.object,
addon: PropTypes.object.isRequired,
fetch: PropTypes.func.isRequired,
@ -211,5 +221,3 @@ AddonFormComponent.propTypes = {
cancel: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
};
export default AddonFormComponent;

View File

@ -0,0 +1,60 @@
import { TextField } from '@material-ui/core';
import {
IAddonConfig,
IAddonProvider,
IAddonProviderParams,
} from '../../../../../interfaces/addons';
const resolveType = ({ type = 'text', sensitive = false }, value: string) => {
if (sensitive && value === MASKED_VALUE) {
return 'text';
}
if (type === 'textfield') {
return 'text';
}
return type;
};
const MASKED_VALUE = '*****';
interface IAddonParameterProps {
provider: IAddonProvider;
errors: Record<string, string>;
definition: IAddonProviderParams;
setParameterValue: (param: string) => void;
config: IAddonConfig;
}
export const AddonParameter = ({
definition,
config,
errors,
setParameterValue,
}: IAddonParameterProps) => {
const value = config.parameters[definition.name] || '';
const type = resolveType(definition, value);
const error = errors.parameters[definition.name];
return (
<div style={{ width: '80%', marginTop: '25px' }}>
<TextField
size="small"
style={{ width: '100%' }}
rows={definition.type === 'textfield' ? 9 : 0}
multiline={definition.type === 'textfield'}
type={type}
label={definition.displayName}
name={definition.name}
placeholder={definition.placeholder || ''}
InputLabelProps={{
shrink: true,
}}
value={value}
error={error}
onChange={setParameterValue(definition.name)}
variant="outlined"
helperText={definition.description}
/>
</div>
);
};

View File

@ -0,0 +1,43 @@
import React from 'react';
import { IAddonConfig, IAddonProvider } from '../../../../interfaces/addons';
import { AddonParameter } from './AddonParameter/AddonParameter';
interface IAddonParametersProps {
provider: IAddonProvider;
errors: Record<string, string>;
editMode: boolean;
setParameterValue: (param: string) => void;
config: IAddonConfig;
}
export const AddonParameters = ({
provider,
config,
errors,
setParameterValue,
editMode,
}: IAddonParametersProps) => {
if (!provider) return null;
return (
<React.Fragment>
<h4>Parameters</h4>
{editMode ? (
<p>
Sensitive parameters will be masked with value "<i>*****</i>
". If you don't change the value they will not be updated
when saving.
</p>
) : null}
{provider.parameters.map(parameter => (
<AddonParameter
key={parameter.name}
definition={parameter}
errors={errors}
config={config}
setParameterValue={setParameterValue}
/>
))}
</React.Fragment>
);
};

View File

@ -1,12 +1,9 @@
import { useContext, useEffect } from 'react';
import ConfiguredAddons from './ConfiguredAddons';
import AvailableAddons from './AvailableAddons';
import { ReactElement } from 'react';
import { ConfiguredAddons } from './ConfiguredAddons/ConfiguredAddons';
import { AvailableAddons } from './AvailableAddons/AvailableAddons';
import { Avatar } from '@material-ui/core';
import { DeviceHub } from '@material-ui/icons';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import AccessContext from '../../../contexts/AccessContext';
import slackIcon from '../../../assets/icons/slack.svg';
import jiraIcon from '../../../assets/icons/jira.svg';
import webhooksIcon from '../../../assets/icons/webhooks.svg';
@ -14,7 +11,6 @@ import teamsIcon from '../../../assets/icons/teams.svg';
import dataDogIcon from '../../../assets/icons/datadog.svg';
import { formatAssetPath } from '../../../utils/format-path';
import useAddons from '../../../hooks/api/getters/useAddons/useAddons';
import { useHistory } from 'react-router-dom';
const style = {
width: '40px',
@ -23,7 +19,7 @@ const style = {
float: 'left',
};
const getIcon = name => {
const getAddonIcon = (name: string): ReactElement => {
switch (name) {
case 'slack':
return (
@ -74,40 +70,21 @@ const getIcon = name => {
}
};
const AddonList = () => {
const { hasAccess } = useContext(AccessContext);
const { addons, providers, refetchAddons } = useAddons();
const history = useHistory();
useEffect(() => {
if (addons.length === 0) {
refetchAddons();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [addons.length]);
export const AddonList = () => {
const { providers, addons } = useAddons();
return (
<>
<ConditionallyRender
condition={addons.length > 0}
show={
<ConfiguredAddons
addons={addons}
hasAccess={hasAccess}
getIcon={getIcon}
/>
}
show={<ConfiguredAddons getAddonIcon={getAddonIcon} />}
/>
<br />
<AvailableAddons
providers={providers}
hasAccess={hasAccess}
history={history}
getIcon={getIcon}
getAddonIcon={getAddonIcon}
/>
</>
);
};
export default AddonList;

View File

@ -1,57 +0,0 @@
import React from 'react';
import PageContent from '../../../common/PageContent/PageContent';
import {
Button,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
} from '@material-ui/core';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import { CREATE_ADDON } from '../../../providers/AccessProvider/permissions';
import PropTypes from 'prop-types';
const AvailableAddons = ({ providers, getIcon, hasAccess, history }) => {
const renderProvider = provider => (
<ListItem key={provider.name}>
<ListItemAvatar>{getIcon(provider.name)}</ListItemAvatar>
<ListItemText
primary={provider.displayName}
secondary={provider.description}
/>
<ListItemSecondaryAction>
<ConditionallyRender
condition={hasAccess(CREATE_ADDON)}
show={
<Button
variant="contained"
name="device_hub"
onClick={() =>
history.push(`/addons/create/${provider.name}`)
}
title="Configure"
>
Configure
</Button>
}
/>
</ListItemSecondaryAction>
</ListItem>
);
return (
<PageContent headerContent="Available addons">
<List>{providers.map(provider => renderProvider(provider))}</List>
</PageContent>
);
};
AvailableAddons.propTypes = {
providers: PropTypes.array.isRequired,
getIcon: PropTypes.func.isRequired,
hasAccess: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
export default AvailableAddons;

View File

@ -0,0 +1,63 @@
import { ReactElement } from 'react';
import PageContent from '../../../common/PageContent/PageContent';
import {
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
} from '@material-ui/core';
import { CREATE_ADDON } from '../../../providers/AccessProvider/permissions';
import { useHistory } from 'react-router-dom';
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
interface IProvider {
name: string;
displayName: string;
description: string;
documentationUrl: string;
parameters: object[];
events: string[];
}
interface IAvailableAddonsProps {
getAddonIcon: (name: string) => ReactElement;
providers: IProvider[];
}
export const AvailableAddons = ({
providers,
getAddonIcon,
}: IAvailableAddonsProps) => {
const history = useHistory();
const renderProvider = (provider: IProvider) => (
<ListItem key={provider.name}>
<ListItemAvatar>{getAddonIcon(provider.name)}</ListItemAvatar>
<ListItemText
primary={provider.displayName}
secondary={provider.description}
/>
<ListItemSecondaryAction>
<PermissionButton
permission={CREATE_ADDON}
onClick={() =>
history.push(`/addons/create/${provider.name}`)
}
tooltip={`Configure ${provider.name} Addon`}
>
Configure
</PermissionButton>
</ListItemSecondaryAction>
</ListItem>
);
return (
<PageContent headerContent="Available addons">
<List>
{providers.map((provider: IProvider) =>
renderProvider(provider)
)}
</List>
</PageContent>
);
};

View File

@ -1,3 +0,0 @@
import AvailableAddons from './AvailableAddons';
export default AvailableAddons;

View File

@ -1,6 +1,4 @@
import React from 'react';
import {
IconButton,
List,
ListItem,
ListItemAvatar,
@ -8,7 +6,6 @@ import {
ListItemText,
} from '@material-ui/core';
import { Visibility, VisibilityOff, Delete } from '@material-ui/icons';
import ConditionallyRender from '../../../common/ConditionallyRender/ConditionallyRender';
import {
DELETE_ADDON,
@ -16,17 +13,43 @@ import {
} from '../../../providers/AccessProvider/permissions';
import { Link } from 'react-router-dom';
import PageContent from '../../../common/PageContent/PageContent';
import PropTypes from 'prop-types';
import useAddons from '../../../../hooks/api/getters/useAddons/useAddons';
import useToast from '../../../../hooks/useToast';
import useAddonsApi from '../../../../hooks/api/actions/useAddonsApi/useAddonsApi';
import { ReactElement, useContext, useState } from 'react';
import AccessContext from '../../../../contexts/AccessContext';
import { IAddon } from '../../../../interfaces/addons';
import PermissionIconButton from '../../../common/PermissionIconButton/PermissionIconButton';
import Dialogue from '../../../common/Dialogue';
const ConfiguredAddons = ({ addons, hasAccess, getIcon }) => {
const { refetchAddons } = useAddons();
interface IConfigureAddonsProps {
getAddonIcon: (name: string) => ReactElement;
}
export const ConfiguredAddons = ({ getAddonIcon }: IConfigureAddonsProps) => {
const { refetchAddons, addons } = useAddons();
const { updateAddon, removeAddon } = useAddonsApi();
const { setToastData, setToastApiError } = useToast();
const { hasAccess } = useContext(AccessContext);
const [showDelete, setShowDelete] = useState(false);
const [deletedAddon, setDeletedAddon] = useState<IAddon>({
id: 0,
provider: '',
description: '',
enabled: false,
events: [],
parameters: {},
});
const toggleAddon = async addon => {
const sortAddons = (addons: IAddon[]) => {
if (!addons) return [];
return addons.sort((addonA: IAddon, addonB: IAddon) => {
return addonA.id - addonB.id;
});
};
const toggleAddon = async (addon: IAddon) => {
try {
await updateAddon({ ...addon, enabled: !addon.enabled });
refetchAddons();
@ -35,12 +58,12 @@ const ConfiguredAddons = ({ addons, hasAccess, getIcon }) => {
title: 'Success',
text: 'Addon state switched successfully',
});
} catch (e) {
} catch (e: any) {
setToastApiError(e.toString());
}
};
const onRemoveAddon = addon => async () => {
const onRemoveAddon = async (addon: IAddon) => {
try {
await removeAddon(addon.id);
refetchAddons();
@ -58,9 +81,9 @@ const ConfiguredAddons = ({ addons, hasAccess, getIcon }) => {
}
};
const renderAddon = addon => (
const renderAddon = (addon: IAddon) => (
<ListItem key={addon.id}>
<ListItemAvatar>{getIcon(addon.provider)}</ListItemAvatar>
<ListItemAvatar>{getAddonIcon(addon.provider)}</ListItemAvatar>
<ListItemText
primary={
<span>
@ -85,50 +108,48 @@ const ConfiguredAddons = ({ addons, hasAccess, getIcon }) => {
secondary={addon.description}
/>
<ListItemSecondaryAction>
<ConditionallyRender
condition={hasAccess(UPDATE_ADDON)}
show={
<IconButton
size="small"
title={
addon.enabled ? 'Disable addon' : 'Enable addon'
}
onClick={() => toggleAddon(addon)}
>
<ConditionallyRender
condition={addon.enabled}
show={<Visibility />}
elseShow={<VisibilityOff />}
/>
</IconButton>
}
/>
<ConditionallyRender
condition={hasAccess(DELETE_ADDON)}
show={
<IconButton
size="small"
title="Remove addon"
onClick={onRemoveAddon(addon)}
>
<Delete />
</IconButton>
}
/>
<PermissionIconButton
permission={UPDATE_ADDON}
tooltip={addon.enabled ? 'Disable addon' : 'Enable addon'}
onClick={() => toggleAddon(addon)}
>
<ConditionallyRender
condition={addon.enabled}
show={<Visibility />}
elseShow={<VisibilityOff />}
/>
</PermissionIconButton>
<PermissionIconButton
permission={DELETE_ADDON}
tooltip={'Remove Addon'}
onClick={() => {
setDeletedAddon(addon);
setShowDelete(true);
}}
>
<Delete />
</PermissionIconButton>
</ListItemSecondaryAction>
</ListItem>
);
return (
<PageContent headerContent="Configured addons">
<List>{addons.map(addon => renderAddon(addon))}</List>
<List>
{sortAddons(addons).map((addon: IAddon) => renderAddon(addon))}
</List>
<Dialogue
open={showDelete}
onClick={() => {
onRemoveAddon(deletedAddon);
setShowDelete(false);
}}
onClose={() => {
setShowDelete(false);
}}
title="Confirm deletion"
>
<div>Are you sure you want to delete this Addon?</div>
</Dialogue>
</PageContent>
);
};
ConfiguredAddons.propTypes = {
addons: PropTypes.array.isRequired,
hasAccess: PropTypes.func.isRequired,
toggleAddon: PropTypes.func.isRequired,
getIcon: PropTypes.func.isRequired,
};
export default ConfiguredAddons;

View File

@ -1,3 +0,0 @@
import ConfiguredAddons from './ConfiguredAddons';
export default ConfiguredAddons;

View File

@ -1,3 +0,0 @@
import AddonListComponent from './AddonList';
export default AddonListComponent;

View File

@ -0,0 +1,42 @@
import { useParams } from 'react-router-dom';
import useAddons from '../../../hooks/api/getters/useAddons/useAddons';
import { AddonForm } from '../AddonForm/AddonForm';
import cloneDeep from 'lodash.clonedeep';
interface IAddonCreateParams {
providerId: string;
}
const DEFAULT_DATA = {
provider: '',
description: '',
enabled: true,
parameters: {},
events: [],
};
export const CreateAddon = () => {
const { providerId } = useParams<IAddonCreateParams>();
const { providers, refetchAddons } = useAddons();
const editMode = false;
const provider = providers.find(
(providerItem: any) => providerItem.name === providerId
);
const defaultAddon = {
...cloneDeep(DEFAULT_DATA),
provider: provider ? provider.name : '',
};
return (
// @ts-expect-error
<AddonForm
editMode={editMode}
provider={provider}
fetch={refetchAddons}
addon={defaultAddon}
/>
);
};

View File

@ -0,0 +1,41 @@
import { useParams } from 'react-router-dom';
import useAddons from '../../../hooks/api/getters/useAddons/useAddons';
import { AddonForm } from '../AddonForm/AddonForm';
import cloneDeep from 'lodash.clonedeep';
import { IAddon } from '../../../interfaces/addons';
interface IAddonEditParams {
addonId: string;
}
const DEFAULT_DATA = {
provider: '',
description: '',
enabled: true,
parameters: {},
events: [],
};
export const EditAddon = () => {
const { addonId } = useParams<IAddonEditParams>();
const { providers, addons, refetchAddons } = useAddons();
const editMode = true;
const addon = addons.find(
(addon: IAddon) => addon.id === Number(addonId)
) || { ...cloneDeep(DEFAULT_DATA) };
const provider = addon
? providers.find(provider => provider.name === addon.provider)
: undefined;
return (
// @ts-expect-error
<AddonForm
editMode={editMode}
provider={provider}
fetch={refetchAddons}
addon={addon}
/>
);
};

View File

@ -1,17 +0,0 @@
.nameInput {
margin-right: 1.5rem;
}
.formContainer {
margin-bottom: 1.5rem;
max-width: 350px;
}
.formSection {
padding: 10px 28px;
}
.header {
font-size: var(--h1-size);
padding: var(--card-header-padding);
}

View File

@ -1,55 +0,0 @@
import { connect } from 'react-redux';
import FormComponent from './form-addon-component';
import { updateAddon, createAddon, fetchAddons } from '../../store/addons/actions';
import cloneDeep from 'lodash.clonedeep';
// Required for to fill the initial 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;

View File

@ -1,35 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Grid, FormControlLabel, Checkbox } from '@material-ui/core';
import { styles as commonStyles } from '../common';
const AddonEvents = ({ provider, checkedEvents, setEventValue, error }) => {
if (!provider) return null;
return (
<React.Fragment>
<h4>Events</h4>
<span className={commonStyles.error}>{error}</span>
<Grid container spacing={0}>
{provider.events.map(e => (
<Grid item xs={4} key={e}>
<FormControlLabel
control={<Checkbox checked={checkedEvents.includes(e)} onChange={setEventValue(e)} />}
label={e}
/>
</Grid>
))}
</Grid>
</React.Fragment>
);
};
AddonEvents.propTypes = {
provider: PropTypes.object,
checkedEvents: PropTypes.array.isRequired,
setEventValue: PropTypes.func.isRequired,
error: PropTypes.string,
};
export default AddonEvents;

View File

@ -1,86 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TextField } from '@material-ui/core';
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];
return (
<div style={{ width: '80%', marginTop: '25px' }}>
<TextField
size="small"
style={{ width: '100%' }}
rows={definition.type === 'textfield' ? 9 : 0}
multiline={definition.type === 'textfield'}
type={type}
label={definition.displayName}
name={definition.name}
placeholder={definition.placeholder || ''}
InputLabelProps={{
shrink: true,
}}
value={value}
error={error}
onChange={setParameterValue(definition.name)}
variant="outlined"
helperText={definition.description}
/>
</div>
);
};
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 (
<React.Fragment>
<h4>Parameters</h4>
{editMode ? (
<p>
Sensitive parameters will be masked with value "<i>*****</i>
". If you don't change the value they will not be updated when saving.
</p>
) : null}
{provider.parameters.map(p => (
<AddonParameter
key={p.name}
definition={p}
errors={errors}
config={config}
setParameterValue={setParameterValue}
/>
))}
</React.Fragment>
);
};
AddonParameters.propTypes = {
provider: PropTypes.object,
config: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
setParameterValue: PropTypes.func.isRequired,
editMode: PropTypes.bool,
};
export default AddonParameters;

View File

@ -1,30 +0,0 @@
import { connect } from 'react-redux';
import AddonsListComponent from './AddonList';
import { fetchAddons, removeAddon, updateAddon } from '../../store/addons/actions';
const mapStateToProps = state => {
const list = state.addons.toJS();
return {
addons: list.addons,
providers: list.providers,
};
};
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;

View File

@ -286,7 +286,7 @@ Array [
"layout": "main",
"menu": Object {},
"parent": "/addons",
"path": "/addons/create/:provider",
"path": "/addons/create/:providerId",
"title": "Create",
"type": "protected",
},
@ -295,7 +295,7 @@ Array [
"layout": "main",
"menu": Object {},
"parent": "/addons",
"path": "/addons/edit/:id",
"path": "/addons/edit/:addonId",
"title": "Edit",
"type": "protected",
},

View File

@ -9,9 +9,7 @@ import { ArchiveListContainer } from '../archive/ArchiveListContainer';
import Applications from '../../page/applications';
import ApplicationView from '../../page/applications/view';
import { TagTypeList } from '../tags/TagTypeList/TagTypeList';
import Addons from '../../page/addons';
import AddonsCreate from '../../page/addons/create';
import AddonsEdit from '../../page/addons/edit';
import { AddonList } from '../addons/AddonList/AddonList';
import Admin from '../admin';
import AdminApi from '../admin/api';
import AdminInvoice from '../admin/invoice/InvoiceAdminPage';
@ -45,6 +43,8 @@ import CreateFeature from '../feature/CreateFeature/CreateFeature';
import EditFeature from '../feature/EditFeature/EditFeature';
import ContextList from '../context/ContextList/ContextList';
import RedirectFeatureView from '../feature/RedirectFeatureView/RedirectFeatureView';
import { CreateAddon } from '../addons/CreateAddon/CreateAddon';
import { EditAddon } from '../addons/EditAddon/EditAddon';
export const routes = [
// Project
@ -322,19 +322,19 @@ export const routes = [
// Addons
{
path: '/addons/create/:provider',
path: '/addons/create/:providerId',
parent: '/addons',
title: 'Create',
component: AddonsCreate,
component: CreateAddon,
type: 'protected',
layout: 'main',
menu: {},
},
{
path: '/addons/edit/:id',
path: '/addons/edit/:addonId',
parent: '/addons',
title: 'Edit',
component: AddonsEdit,
component: EditAddon,
type: 'protected',
layout: 'main',
menu: {},
@ -342,7 +342,7 @@ export const routes = [
{
path: '/addons',
title: 'Addons',
component: Addons,
component: AddonList,
hidden: false,
type: 'protected',
layout: 'main',

View File

@ -1,4 +1,4 @@
import { IAddons } from '../../../../interfaces/addons';
import { IAddon } from '../../../../interfaces/addons';
import useAPI from '../useApi/useApi';
const useAddonsApi = () => {
@ -8,7 +8,7 @@ const useAddonsApi = () => {
const URI = 'api/admin/addons';
const createAddon = async (addonConfig: IAddons) => {
const createAddon = async (addonConfig: IAddon) => {
const path = URI;
const req = createRequest(path, {
method: 'POST',
@ -38,7 +38,7 @@ const useAddonsApi = () => {
}
};
const updateAddon = async (addonConfig: IAddons) => {
const updateAddon = async (addonConfig: IAddon) => {
const path = `${URI}/${addonConfig.id}`;
const req = createRequest(path, {
method: 'PUT',

View File

@ -1,4 +1,6 @@
export interface IAddons {
import { ITagType } from './tags';
export interface IAddon {
id: number;
provider: string;
description: string;
@ -6,3 +8,32 @@ export interface IAddons {
events: string[];
parameters: object;
}
export interface IAddonProvider {
description: string;
displayName: string;
documentationUrl: string;
events: string[];
name: string;
parameters: IAddonProviderParams[];
tagTypes: ITagType[];
}
export interface IAddonProviderParams {
name: string;
displayName: string;
type: string;
required: boolean;
sensitive: boolean;
placeholder?: string;
description?: string;
}
export interface IAddonConfig {
description: string;
enabled: boolean;
events: string[];
id: number;
parameters: Record<string, any>;
provider: string;
}

View File

@ -1,14 +0,0 @@
import React from 'react';
import AddonForm from '../../component/addons/form-addon-container';
import PropTypes from 'prop-types';
const render = ({ match: { params }, history }) => (
<AddonForm provider={params.provider} title="Configure addon" history={history} />
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -1,14 +0,0 @@
import React from 'react';
import AddonForm from '../../component/addons/form-addon-container';
import PropTypes from 'prop-types';
const render = ({ match: { params }, history }) => (
<AddonForm addonId={params.id} title="Edit addon" history={history} />
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -1,11 +0,0 @@
import React from 'react';
import Addons from '../../component/addons';
import PropTypes from 'prop-types';
const render = ({ history }) => <Addons history={history} />;
render.propTypes = {
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -1,69 +0,0 @@
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'],
},
],
};

View File

@ -1,189 +0,0 @@
// 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",
},
],
},
],
}
`;

View File

@ -1,113 +0,0 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import {
RECEIVE_ADDON_CONFIG,
ERROR_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].type).toEqual(ERROR_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);
});
});

View File

@ -1,54 +0,0 @@
import reducer from '../index';
import { RECEIVE_ADDON_CONFIG, ADD_ADDON_CONFIG, REMOVE_ADDON_CONFIG, UPDATE_ADDON_CONFIG } from '../actions';
import { addonSimple, addonsWithConfig, addonConfig } from '../__testdata__/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);
});

View File

@ -1,51 +0,0 @@
import api from './api';
import { dispatchError } 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(dispatchError(dispatch, ERROR_RECEIVE_ADDON_CONFIG));
}
export function removeAddon(addon) {
return dispatch =>
api
.remove(addon)
.then(() => dispatch(removeAddonconfig(addon)))
.catch(dispatchError(dispatch, ERROR_REMOVING_ADDON_CONFIG));
}
export function createAddon(addon) {
return dispatch =>
api
.create(addon)
.then(res => res.json())
.then(value => dispatch(addAddonConfig(value)))
.catch(dispatchError(dispatch, ERROR_ADD_ADDON_CONFIG));
}
export function updateAddon(addon) {
return dispatch =>
api
.update(addon)
.then(() => dispatch(updateAdddonConfig(addon)))
.catch(dispatchError(dispatch, ERROR_UPDATE_ADDON_CONFIG));
}

View File

@ -1,44 +0,0 @@
import { formatApiPath } from '../../utils/format-path';
import { throwIfNotSuccess, headers } from '../api-helper';
const URI = formatApiPath(`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);
}
const api = {
fetchAll,
create,
update,
remove,
};
export default api;

View File

@ -1,33 +0,0 @@
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;

View File

@ -22,12 +22,6 @@ import {
ERROR_UPDATE_PROJECT,
} from '../project/actions';
import {
ERROR_ADD_ADDON_CONFIG,
ERROR_UPDATE_ADDON_CONFIG,
ERROR_REMOVING_ADDON_CONFIG,
} from '../addons/actions';
import { UPDATE_APPLICATION_FIELD } from '../application/actions';
import { FORBIDDEN } from '../util';
@ -59,9 +53,6 @@ const strategies = (state = getInitState(), action) => {
case ERROR_RECEIVE_STRATEGIES:
case ERROR_REMOVING_PROJECT:
case ERROR_UPDATE_PROJECT:
case ERROR_ADD_ADDON_CONFIG:
case ERROR_UPDATE_ADDON_CONFIG:
case ERROR_REMOVING_ADDON_CONFIG:
case ERROR_ADD_PROJECT:
return addErrorIfNotAlreadyInList(state, action.error.message);
case FORBIDDEN:

View File

@ -5,7 +5,6 @@ import error from './error';
import user from './user';
import applications from './application';
import projects from './project';
import addons from './addons';
import apiCalls from './api-calls';
const unleashStore = combineReducers({
@ -15,7 +14,6 @@ const unleashStore = combineReducers({
user,
applications,
projects,
addons,
apiCalls,
});

View File

@ -2077,6 +2077,18 @@
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/lodash.clonedeep@^4.5.6":
version "4.5.6"
resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.6.tgz#3b6c40a0affe0799a2ce823b440a6cf33571d32b"
integrity sha512-cE1jYr2dEg1wBImvXlNtp0xDoS79rfEdGozQVgliDZj1uERH4k+rmEMTudP9b4VQ8O6nRb5gPqft0QzEQGMQgA==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.178"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8"
integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw==
"@types/minimatch@*":
version "3.0.4"
resolved "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz"