1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

Feat/environment crud (#335)

* feat: add env

* fix: create environment form

* feat: create environment

* feat: add deletion protection

* fix: lift up state

* feat: add ability to update environment

* fix: remove env reset

* fix: remove link

* feat: add drag and drop sorting

* fix: remove unused imports

* feat: add methods to toggle env on/off

* feat: only make api call on drop

* fix: disabled text

* fix: add disabled indicator

* fix: add edit env payload

* fix: add E flag

* fix: cleanup

* fix: update snapshots

* fix: remove useFeature

* fix: change property to errorText

* fix: update tests

* fix: change menu

* fix: update snapshots

* feat: toggle view v2

* fix: handle error on sort order api call

* fix: remove unused import

* fix: useFeature

* fix: update tests

* fix: console logs

* fix: use try catch

* fix: update snapshots
This commit is contained in:
Fredrik Strand Oseberg 2021-09-14 14:20:23 +02:00 committed by GitHub
parent b2a6201ce3
commit 92f3f8af08
43 changed files with 1905 additions and 13 deletions

View File

@ -24,3 +24,5 @@ export const DELETE_ADDON = 'DELETE_ADDON';
export const UPDATE_API_TOKEN = 'UPDATE_API_TOKEN';
export const CREATE_API_TOKEN = 'CREATE_API_TOKEN';
export const DELETE_API_TOKEN = 'DELETE_API_TOKEN';
export const DELETE_ENVIRONMENT = 'DELETE_ENVIRONMENT';
export const UPDATE_ENVIRONMENT = 'UPDATE_ENVIRONMENT';

View File

@ -0,0 +1,18 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
badge: {
backgroundColor: theme.palette.primary.main,
width: '75px',
height: '75px',
borderRadius: '50px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
check: {
color: '#fff',
width: '35px',
height: '35px',
},
}));

View File

@ -0,0 +1,13 @@
import { Check } from '@material-ui/icons';
import { useStyles } from './CheckMarkBadge.styles';
const CheckMarkBadge = () => {
const styles = useStyles();
return (
<div className={styles.badge}>
<Check className={styles.check} />
</div>
);
};
export default CheckMarkBadge;

View File

@ -13,7 +13,7 @@ const HeaderTitle = ({
subtitle,
variant,
loading,
className,
className = '',
}) => {
const styles = useStyles();
const headerClasses = classnames({ skeleton: loading });

View File

@ -0,0 +1,12 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
helperText: {
position: 'absolute',
top: '35px',
},
inputContainer: {
width: '100%',
position: 'relative',
},
}));

View File

@ -0,0 +1,53 @@
import { TextField } from '@material-ui/core';
import { useStyles } from './Input.styles.ts';
interface IInputProps {
label: string;
placeholder?: string;
error?: boolean;
errorText?: string;
style?: Object;
className?: string;
value: string;
onChange: (e: any) => any;
onFocus?: (e: any) => any;
onBlur?: (e: any) => any;
}
const Input = ({
label,
placeholder,
error,
errorText,
style,
className,
value,
onChange,
...rest
}: IInputProps) => {
const styles = useStyles();
return (
<div className={styles.inputContainer} data-loading>
<TextField
size="small"
variant="outlined"
label={label}
placeholder={placeholder}
error={error}
helperText={errorText}
style={style}
className={className ? className : ''}
value={value}
onChange={onChange}
FormHelperTextProps={{
classes: {
root: styles.helperText,
},
}}
{...rest}
/>
</div>
);
};
export default Input;

View File

@ -1,4 +1,5 @@
export const P = 'P';
export const C = 'C';
export const E = 'E';
export const RBAC = 'RBAC';
export const PROJECTFILTERING = false;

View File

@ -0,0 +1,31 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
helperText: { marginBottom: '1rem' },
formHeader: {
fontWeight: 'bold',
fontSize: '1rem',
marginTop: '2rem',
},
radioGroup: {
flexDirection: 'row',
},
environmentDetailsContainer: {
display: 'flex',
flexDirection: 'column',
margin: '1rem 0',
},
submitButton: {
marginTop: '1rem',
width: '150px',
marginRight: '1rem',
},
btnContainer: {
display: 'flex',
justifyContent: 'center',
},
inputField: {
width: '100%',
marginTop: '1rem',
},
}));

View File

