diff --git a/frontend/src/component/AccessProvider/permissions.ts b/frontend/src/component/AccessProvider/permissions.ts
index 34b597774d..cfc09f17dd 100644
--- a/frontend/src/component/AccessProvider/permissions.ts
+++ b/frontend/src/component/AccessProvider/permissions.ts
@@ -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';
diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts
new file mode 100644
index 0000000000..ad4171e27c
--- /dev/null
+++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
new file mode 100644
index 0000000000..50b3a412f9
--- /dev/null
+++ b/frontend/src/component/common/CheckmarkBadge/CheckMarkBadge.tsx
@@ -0,0 +1,13 @@
+import { Check } from '@material-ui/icons';
+import { useStyles } from './CheckMarkBadge.styles';
+
+const CheckMarkBadge = () => {
+ const styles = useStyles();
+ return (
+
+
+
+ );
+};
+
+export default CheckMarkBadge;
diff --git a/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx b/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx
index 0dac67b96c..e510c8f878 100644
--- a/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx
+++ b/frontend/src/component/common/HeaderTitle/HeaderTitle.jsx
@@ -13,7 +13,7 @@ const HeaderTitle = ({
subtitle,
variant,
loading,
- className,
+ className = '',
}) => {
const styles = useStyles();
const headerClasses = classnames({ skeleton: loading });
diff --git a/frontend/src/component/common/Input/Input.styles.ts b/frontend/src/component/common/Input/Input.styles.ts
new file mode 100644
index 0000000000..fcf9ce8131
--- /dev/null
+++ b/frontend/src/component/common/Input/Input.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx
new file mode 100644
index 0000000000..0a89576af8
--- /dev/null
+++ b/frontend/src/component/common/Input/Input.tsx
@@ -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 (
+
+
+
+ );
+};
+
+export default Input;
diff --git a/frontend/src/component/common/flags.js b/frontend/src/component/common/flags.js
index a41f94c48f..19b7576572 100644
--- a/frontend/src/component/common/flags.js
+++ b/frontend/src/component/common/flags.js
@@ -1,4 +1,5 @@
export const P = 'P';
export const C = 'C';
+export const E = 'E';
export const RBAC = 'RBAC';
export const PROJECTFILTERING = false;
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.styles.ts b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.styles.ts
new file mode 100644
index 0000000000..76b1198f49
--- /dev/null
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
new file mode 100644
index 0000000000..0d61156275
--- /dev/null
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx
@@ -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) => {
+ setType(event.currentTarget.value);
+ };
+
+ const handleEnvNameChange = (e: React.FormEvent) => {
+ setEnvName(e.currentTarget.value);
+ setEnvDisplayName(e.currentTarget.value);
+ };
+
+ const handleEnvDisplayName = (e: React.FormEvent) =>
+ 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 (
+ }>
+
+ }
+ elseShow={
+
+
+ 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.
+
+
+
+
+ }
+ />
+ {toast}
+
+ );
+};
+
+export default CreateEnvironment;
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccess.styles.ts b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccess.styles.ts
new file mode 100644
index 0000000000..f4a82266f7
--- /dev/null
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccess.styles.ts
@@ -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,
+ },
+}));
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccess.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccess.tsx
new file mode 100644
index 0000000000..acada8b694
--- /dev/null
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccess.tsx
@@ -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 (
+
+
+
Environment created
+
+
Next steps
+
+
+
+
1
+
+ Update SDK version and provide the environment id to
+ the SDK
+
+
+ By providing the environment id in the SDK the SDK
+ will only retrieve activation strategies for
+ specified environment
+
+
+ Learn more
+
+
+
+
+
+
2
+
+ Add environment specific activation strategies
+
+
+
+ You can now select this environment when you are
+ adding new activation strategies on feature toggles.
+
+
+ Learn more
+
+
+
+
+
+
+ Got it!
+
+
+ );
+};
+
+export default CreateEnvironmentSuccess;
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard.styles.ts b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard.styles.ts
new file mode 100644
index 0000000000..fd691d7d46
--- /dev/null
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard.styles.ts
@@ -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' },
+}));
diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard.tsx
new file mode 100644
index 0000000000..4fcb1c90b3
--- /dev/null
+++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironmentSuccess/CreateEnvironmentSuccessCard/CreateEnvironmentSuccessCard.tsx
@@ -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 (
+
+
+ Environment
+
+
+
+
+
+
Displayname
+
{displayName}
+
+
+
+
+ );
+};
+
+export default CreateEnvironmentSuccessCard;
diff --git a/frontend/src/component/environments/EditEnvironment/EditEnvironment.styles.ts b/frontend/src/component/environments/EditEnvironment/EditEnvironment.styles.ts
new file mode 100644
index 0000000000..c1e73b0656
--- /dev/null
+++ b/frontend/src/component/environments/EditEnvironment/EditEnvironment.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx
new file mode 100644
index 0000000000..fdda709b3a
--- /dev/null
+++ b/frontend/src/component/environments/EditEnvironment/EditEnvironment.tsx
@@ -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>;
+ editEnvironment: boolean;
+ setToastData: React.Dispatch>;
+}
+
+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) => {
+ setType(event.currentTarget.value);
+ };
+
+ const handleEnvDisplayName = (e: React.FormEvent) =>
+ 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 (
+
+
+
+ Environment Id
+
+
+ {env.name}
+
+
+
+
+ );
+};
+
+export default EditEnvironment;
diff --git a/frontend/src/component/environments/EnvironmentList/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts b/frontend/src/component/environments/EnvironmentList/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts
new file mode 100644
index 0000000000..bd3b321fc4
--- /dev/null
+++ b/frontend/src/component/environments/EnvironmentList/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.styles.ts
@@ -0,0 +1,10 @@
+import { makeStyles } from '@material-ui/core/styles';
+
+export const useStyles = makeStyles(theme => ({
+ deleteParagraph: {
+ marginTop: '2rem',
+ },
+ environmentDeleteInput: {
+ marginTop: '1rem',
+ },
+}));
diff --git a/frontend/src/component/environments/EnvironmentList/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx b/frontend/src/component/environments/EnvironmentList/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx
new file mode 100644
index 0000000000..e8541a9d98
--- /dev/null
+++ b/frontend/src/component/environments/EnvironmentList/EnvironmentDeleteConfirm/EnvironmentDeleteConfirm.tsx
@@ -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>;
+ setDeldialogue: React.Dispatch>;
+ handleDeleteEnvironment: (name: string) => Promise;
+ confirmName: string;
+ setConfirmName: React.Dispatch>;
+}
+
+const EnvironmentDeleteConfirm = ({
+ env,
+ open,
+ setDeldialogue,
+ handleDeleteEnvironment,
+ confirmName,
+ setConfirmName,
+}: IEnviromentDeleteConfirmProps) => {
+ const styles = useStyles();
+
+ const handleChange = (e: React.ChangeEvent) =>
+ setConfirmName(e.currentTarget.value);
+
+ const handleCancel = () => {
+ setDeldialogue(false);
+ setConfirmName('');
+ };
+
+ return (
+
+
+ Danger. Deleting this environment will result in removing all
+ strategies that are active in this environment across all
+ feature toggles.
+
+
+
+
+ In order to delete this environment, please enter the id of the
+ environment in the textfield below: {env?.name}
+
+
+
+
+ );
+};
+
+export default EnvironmentDeleteConfirm;
diff --git a/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx b/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx
new file mode 100644
index 0000000000..cbcbef34f7
--- /dev/null
+++ b/frontend/src/component/environments/EnvironmentList/EnvironmentList.tsx
@@ -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) => (
+
+ ));
+
+ const navigateToCreateEnvironment = () => {
+ history.push('/environments/create');
+ };
+
+ return (
+
+
+ Add Environment
+
+ >
+ }
+ />
+ }
+ >
+ {environmentList()}
+
+
+
+
+ {toast}
+
+ );
+};
+
+export default EnvironmentList;
diff --git a/frontend/src/component/environments/EnvironmentList/EnvironmentListItem/EnvironmentListItem.tsx b/frontend/src/component/environments/EnvironmentList/EnvironmentListItem/EnvironmentListItem.tsx
new file mode 100644
index 0000000000..ee8d159e90
--- /dev/null
+++ b/frontend/src/component/environments/EnvironmentList/EnvironmentListItem/EnvironmentListItem.tsx
@@ -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>;
+ setDeldialogue: React.Dispatch>;
+ setEditEnvironment: React.Dispatch>;
+ setToggleDialog: React.Dispatch>;
+ index: number;
+ moveListItem: (dragIndex: number, hoverIndex: number) => IEnvironment[];
+ moveListItemApi: (dragIndex: number, hoverIndex: number) => Promise;
+}
+
+interface DragItem {
+ index: number;
+ id: string;
+ type: string;
+}
+
+const EnvironmentListItem = ({
+ env,
+ setSelectedEnv,
+ setDeldialogue,
+ index,
+ moveListItem,
+ moveListItemApi,
+ setToggleDialog,
+ setEditEnvironment,
+}: IEnvironmentListItemProps) => {
+ const ref = useRef(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 (
+
+
+
+
+
+ {env.name}
+
+ disabled
+
+ }
+ />
+ >
+ }
+ secondary={env.displayName}
+ />
+
+
+
+
+
+
+
+ {
+ setSelectedEnv(env);
+ setToggleDialog(prev => !prev);
+ }}
+ >
+
+
+
+ }
+ />
+
+ {
+ setSelectedEnv(env);
+ setEditEnvironment(prev => !prev);
+ }}
+ >
+
+
+
+ }
+ />
+
+ {
+ setDeldialogue(true);
+ setSelectedEnv(env);
+ }}
+ >
+
+
+
+ }
+ />
+
+ );
+};
+
+export default EnvironmentListItem;
diff --git a/frontend/src/component/environments/EnvironmentList/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx b/frontend/src/component/environments/EnvironmentList/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx
new file mode 100644
index 0000000000..0d2b681dbe
--- /dev/null
+++ b/frontend/src/component/environments/EnvironmentList/EnvironmentToggleConfirm/EnvironmentToggleConfirm.tsx
@@ -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>;
+ handleConfirmToggleEnvironment: () => void;
+}
+
+const EnvironmentToggleConfirm = ({
+ env,
+ open,
+ setToggleDialog,
+ handleConfirmToggleEnvironment,
+}: IEnvironmentToggleConfirmProps) => {
+ let text = env.enabled ? 'disable' : 'enable';
+
+ const handleCancel = () => {
+ setToggleDialog(false);
+ };
+
+ return (
+
+
+ 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.
+
+ }
+ elseShow={
+
+ Enabling an environment will allow you to add new
+ activation strategies to this environment.
+
+ }
+ />
+
+
+
+ );
+};
+
+export default EnvironmentToggleConfirm;
diff --git a/frontend/src/component/environments/form/EnvironmentTypeSelector/EnvironmentTypeSelector.styles.ts b/frontend/src/component/environments/form/EnvironmentTypeSelector/EnvironmentTypeSelector.styles.ts
new file mode 100644
index 0000000000..11aabd3422
--- /dev/null
+++ b/frontend/src/component/environments/form/EnvironmentTypeSelector/EnvironmentTypeSelector.styles.ts
@@ -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' },
+}));
diff --git a/frontend/src/component/environments/form/EnvironmentTypeSelector/EnvironmentTypeSelector.tsx b/frontend/src/component/environments/form/EnvironmentTypeSelector/EnvironmentTypeSelector.tsx
new file mode 100644
index 0000000000..bfa0154990
--- /dev/null
+++ b/frontend/src/component/environments/form/EnvironmentTypeSelector/EnvironmentTypeSelector.tsx
@@ -0,0 +1,60 @@
+import {
+ FormControl,
+ FormControlLabel,
+ RadioGroup,
+ Radio,
+} from '@material-ui/core';
+import { useStyles } from './EnvironmentTypeSelector.styles';
+
+interface IEnvironmentTypeSelectorProps {
+ onChange: (event: React.FormEvent) => void;
+ value: string;
+}
+
+const EnvironmentTypeSelector = ({
+ onChange,
+ value,
+}: IEnvironmentTypeSelectorProps) => {
+ const styles = useStyles();
+ return (
+
+
+ Environment Type
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+ }
+ />
+ }
+ />
+
+
+
+ );
+};
+
+export default EnvironmentTypeSelector;
diff --git a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx
new file mode 100644
index 0000000000..7c109b12cc
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx
@@ -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 (
+
+
+ {feature.environments.map(env => {
+ return ;
+ })}
+
+ );
+};
+
+export default FeatureView2;
diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts
new file mode 100644
index 0000000000..353de4fdcd
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts
@@ -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',
+ },
+}));
diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx
new file mode 100644
index 0000000000..e1f5d6c93f
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx
@@ -0,0 +1,16 @@
+import { Switch } from '@material-ui/core';
+import { useStyles } from './FeatureViewEnvironment.styles';
+
+const FeatureViewEnvironment = ({ env }: any) => {
+ const styles = useStyles();
+ return (
+
+
+ Toggle in{' '}
+ {env.name} is {env.enabled ? 'enabled' : 'disabled'}
+
+
+ );
+};
+
+export default FeatureViewEnvironment;
diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetaData.tsx b/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetaData.tsx
new file mode 100644
index 0000000000..db8ed445e0
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetaData.tsx
@@ -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 (
+
+
+ {' '}
+ {capitalize(type || '')} toggle
+
+ Project: {project}
+
+ Description: {description}{' '}
+
+
+
+
+ }
+ elseShow={
+
+ No description.{' '}
+
+
+
+
+ }
+ />
+
+ );
+};
+
+export default FeatureViewMetaData;
diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts
new file mode 100644
index 0000000000..96ec8c0725
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView2/FeatureViewMetaData/FeatureViewMetadata.styles.ts
@@ -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,
+ },
+}));
diff --git a/frontend/src/component/layout/MainLayout/MainLayout.jsx b/frontend/src/component/layout/MainLayout/MainLayout.jsx
index 126eeee55d..6b3c836c5a 100644
--- a/frontend/src/component/layout/MainLayout/MainLayout.jsx
+++ b/frontend/src/component/layout/MainLayout/MainLayout.jsx
@@ -38,7 +38,7 @@ const MainLayout = ({ children, location, uiConfig }) => {
diff --git a/frontend/src/component/menu/Footer/Footer.styles.js b/frontend/src/component/menu/Footer/Footer.styles.js
index 96a765d169..898d896c46 100644
--- a/frontend/src/component/menu/Footer/Footer.styles.js
+++ b/frontend/src/component/menu/Footer/Footer.styles.js
@@ -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,
diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx
index ff5eed4f4a..679ce4db7f 100644
--- a/frontend/src/component/menu/Header/Header.tsx
+++ b/frontend/src/component/menu/Header/Header.tsx
@@ -96,6 +96,7 @@ const Header = () => {
show={
Projects}
/>
Feature toggles
+
Reporting
{
setAnchorElAdvanced(e.currentTarget)
}
>
- Advanced
+ Configure
{
- expect(baseRoutes).toHaveLength(35);
+ expect(baseRoutes).toHaveLength(38);
expect(baseRoutes).toMatchSnapshot();
});
diff --git a/frontend/src/component/menu/routes.js b/frontend/src/component/menu/routes.js
index 3f2f41cad9..1967bdc841 100644
--- a/frontend/src/component/menu/routes.js
+++ b/frontend/src/component/menu/routes.js
@@ -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);
diff --git a/frontend/src/hooks/api/actions/useApi/useApi.ts b/frontend/src/hooks/api/actions/useApi/useApi.ts
index 639cb7fa14..2d3844457e 100644
--- a/frontend/src/hooks/api/actions/useApi/useApi.ts
+++ b/frontend/src/hooks/api/actions/useApi/useApi.ts
@@ -55,9 +55,12 @@ const useAPI = ({
const makeRequest = async (
apiCaller: any,
- requestId?: string
+ requestId?: string,
+ loading: boolean = true
): Promise => {
- setLoading(true);
+ if (loading) {
+ setLoading(true);
+ }
try {
const res = await apiCaller();
setLoading(false);
diff --git a/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts b/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts
new file mode 100644
index 0000000000..edc23ebd16
--- /dev/null
+++ b/frontend/src/hooks/api/actions/useEnvironmentApi/useEnvironmentApi.ts
@@ -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;
diff --git a/frontend/src/hooks/api/getters/useEnvironments/useEnvironments.ts b/frontend/src/hooks/api/getters/useEnvironments/useEnvironments.ts
new file mode 100644
index 0000000000..864abc33a5
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useEnvironments/useEnvironments.ts
@@ -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(
+ 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;
diff --git a/frontend/src/hooks/api/getters/useFeature/defaultFeature.ts b/frontend/src/hooks/api/getters/useFeature/defaultFeature.ts
new file mode 100644
index 0000000000..2cd0441640
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useFeature/defaultFeature.ts
@@ -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: '',
+};
diff --git a/frontend/src/hooks/api/getters/useFeature/useFeature.ts b/frontend/src/hooks/api/getters/useFeature/useFeature.ts
new file mode 100644
index 0000000000..59bde50a74
--- /dev/null
+++ b/frontend/src/hooks/api/getters/useFeature/useFeature.ts
@@ -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(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;
diff --git a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
index ea2e9d0d61..8d4adc70cc 100644
--- a/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
+++ b/frontend/src/hooks/api/getters/useUiConfig/defaultValue.ts
@@ -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',
diff --git a/frontend/src/interfaces/environments.ts b/frontend/src/interfaces/environments.ts
new file mode 100644
index 0000000000..075da72a80
--- /dev/null
+++ b/frontend/src/interfaces/environments.ts
@@ -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;
+}
diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts
index aad8546be3..31d9f0cbf6 100644
--- a/frontend/src/interfaces/featureToggle.ts
+++ b/frontend/src/interfaces/featureToggle.ts
@@ -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;
+}
diff --git a/frontend/src/interfaces/strategy.ts b/frontend/src/interfaces/strategy.ts
new file mode 100644
index 0000000000..f577587e07
--- /dev/null
+++ b/frontend/src/interfaces/strategy.ts
@@ -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;
+}
diff --git a/frontend/src/themes/main-theme.js b/frontend/src/themes/main-theme.js
index baea88c57d..24973c9034 100644
--- a/frontend/src/themes/main-theme.js
+++ b/frontend/src/themes/main-theme.js
@@ -101,6 +101,7 @@ const theme = createMuiTheme({
fontSizes: {
mainHeader: '1.2rem',
subHeader: '1.1rem',
+ bodySize: '1rem',
},
boxShadows: {
chip: {