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. +

+ +
+ +

+ Environment Id and name +

+ +
+

+ Unique env name for SDK configurations. +

+ +
+ +
+

Environment display name.

+ +
+ +
+
+ {' '} + +
+
+
+ } + /> + {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 + +
+
+
+ + +
+ ); +}; + +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 +
+ +
+
+
Id
+
{name}
+
+
+
Displayname
+
{displayName}
+
+
+
Type
+
{type}
+
+
+
+ ); +}; + +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} +

+
+ + +

+ Environment display 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 { - 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: {