@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { FormControl, Button } from '@material-ui/core';
import HeaderTitle from '../../common/HeaderTitle';
import PageContent from '../../common/PageContent';
import { useStyles } from './CreateEnvironment.styles';
import { useHistory } from 'react-router-dom';
import useEnvironmentApi from '../../../hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import ConditionallyRender from '../../common/ConditionallyRender';
import CreateEnvironmentSuccess from './CreateEnvironmentSuccess/CreateEnvironmentSuccess';
import useLoading from '../../../hooks/useLoading';
import useToast from '../../../hooks/useToast';
import EnvironmentTypeSelector from '../form/EnvironmentTypeSelector/EnvironmentTypeSelector';
import Input from '../../common/Input/Input';
const NAME_EXISTS_ERROR = 'Error: Environment';
const CreateEnvironment = () => {
const [type, setType] = useState('development');
const [envName, setEnvName] = useState('');
const [envDisplayName, setEnvDisplayName] = useState('');
const [nameError, setNameError] = useState('');
const [createSuccess, setCreateSucceess] = useState(false);
const history = useHistory();
const styles = useStyles();
const { validateEnvName, createEnvironment, loading } = useEnvironmentApi();
const ref = useLoading(loading);
const { toast, setToastData } = useToast();
const handleTypeChange = (event: React.FormEvent<HTMLInputElement>) => {
setType(event.currentTarget.value);
};
const handleEnvNameChange = (e: React.FormEvent<HTMLInputElement>) => {
setEnvName(e.currentTarget.value);
setEnvDisplayName(e.currentTarget.value);
};
const handleEnvDisplayName = (e: React.FormEvent<HTMLInputElement>) =>
setEnvDisplayName(e.currentTarget.value);
const goBack = () => history.goBack();
const validateEnvironmentName = async () => {
if (envName.length === 0) {
setNameError('Environment Id can not be empty.');
return false;
}
try {
await validateEnvName(envName);
} catch (e) {
if (e.toString().includes(NAME_EXISTS_ERROR)) {
setNameError('Name already exists');
}
return false;
}
return true;
};
const clearNameError = () => setNameError('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validName = await validateEnvironmentName();
if (validName) {
const environment = {
name: envName,
displayName: envDisplayName,
type,
};
try {
await createEnvironment(environment);
setCreateSucceess(true);
} catch (e) {
setToastData({ show: true, type: 'error', text: e.toString() });
}
}
};
return (
<PageContent headerContent={<HeaderTitle title="Create environment" />}>
<ConditionallyRender
condition={createSuccess}
show={
<CreateEnvironmentSuccess
name={envName}
displayName={envDisplayName}
type={type}
/>
}
elseShow={
<div ref={ref}>
<p className={styles.helperText} data-loading>
Environments allow you to manage your product
lifecycle from local development through production.
Your projects and feature toggles are accessible in
all your environments, but they can take different
configurations per environment. This means that you
can enable a feature toggle in a development or test
environment without enabling the feature toggle in
the production environment.
</p>
<form onSubmit={handleSubmit}>
<FormControl component="fieldset">
<h3 className={styles.formHeader} data-loading>
Environment Id and name
</h3>
<div
data-loading
className={
styles.environmentDetailsContainer
}
>
<p>
Unique env name for SDK configurations.
</p>
<Input
label="Environment Id"
onFocus={clearNameError}
placeholder="A unique name for your environment"
onBlur={validateEnvironmentName}
error={Boolean(nameError)}
errorText={nameError}
value={envName}
onChange={handleEnvNameChange}
className={styles.inputField}
/>
</div>
<div
data-loading
className={
styles.environmentDetailsContainer
}
>
<p>Environment display name.</p>
<Input
label="Display name"
placeholder="Optional name to be displayed in the admin panel"
className={styles.inputField}
value={envDisplayName}
onChange={handleEnvDisplayName}
/>
</div>
<EnvironmentTypeSelector
onChange={handleTypeChange}
value={type}
/>
</FormControl>
<div className={styles.btnContainer}>
<Button
className={styles.submitButton}
variant="contained"
color="primary"
type="submit"
data-loading
>
Submit
</Button>{' '}
<Button
className={styles.submitButton}
variant="outlined"
color="secondary"
onClick={goBack}
data-loading
>
Cancel
</Button>
</div>
</form>
</div>
}
/>
{toast}
</PageContent>
);
};
export default CreateEnvironment;

View File

@ -0,0 +1,41 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
subheader: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
marginTop: '2rem',
},
container: {
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
alignItems: 'center',
},
nextSteps: {
display: 'flex',
},
step: { maxWidth: '350px', margin: '0 1.5rem', position: 'relative' },
stepBadge: {
backgroundColor: theme.palette.primary.main,
width: '30px',
height: '30px',
borderRadius: '25px',
color: '#fff',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontWeight: 'bold',
margin: '2rem auto',
},
stepParagraph: {
marginBottom: '1rem',
},
button: {
marginTop: '2.5rem',
minWidth: '150px',
},
link: {
color: theme.palette.primary.main,
},
}));

View File

@ -0,0 +1,93 @@
import { Button } from '@material-ui/core';
import { useHistory } from 'react-router-dom';
import CheckMarkBadge from '../../../common/CheckmarkBadge/CheckMarkBadge';
import { useStyles } from './CreateEnvironmentSuccess.styles';
import CreateEnvironmentSuccessCard from './CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard';
export interface ICreateEnvironmentSuccessProps {
name: string;
displayName: string;
type: string;
}
const CreateEnvironmentSuccess = ({
name,
displayName,
type,
}: ICreateEnvironmentSuccessProps) => {
const history = useHistory();
const styles = useStyles();
const navigateToEnvironmentList = () => {
history.push('/environments');
};
return (
<div className={styles.container}>
<CheckMarkBadge />
<h2 className={styles.subheader}>Environment created</h2>
<CreateEnvironmentSuccessCard
name={name}
displayName={displayName}
type={type}
/>
<h2 className={styles.subheader}>Next steps</h2>
<div className={styles.nextSteps}>
<div className={styles.step}>
<div>
<div className={styles.stepBadge}>1</div>
<h3 className={styles.subheader}>
Update SDK version and provide the environment id to
the SDK
</h3>
<p className={styles.stepParagraph}>
By providing the environment id in the SDK the SDK
will only retrieve activation strategies for
specified environment
</p>
<a
href="https://docs.getunleash.io/"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
Learn more
</a>
</div>
</div>
<div className={styles.step}>
<div>
<div className={styles.stepBadge}>2</div>
<h3 className={styles.subheader}>
Add environment specific activation strategies
</h3>
<p className={styles.stepParagraph}>
You can now select this environment when you are
adding new activation strategies on feature toggles.
</p>
<a
href="https://docs.getunleash.io/"
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
Learn more
</a>
</div>
</div>
</div>
<Button
variant="contained"
color="primary"
className={styles.button}
onClick={navigateToEnvironmentList}
>
Got it!
</Button>
</div>
);
};
export default CreateEnvironmentSuccess;

View File

@ -0,0 +1,31 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
flexDirection: 'column',
border: `1px solid ${theme.palette.grey[200]}`,
padding: '1.5rem',
borderRadius: '5px',
margin: '1.5rem 0',
minWidth: '450px',
},
icon: {
fill: theme.palette.grey[600],
marginRight: '0.5rem',
},
header: {
display: 'flex',
alignItems: 'center',
marginBottom: '0.25rem',
},
infoContainer: {
marginTop: '1rem',
display: 'flex',
justifyContent: 'space-between',
},
infoInnerContainer: {
textAlign: 'center',
},
infoTitle: { fontWeight: 'bold', marginBottom: '0.25rem' },
}));

View File

@ -0,0 +1,35 @@
import { CloudCircle } from '@material-ui/icons';
import { ICreateEnvironmentSuccessProps } from '../CreateEnvironmentSuccess';
import { useStyles } from './CreateEnvironmentSuccessCard.styles';
const CreateEnvironmentSuccessCard = ({
name,
displayName,
type,
}: ICreateEnvironmentSuccessProps) => {
const styles = useStyles();
return (
<div className={styles.container}>
<div className={styles.header}>
<CloudCircle className={styles.icon} /> Environment
</div>
<div className={styles.infoContainer}>
<div className={styles.infoInnerContainer}>
<div className={styles.infoTitle}>Id</div>
<div>{name}</div>
</div>
<div className={styles.infoInnerContainer}>
<div className={styles.infoTitle}>Displayname</div>
<div>{displayName}</div>
</div>
<div className={styles.infoInnerContainer}>
<div className={styles.infoTitle}>Type</div>
<div>{type}</div>
</div>
</div>
</div>
);
};
export default CreateEnvironmentSuccessCard;

View File

@ -0,0 +1,54 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
minWidth: '300px',
position: 'absolute',
right: '80px',
bottom: '-475px',
zIndex: 9999,
opacity: 0,
transform: 'translateY(100px)',
},
inputField: {
width: '100%',
},
header: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
padding: '1rem',
},
body: { padding: '1rem' },
subheader: {
display: 'flex',
alignItems: 'center',
fontSize: theme.fontSizes.bodySize,
fontWeight: 'normal',
},
icon: {
marginRight: '0.5rem',
fill: theme.palette.grey[600],
},
formHeader: {
fontSize: theme.fontSizes.bodySize,
},
buttonGroup: {
marginTop: '2rem',
display: 'flex',
justifyContent: 'space-between',
},
editEnvButton: {
width: '150px',
},
fadeInBottomEnter: {
transform: 'translateY(0)',
opacity: '1',
transition: 'transform 0.4s ease, opacity .4s ease',
},
fadeInBottomLeave: {
transform: 'translateY(100px)',
opacity: '0',
transition: 'transform 0.4s ease, opacity 0.4s ease',
},
}));

View File

@ -0,0 +1,130 @@
import { CloudCircle } from '@material-ui/icons';
import { useEffect, useState } from 'react';
import EnvironmentTypeSelector from '../form/EnvironmentTypeSelector/EnvironmentTypeSelector';
import { useStyles } from './EditEnvironment.styles';
import { IEnvironment } from '../../../interfaces/environments';
import Input from '../../common/Input/Input';
import useEnvironmentApi from '../../../hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import useLoading from '../../../hooks/useLoading';
import useEnvironments from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import Dialogue from '../../common/Dialogue';
interface IEditEnvironmentProps {
env: IEnvironment;
setEditEnvironment: React.Dispatch<React.SetStateAction<boolean>>;
editEnvironment: boolean;
setToastData: React.Dispatch<React.SetStateAction<any>>;
}
const EditEnvironment = ({
env,
setEditEnvironment,
editEnvironment,
setToastData,
}: IEditEnvironmentProps) => {
const styles = useStyles();
const [type, setType] = useState(env.type);
const [envDisplayName, setEnvDisplayName] = useState(env.displayName);
const { updateEnvironment, loading } = useEnvironmentApi();
const { refetch } = useEnvironments();
const ref = useLoading(loading);
useEffect(() => {
setType(env.type);
setEnvDisplayName(env.displayName);
}, [env.type, env.displayName]);
const handleTypeChange = (event: React.FormEvent<HTMLInputElement>) => {
setType(event.currentTarget.value);
};
const handleEnvDisplayName = (e: React.FormEvent<HTMLInputElement>) =>
setEnvDisplayName(e.currentTarget.value);
const isDisabled = () => {
if (type === env.type && envDisplayName === env.displayName) {
return true;
}
return false;
};
const handleCancel = () => {
setEditEnvironment(false);
resetFields();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const updatedEnv = {
sortOrder: env.sortOrder,
displayName: envDisplayName,
type,
};
try {
await updateEnvironment(env.name, updatedEnv);
setToastData({
type: 'success',
show: true,
text: 'Successfully updated environment.',
});
resetFields();
refetch();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
} finally {
setEditEnvironment(false);
}
};
const resetFields = () => {
setType(env.type);
setEnvDisplayName(env.displayName);
};
return (
<Dialogue
open={editEnvironment}
title="Edit environment"
onClose={handleCancel}
onClick={handleSubmit}
primaryButtonText="Save"
secondaryButtonText="Cancel"
disabledPrimaryButton={isDisabled()}
>
<div className={styles.body} ref={ref}>
<h3 className={styles.formHeader} data-loading>
Environment Id
</h3>
<h3 className={styles.subheader} data-loading>
<CloudCircle className={styles.icon} /> {env.name}
</h3>
<form>
<EnvironmentTypeSelector
onChange={handleTypeChange}
value={type}
/>
<h3 className={styles.formHeader} data-loading>
Environment display name
</h3>
<Input
label="Display name"
placeholder="Optional name to be displayed in the admin panel"
className={styles.inputField}
value={envDisplayName}
onChange={handleEnvDisplayName}
data-loading
/>
</form>
</div>
</Dialogue>
);
};
export default EditEnvironment;

View File

@ -0,0 +1,10 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
deleteParagraph: {
marginTop: '2rem',
},
environmentDeleteInput: {
marginTop: '1rem',
},
}));

View File

@ -0,0 +1,73 @@
import { Alert } from '@material-ui/lab';
import React from 'react';
import { IEnvironment } from '../../../../interfaces/environments';
import Dialogue from '../../../common/Dialogue';
import Input from '../../../common/Input/Input';
import CreateEnvironmentSuccessCard from '../../CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard';
import { useStyles } from './EnvironmentDeleteConfirm.styles';
interface IEnviromentDeleteConfirmProps {
env: IEnvironment;
open: boolean;
setSelectedEnv: React.Dispatch<React.SetStateAction<IEnvironment>>;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteEnvironment: (name: string) => Promise<void>;
confirmName: string;
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
}
const EnvironmentDeleteConfirm = ({
env,
open,
setDeldialogue,
handleDeleteEnvironment,
confirmName,
setConfirmName,
}: IEnviromentDeleteConfirmProps) => {
const styles = useStyles();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmName(e.currentTarget.value);
const handleCancel = () => {
setDeldialogue(false);
setConfirmName('');
};
return (
<Dialogue
title="Are you sure you want to delete this environment?"
open={open}
primaryButtonText="Delete environment"
secondaryButtonText="Cancel"
onClick={handleDeleteEnvironment}
disabledPrimaryButton={env?.name !== confirmName}
onClose={handleCancel}
>
<Alert severity="error">
Danger. Deleting this environment will result in removing all
strategies that are active in this environment across all
feature toggles.
</Alert>
<CreateEnvironmentSuccessCard
name={env?.name}
displayName={env?.displayName}
type={env?.type}
/>
<p className={styles.deleteParagraph}>
In order to delete this environment, please enter the id of the
environment in the textfield below: <strong>{env?.name}</strong>
</p>
<Input
onChange={handleChange}
value={confirmName}
label="Environment name"
className={styles.environmentDeleteInput}
/>
</Dialogue>
);
};
export default EnvironmentDeleteConfirm;

View File

@ -0,0 +1,233 @@
import HeaderTitle from '../../common/HeaderTitle';
import ResponsiveButton from '../../common/ResponsiveButton/ResponsiveButton';
import { Add } from '@material-ui/icons';
import PageContent from '../../common/PageContent';
import { List } from '@material-ui/core';
import useEnvironments, {
ENVIRONMENT_CACHE_KEY,
} from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import {
IEnvironment,
ISortOrderPayload,
} from '../../../interfaces/environments';
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import EnvironmentDeleteConfirm from './EnvironmentDeleteConfirm/EnvironmentDeleteConfirm';
import useToast from '../../../hooks/useToast';
import useEnvironmentApi from '../../../hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import EnvironmentListItem from './EnvironmentListItem/EnvironmentListItem';
import { mutate } from 'swr';
import EditEnvironment from '../EditEnvironment/EditEnvironment';
import EnvironmentToggleConfirm from './EnvironmentToggleConfirm/EnvironmentToggleConfirm';
const EnvironmentList = () => {
const defaultEnv = {
name: '',
type: '',
displayName: '',
sortOrder: 0,
createdAt: '',
enabled: true,
protected: false,
};
const { environments, refetch } = useEnvironments();
const [editEnvironment, setEditEnvironment] = useState(false);
const [selectedEnv, setSelectedEnv] = useState(defaultEnv);
const [delDialog, setDeldialogue] = useState(false);
const [toggleDialog, setToggleDialog] = useState(false);
const [confirmName, setConfirmName] = useState('');
const history = useHistory();
const { toast, setToastData } = useToast();
const {
deleteEnvironment,
changeSortOrder,
toggleEnvironmentOn,
toggleEnvironmentOff,
} = useEnvironmentApi();
const moveListItem = (dragIndex: number, hoverIndex: number) => {
const newEnvList = [...environments];
if (newEnvList.length === 0) return newEnvList;
const item = newEnvList.splice(dragIndex, 1)[0];
newEnvList.splice(hoverIndex, 0, item);
mutate(ENVIRONMENT_CACHE_KEY, { environments: newEnvList }, false);
return newEnvList;
};
const moveListItemApi = async (dragIndex: number, hoverIndex: number) => {
const newEnvList = moveListItem(dragIndex, hoverIndex);
const sortOrder = newEnvList.reduce(
(acc: ISortOrderPayload, env: IEnvironment, index: number) => {
acc[env.name] = index + 1;
return acc;
},
{}
);
try {
await sortOrderAPICall(sortOrder);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
mutate(ENVIRONMENT_CACHE_KEY);
};
const sortOrderAPICall = async (sortOrder: ISortOrderPayload) => {
try {
changeSortOrder(sortOrder);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
const handleDeleteEnvironment = async () => {
try {
await deleteEnvironment(selectedEnv.name);
setToastData({
show: true,
type: 'success',
text: 'Successfully deleted environment.',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
} finally {
setDeldialogue(false);
setSelectedEnv(defaultEnv);
setConfirmName('');
refetch();
}
};
const handleConfirmToggleEnvironment = () => {
if (selectedEnv.enabled) {
return handleToggleEnvironmentOff();
}
handleToggleEnvironmentOn();
};
const handleToggleEnvironmentOn = async () => {
try {
await toggleEnvironmentOn(selectedEnv.name);
setToggleDialog(false);
setToastData({
show: true,
type: 'success',
text: 'Successfully enabled environment.',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
} finally {
refetch();
}
};
const handleToggleEnvironmentOff = async () => {
try {
await toggleEnvironmentOff(selectedEnv.name);
setToggleDialog(false);
setToastData({
show: true,
type: 'success',
text: 'Successfully disabled environment.',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
} finally {
refetch();
}
};
const environmentList = () =>
environments.map((env: IEnvironment, index: number) => (
<EnvironmentListItem
key={env.name}
env={env}
setEditEnvironment={setEditEnvironment}
setDeldialogue={setDeldialogue}
setSelectedEnv={setSelectedEnv}
setToggleDialog={setToggleDialog}
index={index}
moveListItem={moveListItem}
moveListItemApi={moveListItemApi}
/>
));
const navigateToCreateEnvironment = () => {
history.push('/environments/create');
};
return (
<PageContent
headerContent={
<HeaderTitle
title="Environments"
actions={
<>
<ResponsiveButton
onClick={navigateToCreateEnvironment}
maxWidth="700px"
tooltip="Add environment"
Icon={Add}
>
Add Environment
</ResponsiveButton>
</>
}
/>
}
>
<List>{environmentList()}</List>
<EnvironmentDeleteConfirm
env={selectedEnv}
setSelectedEnv={setSelectedEnv}
setDeldialogue={setDeldialogue}
open={delDialog}
handleDeleteEnvironment={handleDeleteEnvironment}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
<EditEnvironment
env={selectedEnv}
setEditEnvironment={setEditEnvironment}
editEnvironment={editEnvironment}
setToastData={setToastData}
/>
<EnvironmentToggleConfirm
env={selectedEnv}
open={toggleDialog}
setToggleDialog={setToggleDialog}
handleConfirmToggleEnvironment={handleConfirmToggleEnvironment}
/>
{toast}
</PageContent>
);
};
export default EnvironmentList;

View File

@ -0,0 +1,213 @@
import {
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
IconButton,
} from '@material-ui/core';
import {
CloudCircle,
Delete,
DragIndicator,
Edit,
OfflineBolt,
} from '@material-ui/icons';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { IEnvironment } from '../../../../interfaces/environments';
import React, { useContext, useRef } from 'react';
import AccessContext from '../../../../contexts/AccessContext';
import {
DELETE_ENVIRONMENT,
UPDATE_ENVIRONMENT,
} from '../../../AccessProvider/permissions';
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd';
import { XYCoord } from 'dnd-core';
interface IEnvironmentListItemProps {
env: IEnvironment;
setSelectedEnv: React.Dispatch<React.SetStateAction<IEnvironment>>;
setDeldialogue: React.Dispatch<React.SetStateAction<boolean>>;
setEditEnvironment: React.Dispatch<React.SetStateAction<boolean>>;
setToggleDialog: React.Dispatch<React.SetStateAction<boolean>>;
index: number;
moveListItem: (dragIndex: number, hoverIndex: number) => IEnvironment[];
moveListItemApi: (dragIndex: number, hoverIndex: number) => Promise<void>;
}
interface DragItem {
index: number;
id: string;
type: string;
}
const EnvironmentListItem = ({
env,
setSelectedEnv,
setDeldialogue,
index,
moveListItem,
moveListItemApi,
setToggleDialog,
setEditEnvironment,
}: IEnvironmentListItemProps) => {
const ref = useRef<HTMLDivElement>(null);
const ACCEPT_TYPE = 'LIST_ITEM';
const [{ isDragging }, drag] = useDrag({
type: ACCEPT_TYPE,
item: () => {
return { env, index };
},
collect: (monitor: any) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ handlerId }, drop] = useDrop({
accept: ACCEPT_TYPE,
collect(monitor) {
return {
handlerId: monitor.getHandlerId(),
};
},
drop(item: DragItem, monitor: DropTargetMonitor) {
const dragIndex = item.index;
const hoverIndex = index;
moveListItemApi(dragIndex, hoverIndex);
},
hover(item: DragItem, monitor: DropTargetMonitor) {
if (!ref.current) {
return;
}
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) {
return;
}
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
const hoverClientY =
(clientOffset as XYCoord).y - hoverBoundingRect.top;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return;
}
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return;
}
moveListItem(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const opacity = isDragging ? 0 : 1;
drag(drop(ref));
const { hasAccess } = useContext(AccessContext);
const tooltipText = env.enabled ? 'Disable' : 'Enable';
return (
<ListItem
style={{ position: 'relative', opacity }}
ref={ref}
data-handler-id={handlerId}
>
<ListItemIcon>
<CloudCircle />
</ListItemIcon>
<ListItemText
primary={
<>
<strong>{env.name}</strong>
<ConditionallyRender
condition={!env.enabled}
show={
<span
style={{
padding: '0.2rem',
borderRadius: '5px',
marginLeft: '0.5rem',
backgroundColor: '#000',
color: '#fff',
fontWeight: 'bold',
}}
>
disabled
</span>
}
/>
</>
}
secondary={env.displayName}
/>
<Tooltip title="Drag to reorder">
<IconButton>
<DragIndicator />
</IconButton>
</Tooltip>
<ConditionallyRender
condition={hasAccess(UPDATE_ENVIRONMENT)}
show={
<Tooltip title={`${tooltipText} environment`}>
<IconButton
aria-label="disable"
disabled={env.protected}
onClick={() => {
setSelectedEnv(env);
setToggleDialog(prev => !prev);
}}
>
<OfflineBolt />
</IconButton>
</Tooltip>
}
/>
<ConditionallyRender
condition={hasAccess(UPDATE_ENVIRONMENT)}
show={
<Tooltip title="Update environment">
<IconButton
aria-label="update"
disabled={env.protected}
onClick={() => {
setSelectedEnv(env);
setEditEnvironment(prev => !prev);
}}
>
<Edit />
</IconButton>
</Tooltip>
}
/>
<ConditionallyRender
condition={hasAccess(DELETE_ENVIRONMENT)}
show={
<Tooltip title="Delete environment">
<IconButton
aria-label="delete"
disabled={env.protected}
onClick={() => {
setDeldialogue(true);
setSelectedEnv(env);
}}
>
<Delete />
</IconButton>
</Tooltip>
}
/>
</ListItem>
);
};
export default EnvironmentListItem;

View File

@ -0,0 +1,64 @@
import { capitalize } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { IEnvironment } from '../../../../interfaces/environments';
import ConditionallyRender from '../../../common/ConditionallyRender';
import Dialogue from '../../../common/Dialogue';
import CreateEnvironmentSuccessCard from '../../CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard';
interface IEnvironmentToggleConfirmProps {
env: IEnvironment;
open: boolean;
setToggleDialog: React.Dispatch<React.SetStateAction<boolean>>;
handleConfirmToggleEnvironment: () => void;
}
const EnvironmentToggleConfirm = ({
env,
open,
setToggleDialog,
handleConfirmToggleEnvironment,
}: IEnvironmentToggleConfirmProps) => {
let text = env.enabled ? 'disable' : 'enable';
const handleCancel = () => {
setToggleDialog(false);
};
return (
<Dialogue
title={`Are you sure you want to ${text} this environment?`}
open={open}
primaryButtonText={capitalize(text)}
secondaryButtonText="Cancel"
onClick={handleConfirmToggleEnvironment}
onClose={handleCancel}
>
<ConditionallyRender
condition={env.enabled}
show={
<Alert severity="info">
Disabling an environment will not effect any strategies
that already exist in that environment, but it will make
it unavailable as a selection option for new activation
strategies.
</Alert>
}
elseShow={
<Alert severity="info">
Enabling an environment will allow you to add new
activation strategies to this environment.
</Alert>
}
/>
<CreateEnvironmentSuccessCard
name={env?.name}
displayName={env?.displayName}
type={env?.type}
/>
</Dialogue>
);
};
export default EnvironmentToggleConfirm;

View File

@ -0,0 +1,14 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
radioGroup: {
flexDirection: 'row',
},
formHeader: {
fontWeight: 'bold',
fontSize: theme.fontSizes.bodySize,
marginTop: '1.5rem',
marginBottom: '0.5rem',
},
radioBtnGroup: { display: 'flex', flexDirection: 'column' },
}));

View File

@ -0,0 +1,60 @@
import {
FormControl,
FormControlLabel,
RadioGroup,
Radio,
} from '@material-ui/core';
import { useStyles } from './EnvironmentTypeSelector.styles';
interface IEnvironmentTypeSelectorProps {
onChange: (event: React.FormEvent<HTMLInputElement>) => void;
value: string;
}
const EnvironmentTypeSelector = ({
onChange,
value,
}: IEnvironmentTypeSelectorProps) => {
const styles = useStyles();
return (
<FormControl component="fieldset">
<h3 className={styles.formHeader} data-loading>
Environment Type
</h3>
<RadioGroup
data-loading
value={value}
onChange={onChange}
className={styles.radioGroup}
>
<div className={styles.radioBtnGroup}>
<FormControlLabel
value="development"
label="Development"
control={<Radio />}
/>
<FormControlLabel
value="test"
label="Test"
control={<Radio />}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<FormControlLabel
value="preproduction"
label="Pre production"
control={<Radio />}
/>
<FormControlLabel
value="production"
label="Production"
control={<Radio />}
/>
</div>
</RadioGroup>
</FormControl>
);
};
export default EnvironmentTypeSelector;

View File

@ -0,0 +1,20 @@
import { useParams } from 'react-router-dom';
import useFeature from '../../../hooks/api/getters/useFeature/useFeature';
import FeatureViewEnvironment from './FeatureViewEnvironment/FeatureViewEnvironment';
import FeatureViewMetaData from './FeatureViewMetaData/FeatureViewMetaData';
const FeatureView2 = () => {
const { projectId, featureId } = useParams();
const { feature } = useFeature(projectId, featureId);
return (
<div style={{ display: 'flex', width: '100%' }}>
<FeatureViewMetaData />
{feature.environments.map(env => {
return <FeatureViewEnvironment env={env} key={env.name} />;
})}
</div>
);
};
export default FeatureView2;

View File

@ -0,0 +1,12 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
environmentContainer: {
alignItems: 'center',
width: '100%',
borderRadius: '5px',
backgroundColor: '#fff',
display: 'flex',
padding: '1.5rem',
},
}));

View File

@ -0,0 +1,16 @@
import { Switch } from '@material-ui/core';
import { useStyles } from './FeatureViewEnvironment.styles';
const FeatureViewEnvironment = ({ env }: any) => {
const styles = useStyles();
return (
<div style={{ width: '100%' }}>
<div className={styles.environmentContainer}>
<Switch value={env.enabled} checked={env.enabled} /> Toggle in{' '}
{env.name} is {env.enabled ? 'enabled' : 'disabled'}
</div>
</div>
);
};
export default FeatureViewEnvironment;

View File

@ -0,0 +1,58 @@
import { capitalize, IconButton } from '@material-ui/core';
import classnames from 'classnames';
import { useParams } from 'react-router-dom';
import { useCommonStyles } from '../../../../common.styles';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
import { getFeatureTypeIcons } from '../../../../utils/get-feature-type-icons';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { useStyles } from './FeatureViewMetadata.styles';
import { Edit } from '@material-ui/icons';
const FeatureViewMetaData = () => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const { projectId, featureId } = useParams();
const { feature } = useFeature(projectId, featureId);
const { project, description, type } = feature;
const IconComponent = getFeatureTypeIcons(type);
return (
<div
className={classnames(
styles.container,
commonStyles.contentSpacingY
)}
>
<span className={styles.metaDataHeader}>
<IconComponent className={styles.headerIcon} />{' '}
{capitalize(type || '')} toggle
</span>
<span>Project: {project}</span>
<ConditionallyRender
condition
show={
<span>
Description: {description}{' '}
<IconButton>
<Edit />
</IconButton>
</span>
}
elseShow={
<span>
No description.{' '}
<IconButton>
<Edit />
</IconButton>
</span>
}
/>
</div>
);
};
export default FeatureViewMetaData;

View File

@ -0,0 +1,22 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
borderRadius: '5px',
backgroundColor: '#fff',
display: 'flex',
flexDirection: 'column',
padding: '1.5rem',
maxWidth: '350px',
minWidth: '350px',
marginRight: '1rem',
},
metaDataHeader: {
display: 'flex',
alignItems: 'center',
},
headerIcon: {
marginRight: '1rem',
fill: theme.palette.primary.main,
},
}));

View File

@ -38,7 +38,7 @@ const MainLayout = ({ children, location, uiConfig }) => {
<Grid item className={styles.content} xs={12} sm={12}>
<div
className={muiStyles.contentContainer}
style={{ zIndex: '100' }}
style={{ zIndex: '200' }}
>
<BreadcrumbNav />
<Proclamation toast={uiConfig.toast} />

View File

@ -3,7 +3,7 @@ import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
footer: {
background: theme.palette.footer.background,
padding: '2.5rem 4rem',
padding: '2rem 4rem',
width: '100%',
flexGrow: 1,
zIndex: 100,

View File

@ -96,6 +96,7 @@ const Header = () => {
show={<Link to="/projects">Projects</Link>}
/>
<Link to="/features">Feature toggles</Link>
<Link to="/reporting">Reporting</Link>
<button
className={styles.advancedNavButton}
@ -103,7 +104,7 @@ const Header = () => {
setAnchorElAdvanced(e.currentTarget)
}
>
Advanced
Configure
<KeyboardArrowDown />
</button>
<NavigationMenu

View File

@ -50,6 +50,27 @@ Array [
"title": "Strategies",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"menu": Object {},
"parent": "/environments",
"path": "/environments/create",
"title": "Environments",
"type": "protected",
},
Object {
"component": [Function],
"flag": "E",
"layout": "main",
"menu": Object {
"advanced": true,
"mobile": true,
},
"path": "/environments",
"title": "Environments",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
@ -181,6 +202,16 @@ Array [
"title": "Copy",
"type": "protected",
},
Object {
"component": [Function],
"flags": "E",
"layout": "main",
"menu": Object {},
"parent": "/projects",
"path": "/projects/:projectId/features2/:featureId",
"title": "FeatureView2",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
@ -292,7 +323,6 @@ Array [
"component": [Function],
"layout": "main",
"menu": Object {
"advanced": true,
"mobile": true,
},
"path": "/reporting",

View File

@ -1,7 +1,7 @@
import { baseRoutes, getRoute } from '../routes';
test('returns all baseRoutes', () => {
expect(baseRoutes).toHaveLength(35);
expect(baseRoutes).toHaveLength(38);
expect(baseRoutes).toMatchSnapshot();
});

View File

@ -32,7 +32,7 @@ import AdminInvoice from '../../page/admin/invoice';
import AdminAuth from '../../page/admin/auth';
import Reporting from '../../page/reporting';
import Login from '../user/Login';
import { P, C } from '../common/flags';
import { P, C, E } from '../common/flags';
import NewUser from '../user/NewUser';
import ResetPassword from '../user/ResetPassword/ResetPassword';
import ForgottenPassword from '../user/ForgottenPassword/ForgottenPassword';
@ -40,6 +40,9 @@ import ProjectListNew from '../project/ProjectList/ProjectList';
import Project from '../project/Project/Project';
import RedirectFeatureViewPage from '../../page/features/redirect';
import RedirectArchive from '../feature/RedirectArchive/RedirectArchive';
import EnvironmentList from '../environments/EnvironmentList/EnvironmentList';
import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment';
import FeatureView2 from '../feature/FeatureView2/FeatureView2';
export const routes = [
// Features
@ -88,6 +91,24 @@ export const routes = [
layout: 'main',
menu: { mobile: true, advanced: true },
},
{
path: '/environments/create',
title: 'Environments',
component: CreateEnvironment,
parent: '/environments',
type: 'protected',
layout: 'main',
menu: {},
},
{
path: '/environments',
title: 'Environments',
component: EnvironmentList,
type: 'protected',
layout: 'main',
flag: E,
menu: { mobile: true, advanced: true },
},
// History
{
@ -221,6 +242,16 @@ export const routes = [
layout: 'main',
menu: {},
},
{
path: '/projects/:projectId/features2/:featureId',
parent: '/projects',
title: 'FeatureView2',
component: FeatureView2,
type: 'protected',
layout: 'main',
flags: E,
menu: {},
},
{
path: '/projects/:id/features/:name/:activeTab',
parent: '/projects',
@ -339,7 +370,7 @@ export const routes = [
component: Reporting,
type: 'protected',
layout: 'main',
menu: { mobile: true, advanced: true },
menu: { mobile: true },
},
// Admin
{
@ -427,8 +458,7 @@ export const routes = [
export const getRoute = path => routes.find(route => route.path === path);
export const baseRoutes = routes
.filter(route => !route.hidden)
export const baseRoutes = routes.filter(route => !route.hidden);
const computeRoutes = () => {
const mainNavRoutes = baseRoutes.filter(route => route.menu.advanced);

View File

@ -55,9 +55,12 @@ const useAPI = ({
const makeRequest = async (
apiCaller: any,
requestId?: string
requestId?: string,
loading: boolean = true
): Promise<Response> => {
setLoading(true);
if (loading) {
setLoading(true);
}
try {
const res = await apiCaller();
setLoading(false);

View File

@ -0,0 +1,148 @@
import {
IEnvironmentPayload,
ISortOrderPayload,
IEnvironmentEditPayload,
} from '../../../../interfaces/environments';
import useAPI from '../useApi/useApi';
const useEnvironmentApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const validateEnvName = async (envName: string) => {
const path = `api/admin/environments/validate`;
const req = createRequest(
path,
{ method: 'POST', body: JSON.stringify({ name: envName }) },
'validateEnvName'
);
try {
const res = await makeRequest(req.caller, req.id, false);
return res;
} catch (e) {
throw e;
}
};
const createEnvironment = async (payload: IEnvironmentPayload) => {
const path = `api/admin/environments`;
const req = createRequest(
path,
{ method: 'POST', body: JSON.stringify(payload) },
'createEnvironment'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteEnvironment = async (name: string) => {
const path = `api/admin/environments/${name}`;
const req = createRequest(
path,
{ method: 'DELETE' },
'deleteEnvironment'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const updateEnvironment = async (
name: string,
payload: IEnvironmentEditPayload
) => {
const path = `api/admin/environments/update/${name}`;
const req = createRequest(
path,
{ method: 'PUT', body: JSON.stringify(payload) },
'updateEnvironment'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const changeSortOrder = async (payload: ISortOrderPayload) => {
const path = `api/admin/environments/sort-order`;
const req = createRequest(
path,
{ method: 'PUT', body: JSON.stringify(payload) },
'changeSortOrder'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const toggleEnvironmentOn = async (name: string) => {
const path = `api/admin/environments/${name}/on`;
const req = createRequest(
path,
{ method: 'POST' },
'toggleEnvironmentOn'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const toggleEnvironmentOff = async (name: string) => {
const path = `api/admin/environments/${name}/off`;
const req = createRequest(
path,
{ method: 'POST' },
'toggleEnvironmentOff'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
validateEnvName,
createEnvironment,
errors,
loading,
deleteEnvironment,
updateEnvironment,
changeSortOrder,
toggleEnvironmentOff,
toggleEnvironmentOn,
};
};
export default useEnvironmentApi;

View File

@ -0,0 +1,38 @@
import useSWR, { mutate } from 'swr';
import { useState, useEffect } from 'react';
import { IEnvironmentResponse } from '../../../../interfaces/environments';
import { formatApiPath } from '../../../../utils/format-path';
export const ENVIRONMENT_CACHE_KEY = `api/admin/environments`;
const useEnvironments = () => {
const fetcher = () => {
const path = formatApiPath(`api/admin/environments`);
return fetch(path, {
method: 'GET',
}).then(res => res.json());
};
const { data, error } = useSWR<IEnvironmentResponse>(
ENVIRONMENT_CACHE_KEY,
fetcher
);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(ENVIRONMENT_CACHE_KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
environments: data?.environments || [],
error,
loading,
refetch,
};
};
export default useEnvironments;

View File

@ -0,0 +1,14 @@
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
export const defaultFeature: IFeatureToggle = {
environments: [],
name: '',
type: '',
stale: false,
archived: false,
createdAt: '',
lastSeenAt: '',
project: '',
variants: [],
description: '',
};

View File

@ -0,0 +1,46 @@
import useSWR, { mutate } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import { IFeatureToggle } from '../../../../interfaces/featureToggle';
import { defaultFeature } from './defaultFeature';
const useFeature = (projectId: string, id: string) => {
const fetcher = () => {
const path = formatApiPath(
`api/admin/projects/${projectId}/features/${id}`
);
return fetch(path, {
method: 'GET',
}).then(res => res.json());
};
const KEY = `api/admin/projects/${projectId}/features/${id}`;
const { data, error } = useSWR<IFeatureToggle>(KEY, fetcher);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
let feature = defaultFeature;
if (data) {
if (data.environments) {
feature = data;
}
}
return {
feature,
error,
loading,
refetch,
};
};
export default useFeature;

View File

@ -5,7 +5,7 @@ export const defaultValue = {
version: '3.x',
environment: '',
slogan: 'The enterprise ready feature toggle service.',
flags: { P: false, C: false },
flags: { P: false, C: false, E: false },
links: [
{
value: 'Documentation',

View File

@ -0,0 +1,29 @@
export interface IEnvironment {
name: string;
type: string;
createdAt: string;
displayName: string;
sortOrder: number;
enabled: boolean;
protected: boolean;
}
export interface IEnvironmentPayload {
name: string;
displayName: string;
type: string;
}
export interface IEnvironmentEditPayload {
sortOrder: number;
displayName: string;
type: string;
}
export interface IEnvironmentResponse {
environments: IEnvironment[];
}
export interface ISortOrderPayload {
[index: string]: number;
}

View File

@ -1,3 +1,5 @@
import { IStrategy } from './strategy';
export interface IFeatureToggleListItem {
type: string;
name: string;
@ -9,3 +11,41 @@ export interface IEnvironments {
displayName: string;
enabled: boolean;
}
export interface IFeatureToggle {
stale: boolean;
archived: boolean;
createdAt: string;
lastSeenAt: string;
description: string;
environments: IFeatureEnvironment[];
name: string;
project: string;
type: string;
variants: IFeatureVariant[];
}
export interface IFeatureEnvironment {
name: string;
enabled: boolean;
strategies: IStrategy[];
}
export interface IFeatureVariant {
name: string;
stickiness: string;
weight: number;
weightType: string;
overrides: IOverride[];
payload?: IPayload;
}
export interface IOverride {
contextName: string;
values: string[];
}
export interface IPayload {
name: string;
value: string;
}

View File

@ -0,0 +1,19 @@
export interface IStrategy {
constraints: IConstraint[];
id: string;
name: string;
parameters: IParameter;
}
export interface IConstraint {
values: string[];
operator: string;
contextName: string;
}
export interface IParameter {
groupId?: string;
rollout?: number;
stickiness?: string;
[index: string]: any;
}

View File

@ -101,6 +101,7 @@ const theme = createMuiTheme({
fontSizes: {
mainHeader: '1.2rem',
subHeader: '1.1rem',
bodySize: '1rem',
},
boxShadows: {
chip: {