1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Feat/toggle view (#389)

* feat: toggle view

* fix: navigation

* eat: toggle view

* fix: resolve lint

* fix: remove console logs

* fix: reimplement feature validation
This commit is contained in:
Fredrik Strand Oseberg 2021-10-01 13:49:18 +02:00 committed by GitHub
parent fe2a8311bf
commit 47579e2616
33 changed files with 1005 additions and 128 deletions

View File

@ -13,6 +13,10 @@ body {
font-family: 'Sen', sans-serif;
}
button {
font-family: 'Sen', sans-serif;
}
.MuiButton-root {
border-radius: 3px;
text-transform: none;

View File

@ -0,0 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
borderRadius: '12.5px',
backgroundColor: '#fff',
padding: '2rem',
},
}));

View File

@ -0,0 +1,19 @@
import { useParams } from 'react-router';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
import { useStyles } from './FeatureLog.styles';
import { IFeatureViewParams } from '../../../../interfaces/params';
import HistoryComponent from '../../../history/FeatureEventHistory';
const FeatureLog = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
return (
<div className={styles.container}>
<HistoryComponent toggleName={feature.name} />;
</div>
);
};
export default FeatureLog;

View File

@ -0,0 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
borderRadius: '12.5px',
backgroundColor: '#fff',
padding: '2rem',
},
}));

View File

@ -0,0 +1,19 @@
import { useParams } from 'react-router';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
import MetricComponent from '../../view/metric-container';
import { useStyles } from './FeatureMetrics.styles';
import { IFeatureViewParams } from '../../../../interfaces/params';
const FeatureMetrics = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
return (
<div className={styles.container}>
<MetricComponent featureToggle={feature} />
</div>
);
};
export default FeatureMetrics;

View File

@ -0,0 +1,10 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: { display: 'flex', width: '100%' },
mainContent: {
display: 'flex',
flexDirection: 'column',
width: '100%',
},
}));

View File

@ -0,0 +1,21 @@
import FeatureViewMetaData from './FeatureViewMetaData/FeatureViewMetaData';
import FeatureOverviewStrategies from './FeatureOverviewStrategies/FeatureOverviewStrategies';
import { useStyles } from './FeatureOverview.styles';
import FeatureOverviewTags from './FeatureOverviewTags/FeatureOverviewTags';
const FeatureOverview = () => {
const styles = useStyles();
return (
<div className={styles.container}>
<div className={styles.sidebar}>
<FeatureViewMetaData />
<FeatureOverviewTags />
</div>
<div className={styles.mainContent}>
<FeatureOverviewStrategies />
</div>
</div>
);
};
export default FeatureOverview;

View File

@ -0,0 +1,77 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
marginBottom: '2rem',
border: `1px solid ${theme.palette.grey[300]}`,
borderRadius: '5px',
position: 'relative',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
padding: '1rem',
},
icon: {
fill: '#fff',
height: '17.5px',
width: '17.5px',
},
strategiesContainer: {
padding: '1rem 0',
['& > *']: {
margin: '0.5rem 0',
},
},
environmentIdentifier: {
position: 'absolute',
right: '42.5%',
top: '-25px',
display: 'flex',
background: theme.palette.primary.light,
borderRadius: '25px',
padding: '0.4rem 1rem',
minWidth: '150px',
color: '#fff',
alignItems: 'center',
},
environmentBadgeParagraph: {
fontSize: theme.fontSizes.smallBody,
},
iconContainer: {
padding: '0.25rem',
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
border: '1px solid #fff',
marginRight: '0.5rem',
},
body: {
padding: '1rem',
},
disabledEnvContainer: {
backgroundColor: theme.palette.grey[300],
color: theme.palette.grey[600],
},
disabledIconContainer: {
border: `1px solid ${theme.palette.grey[500]}`,
},
iconDisabled: {
fill: theme.palette.grey[500],
},
toggleText: {
fontSize: theme.fontSizes.smallBody,
},
toggleLink: {
color: theme.palette.primary.main,
fontSize: theme.fontSizes.smallBody,
},
headerDisabledEnv: {
border: 'none',
},
}));

View File

@ -0,0 +1,173 @@
import { Cloud } from '@material-ui/icons';
import { IFeatureEnvironment } from '../../../../../../interfaces/featureToggle';
import { Switch } from '@material-ui/core';
import { useStyles } from './FeatureOverviewEnvironment.styles';
import FeatureOverviewStrategyCard from './FeatureOverviewStrategyCard/FeatureOverviewStrategyCard';
import classNames from 'classnames';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
import useFeatureApi from '../../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import { useHistory, useParams, Link } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
import useToast from '../../../../../../hooks/useToast';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
refetch: () => void;
}
const FeatureOverviewEnvironment = ({
env,
refetch,
}: IFeatureOverviewEnvironmentProps) => {
const { featureId, projectId } = useParams<IFeatureViewParams>();
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const styles = useStyles();
const { toast, setToastData } = useToast();
const history = useHistory();
console.log(env);
const handleClick = () => {
history.push(
`/projects/${projectId}/features2/${featureId}/strategies?environment=${env.name}`
);
};
const renderStrategies = () => {
const { strategies } = env;
return strategies.map(strategy => {
return (
<FeatureOverviewStrategyCard
strategy={strategy}
key={strategy.id}
onClick={handleClick}
/>
);
});
};
const handleToggleEnvironmentOn = async () => {
try {
await toggleFeatureEnvironmentOn(projectId, featureId, env.name);
setToastData({
type: 'success',
show: true,
text: 'Successfully turned environment on.',
});
refetch();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
const handleToggleEnvironmentOff = async () => {
try {
await toggleFeatureEnvironmentOff(projectId, featureId, env.name);
setToastData({
type: 'success',
show: true,
text: 'Successfully turned environment off.',
});
refetch();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
const toggleEnvironment = (e: React.ChangeEvent) => {
if (env.enabled) {
handleToggleEnvironmentOff();
return;
}
handleToggleEnvironmentOn();
};
const iconContainerClasses = classNames(styles.iconContainer, {
[styles.disabledIconContainer]: !env.enabled,
});
const iconClasses = classNames(styles.icon, {
[styles.iconDisabled]: !env.enabled,
});
const headerClasses = classNames(styles.header, {
[styles.headerDisabledEnv]: !env.enabled,
});
const environmentIdentifierClasses = classNames(
styles.environmentIdentifier,
{ [styles.disabledEnvContainer]: !env.enabled }
);
return (
<div className={styles.container}>
<div className={environmentIdentifierClasses}>
<div className={iconContainerClasses}>
<Cloud className={iconClasses} />
</div>
<p className={styles.environmentBadgeParagraph}>{env.type}</p>
</div>
<div className={headerClasses}>
<div className={styles.headerInfo}>
<p className={styles.environmentTitle}>{env.name}</p>
</div>
<div className={styles.environmentStatus}>
<ConditionallyRender
condition={env.strategies.length > 0}
show={
<>
<Switch
value={env.enabled}
checked={env.enabled}
onChange={toggleEnvironment}
/>{' '}
<span className={styles.toggleText}>
This environment is{' '}
{env.enabled ? 'enabled' : 'disabled'}
</span>
</>
}
elseShow={
<>
<p className={styles.toggleText}>
No strategies configured for environment.
</p>
<Link
to={`/projects/${projectId}/features2/${featureId}/strategies?addStrategy=true&environment=${env.name}`}
className={styles.toggleLink}
>
Configure strategies for {env.name}
</Link>
</>
}
/>
</div>
</div>
<ConditionallyRender
condition={env.enabled}
show={
<div className={styles.body}>
<div className={styles.strategiesContainer}>
{renderStrategies()}
</div>
</div>
}
/>
{toast}
</div>
);
};
export default FeatureOverviewEnvironment;

View File

@ -0,0 +1,36 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
card: {
border: `1px solid ${theme.palette.grey[300]}`,
borderRadius: '5px',
transition: 'transform 0.3s ease',
transitionDelay: '0.1s',
position: 'relative',
background: 'transparent',
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '0.75rem',
fontSize: theme.fontSizes.bodySize,
},
cardHeader: {
maxWidth: '200px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
[theme.breakpoints.down(700)]: {
maxWidth: '100px',
fontSize: theme.fontSizes.smallBody,
},
},
icon: {
marginRight: '0.5rem',
fill: theme.palette.primary.main,
minWidth: '35px',
},
rollout: {
fontSize: theme.fontSizes.smallBody,
marginLeft: '0.5rem',
},
}));

View File

@ -0,0 +1,42 @@
import { useMediaQuery } from '@material-ui/core';
import { IFeatureStrategy } from '../../../../../../../interfaces/strategy';
import {
getFeatureStrategyIcon,
getHumanReadbleStrategyName,
} from '../../../../../../../utils/strategy-names';
import ConditionallyRender from '../../../../../../common/ConditionallyRender';
import { useStyles } from './FeatureOverviewStrategyCard.styles';
interface IFeatureOverviewStrategyCardProps {
strategy: IFeatureStrategy;
onClick: () => void;
}
const FeatureOverviewStrategyCard = ({
strategy,
onClick,
}: IFeatureOverviewStrategyCardProps) => {
const styles = useStyles();
const smallScreen = useMediaQuery('(max-width:500px)');
const strategyName = getHumanReadbleStrategyName(strategy.name);
const Icon = getFeatureStrategyIcon(strategy.name);
const { parameters } = strategy;
return (
<button className={styles.card} onClick={onClick}>
<Icon className={styles.icon} />
<p className={styles.cardHeader}>{strategyName}</p>
<ConditionallyRender
condition={Boolean(parameters?.rollout) && !smallScreen}
show={
<p className={styles.rollout}>
Rolling out to {parameters?.rollout}%
</p>
}
/>
</button>
);
};
export default FeatureOverviewStrategyCard;

View File

@ -0,0 +1,27 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
borderRadius: '12.5px',
width: '100%',
backgroundColor: '#fff',
},
headerContainer: {
borderBottom: `1px solid ${theme.palette.grey[300]}`,
},
headerTitle: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
margin: 0,
},
headerInnerContainer: {
padding: '1.5rem 2rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
actions: { position: 'relative' },
bodyContainer: {
padding: '3rem 2rem',
},
}));

View File

@ -0,0 +1,54 @@
import { Add } from '@material-ui/icons';
import { Link, useParams } from 'react-router-dom';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import ResponsiveButton from '../../../../common/ResponsiveButton/ResponsiveButton';
import FeatureOverviewEnvironment from './FeatureOverviewEnvironment/FeatureOverviewEnvironment';
import { useStyles } from './FeatureOverviewStrategies.styles';
const FeatureOverviewStrategies = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature, refetch } = useFeature(projectId, featureId);
if (!feature) return null;
const { environments } = feature;
const renderEnvironments = () => {
return environments?.map(env => {
return (
<FeatureOverviewEnvironment
env={env}
key={env.name}
refetch={refetch}
/>
);
});
};
return (
<div className={styles.container}>
<div className={styles.headerContainer}>
<div className={styles.headerInnerContainer}>
<h3 className={styles.headerTitle}>Toggle Strategies</h3>
<div className={styles.actions}>
<ResponsiveButton
maxWidth="700px"
Icon={Add}
className={styles.addStrategyButton}
component={Link}
to={`/projects/${projectId}/features2/${featureId}/strategies?addStrategy=true`}
>
Add new strategy
</ResponsiveButton>
</div>
</div>
</div>
<div className={styles.bodyContainer}>{renderEnvironments()}</div>
</div>
);
};
export default FeatureOverviewStrategies;

View File

@ -0,0 +1,40 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
borderRadius: '10px',
backgroundColor: '#fff',
display: 'flex',
flexDirection: 'column',
maxWidth: '350px',
minWidth: '350px',
marginRight: '1rem',
marginTop: '1rem',
},
tagheaderContainer: {
display: 'flex',
alignItems: 'center',
padding: '1.5rem',
justifyContent: 'space-between',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
},
tagHeader: {
display: 'flex',
alignItems: 'center',
},
tag: {
height: '40px',
width: '40px',
fill: theme.palette.primary.main,
marginRight: '0.8rem',
},
tagHeaderText: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
margin: 0,
},
tagContent: {
padding: '1.5rem',
},
}));

View File

@ -0,0 +1,101 @@
import { Chip } from '@material-ui/core';
import { Label } from '@material-ui/icons';
import { useParams } from 'react-router-dom';
import useTags from '../../../../../hooks/api/getters/useTags/useTags';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import { useStyles } from './FeatureOverviewTags.styles';
import slackIcon from '../../../../../assets/icons/slack.svg';
import jiraIcon from '../../../../../assets/icons/jira.svg';
import webhookIcon from '../../../../../assets/icons/webhooks.svg';
import { formatAssetPath } from '../../../../../utils/format-path';
import useTagTypes from '../../../../../hooks/api/getters/useTagTypes/useTagTypes';
import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import AddTagDialogContainer from '../../../add-tag-dialog-container';
const FeatureOverviewTags = () => {
const styles = useStyles();
const { featureId } = useParams<IFeatureViewParams>();
const { tags, refetch } = useTags(featureId);
const { tagTypes } = useTagTypes();
const { deleteTag } = useFeatureApi();
const handleDelete = async (type: string, value: string) => {
try {
await deleteTag(featureId, type, value);
refetch();
} catch (e) {
// TODO: Handle error
console.log(e);
}
};
const tagIcon = (typeName: string) => {
let tagType = tagTypes.find(type => type.name === typeName);
const style = { width: '20px', height: '20px', marginRight: '5px' };
if (tagType && tagType.icon) {
switch (tagType.name) {
case 'slack':
return (
<img
style={style}
alt="slack"
src={formatAssetPath(slackIcon)}
/>
);
case 'jira':
return (
<img
style={style}
alt="jira"
src={formatAssetPath(jiraIcon)}
/>
);
case 'webhook':
return (
<img
style={style}
alt="webhook"
src={formatAssetPath(webhookIcon)}
/>
);
default:
return <Label />;
}
} else {
return <span>{typeName[0].toUpperCase()}</span>;
}
};
const renderTag = t => (
<Chip
icon={tagIcon(t.type)}
style={{ marginRight: '3px', fontSize: '0.8em' }}
label={t.value}
key={`${t.type}:${t.value}`}
onDelete={() => handleDelete(t.type, t.value)}
/>
);
return (
<div className={styles.container}>
<div className={styles.tagheaderContainer}>
<div className={styles.tagHeader}>
<Label className={styles.tag} />
<h4 className={styles.tagHeaderText}>Tags</h4>
</div>
<AddTagDialogContainer featureToggleName={featureId} />
{/* <IconButton>
<Add />
</IconButton> */}
</div>
<div className={styles.tagContent}>{tags.map(renderTag)}</div>
</div>
);
};
export default FeatureOverviewTags;

View File

@ -0,0 +1,59 @@
import { capitalize, IconButton } from '@material-ui/core';
import classnames from 'classnames';
import { useParams } from 'react-router-dom';
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';
import { IFeatureViewParams } from '../../../../../interfaces/params';
const FeatureViewMetaData = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const { project, description, type } = feature;
const IconComponent = getFeatureTypeIcons(type);
return (
<div className={classnames(styles.container)}>
<div className={styles.metaDataHeader}>
<IconComponent className={styles.headerIcon} />{' '}
<h3 className={styles.header}>
{capitalize(type || '')} toggle
</h3>
</div>
<div className={styles.body}>
<span className={styles.bodyItem}>Project: {project}</span>
<ConditionallyRender
condition
show={
<span className={styles.bodyItem}>
<div>Description:</div>
<div className={styles.descriptionContainer}>
<p>{description}</p>
<IconButton>
<Edit />
</IconButton>
</div>
</span>
}
elseShow={
<span>
No description.{' '}
<IconButton>
<Edit />
</IconButton>
</span>
}
/>
</div>
</div>
);
};
export default FeatureViewMetaData;

View File

@ -15,8 +15,29 @@ export const useStyles = makeStyles(theme => ({
display: 'flex',
alignItems: 'center',
},
header: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
margin: 0,
},
body: {
margin: '1rem 0',
display: 'flex',
flexDirection: 'column',
},
bodyItem: {
margin: '0.5rem 0',
fontSize: theme.fontSizes.bodySize,
},
headerIcon: {
marginRight: '1rem',
height: '40px',
width: '40px',
fill: theme.palette.primary.main,
},
descriptionContainer: {
display: 'flex',
alignItems: 'center',
color: theme.palette.grey[600],
},
}));

View File

@ -2,7 +2,7 @@ import { Button, useMediaQuery } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { useContext, useState } from 'react';
import { getHumanReadbleStrategyName } from '../../../../../../utils/strategy-names';
import { useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import FeatureStrategiesUIContext from '../../../../../../contexts/FeatureStrategiesUIContext';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
@ -24,6 +24,7 @@ const FeatureStrategiesConfigure = ({
setToastData,
}: IFeatureStrategiesConfigure) => {
const smallScreen = useMediaQuery('(max-width:900px)');
const history = useHistory();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const [productionGuard, setProductionGuard] = useState(false);
@ -90,6 +91,7 @@ const FeatureStrategiesConfigure = ({
type: 'success',
text: 'Successfully added strategy.',
});
history.replace(history.location.pathname);
} catch (e) {
setToastData({
show: true,

View File

@ -1,5 +1,5 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import FeatureStrategiesUIContext from '../../../../../../contexts/FeatureStrategiesUIContext';
import useFeatureStrategyApi from '../../../../../../hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import useToast from '../../../../../../hooks/useToast';
@ -11,7 +11,7 @@ const useFeatureStrategiesEnvironmentList = (
strategies: IFeatureStrategy[]
) => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
const history = useHistory();
const { deleteStrategyFromFeature, updateStrategyOnFeature } =
useFeatureStrategyApi();
@ -71,7 +71,7 @@ const useFeatureStrategiesEnvironmentList = (
strategy.parameters = updateStrategyPayload.parameters;
strategy.constraints = updateStrategyPayload.constraints;
history.replace(history.location.pathname);
setFeatureCache(feature);
} catch (e) {
setToastData({
@ -109,6 +109,7 @@ const useFeatureStrategiesEnvironmentList = (
type: 'success',
text: `Successfully deleted strategy from ${featureId}`,
});
history.replace(history.location.pathname);
} catch (e) {
setToastData({
show: true,

View File

@ -1,4 +1,4 @@
import { useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import { useStyles } from './FeatureStrategiesEnvironments.styles';
import { Tabs, Tab, Button, useMediaQuery } from '@material-ui/core';
@ -21,10 +21,12 @@ import ResponsiveButton from '../../../../common/ResponsiveButton/ResponsiveButt
import { Add } from '@material-ui/icons';
import AccessContext from '../../../../../contexts/AccessContext';
import { UPDATE_FEATURE } from '../../../../AccessProvider/permissions';
import useQueryParams from '../../../../../hooks/useQueryParams';
const FeatureStrategiesEnvironments = () => {
const smallScreen = useMediaQuery('(max-width:700px)');
const { hasAccess } = useContext(AccessContext);
const history = useHistory();
const startingTabId = 0;
const { projectId, featureId } = useParams<IFeatureViewParams>();
@ -32,6 +34,10 @@ const FeatureStrategiesEnvironments = () => {
const [showRefreshPrompt, setShowRefreshPrompt] = useState(false);
const styles = useStyles();
const query = useQueryParams();
const addStrategy = query.get('addStrategy');
const environmentTab = query.get('environment');
const { a11yProps, activeTabIdx, setActiveTab } = useTabs(startingTabId);
const {
setActiveEnvironment,
@ -49,6 +55,28 @@ const FeatureStrategiesEnvironments = () => {
refreshInterval: 5000,
});
useEffect(() => {
if (addStrategy) {
setExpandedSidebar(true);
}
if (environmentTab) {
const env = feature.environments.find(
env => env.name === environmentTab
);
const index = feature.environments.findIndex(
env => env.name === environmentTab
);
if (index < 0 || !env) return;
setActiveEnvironment(env);
setActiveTab(index);
return;
}
setActiveEnvironment(feature?.environments[activeTabIdx]);
/*eslint-disable-next-line */
}, [feature]);
useEffect(() => {
if (!feature) return;
if (featureCache === null || !featureCache.createdAt) {
@ -70,12 +98,6 @@ const FeatureStrategiesEnvironments = () => {
/*eslint-disable-next-line */
}, [feature]);
useEffect(() => {
if (!feature?.environments?.length > 0) return;
setActiveEnvironment(feature?.environments[activeTabIdx]);
/* eslint-disable-next-line */
}, [feature]);
const renderTabs = () => {
return featureCache?.environments?.map((env, index) => {
return (
@ -363,6 +385,7 @@ const FeatureStrategiesEnvironments = () => {
setActiveEnvironment(
featureCache?.environments[tabId]
);
history.replace(history.location.pathname);
}}
indicatorColor="primary"
textColor="primary"

View File

@ -9,6 +9,7 @@ export const useStyles = makeStyles(theme => ({
margin: '0.5rem 0',
display: 'flex',
position: 'relative',
cursor: 'pointer',
width: '100%',
'&:active': {
backgroundColor: theme.palette.primary.main,

View File

@ -0,0 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
borderRadius: '12.5px',
backgroundColor: '#fff',
padding: '2rem',
},
}));

View File

@ -0,0 +1,20 @@
import { useStyles } from './FeatureVariants.styles';
import { useHistory, useParams } from 'react-router';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
import { IFeatureViewParams } from '../../../../interfaces/params';
import EditVariants from '../../variant/update-variant-container';
const FeatureVariants = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const history = useHistory();
return (
<div className={styles.container}>
<EditVariants featureToggle={feature} history={history} editable />
</div>
);
};
export default FeatureVariants;

View File

@ -1,64 +1,46 @@
import { Tabs, Tab } from '@material-ui/core';
import { useEffect } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { Route, useHistory, useParams } from 'react-router-dom';
import useFeature from '../../../hooks/api/getters/useFeature/useFeature';
import useTabs from '../../../hooks/useTabs';
import { IFeatureViewParams } from '../../../interfaces/params';
import TabPanel from '../../common/TabNav/TabPanel';
import FeatureLog from './FeatureLog/FeatureLog';
import FeatureMetrics from './FeatureMetrics/FeatureMetrics';
import FeatureOverview from './FeatureOverview/FeatureOverview';
import FeatureStrategies from './FeatureStrategies/FeatureStrategies';
import FeatureVariants from './FeatureVariants/FeatureVariants';
import { useStyles } from './FeatureView2.styles';
import FeatureViewEnvironment from './FeatureViewEnvironment/FeatureViewEnvironment';
import FeatureViewMetaData from './FeatureViewMetaData/FeatureViewMetaData';
const FeatureView2 = () => {
const { projectId, featureId, activeTab } = useParams<IFeatureViewParams>();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const { a11yProps, activeTabIdx, setActiveTab } = useTabs(0);
const { a11yProps } = useTabs(0);
const styles = useStyles();
const history = useHistory();
const basePath = `/projects/${projectId}/features2/${featureId}`;
useEffect(() => {
const tabIdx = tabData.findIndex(tab => tab.name === activeTab);
setActiveTab(tabIdx);
/* eslint-disable-next-line */
}, []);
const renderOverview = () => {
return (
<div style={{ display: 'flex', width: '100%' }}>
<FeatureViewMetaData />
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
}}
>
{feature?.environments?.map(env => {
return (
<FeatureViewEnvironment env={env} key={env.name} />
);
})}
</div>
</div>
);
};
const tabData = [
{
title: 'Overview',
component: renderOverview(),
path: `${basePath}/overview`,
name: 'overview',
},
{
title: 'Strategies',
component: <FeatureStrategies />,
path: `${basePath}/strategies`,
name: 'strategies',
},
{
title: 'Metrics',
path: `${basePath}/metrics`,
name: 'Metrics',
},
{
title: 'Event log',
path: `${basePath}/logs`,
name: 'Event log',
},
{ title: 'Variants', path: `${basePath}/variants`, name: 'Variants' },
];
const renderTabs = () => {
@ -67,9 +49,9 @@ const FeatureView2 = () => {
<Tab
key={tab.title}
label={tab.title}
value={tab.path}
{...a11yProps(index)}
onClick={() => {
setActiveTab(index);
history.push(tab.path);
}}
className={styles.tabButton}
@ -78,16 +60,6 @@ const FeatureView2 = () => {
});
};
const renderTabContent = () => {
return tabData.map((tab, index) => {
return (
<TabPanel value={activeTabIdx} index={index} key={tab.path}>
{tab.component}
</TabPanel>
);
});
};
return (
<>
<div className={styles.header}>
@ -97,10 +69,7 @@ const FeatureView2 = () => {
<div className={styles.separator} />
<div className={styles.tabContainer}>
<Tabs
value={activeTabIdx}
onChange={(_, tabId) => {
setActiveTab(tabId);
}}
value={history.location.pathname}
indicatorColor="primary"
textColor="primary"
className={styles.tabNavigation}
@ -109,7 +78,26 @@ const FeatureView2 = () => {
</Tabs>
</div>
</div>
{renderTabContent()}
<Route
path={`/projects/:projectId/features2/:featureId/overview`}
component={FeatureOverview}
/>
<Route
path={`/projects/:projectId/features2/:featureId/strategies`}
component={FeatureStrategies}
/>
<Route
path={`/projects/:projectId/features2/:featureId/metrics`}
component={FeatureMetrics}
/>
<Route
path={`/projects/:projectId/features2/:featureId/logs`}
component={FeatureLog}
/>
<Route
path={`/projects/:projectId/features2/:featureId/variants`}
component={FeatureVariants}
/>
</>
);
};

View File

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

View File

@ -6,6 +6,7 @@ import { updateWeight } from '../../common/util';
const mapStateToProps = (state, ownProps) => ({
variants: ownProps.featureToggle.variants || [],
features: state.features.toJS(),
stickinessOptions: [
'default',
...state.context.filter(c => c.stickiness).map(c => c.name),
@ -20,7 +21,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
if (currentVariants.length > 0) {
stickiness = currentVariants[0].stickiness || 'default';
} else {
stickiness = 'default'
stickiness = 'default';
}
variant.stickiness = stickiness;

View File

@ -190,7 +190,7 @@ Array [
"layout": "main",
"menu": Object {},
"parent": "/projects",
"path": "/projects/:projectId/features2/:featureId/:activeTab",
"path": "/projects/:projectId/features2/:featureId",
"title": "FeatureView2",
"type": "protected",
},

View File

@ -222,7 +222,7 @@ export const routes = [
menu: {},
},
{
path: '/projects/:projectId/features2/:featureId/:activeTab',
path: '/projects/:projectId/features2/:featureId',
parent: '/projects',
title: 'FeatureView2',
component: FeatureView2,

View File

@ -1,3 +1,4 @@
import { ITag } from '../../../../interfaces/tags';
import useAPI from '../useApi/useApi';
const useFeatureApi = () => {
@ -5,12 +6,54 @@ const useFeatureApi = () => {
propagateErrors: true,
});
const toggleFeatureEnvironmentOn = async (
projectId: string,
featureId: string,
environmentId: string
) => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/on`;
const req = createRequest(
path,
{ method: 'POST' },
'toggleFeatureEnvironmentOn'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const toggleFeatureEnvironmentOff = async (
projectId: string,
featureId: string,
environmentId: string
) => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/off`;
const req = createRequest(
path,
{ method: 'POST' },
'toggleFeatureEnvironmentOff'
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const changeFeatureProject = async (
projectId: string,
featureName: string,
featureId: string,
newProjectId: string
) => {
const path = `api/admin/projects/${projectId}/features/${featureName}/changeProject`;
const path = `api/admin/projects/${projectId}/features/${featureId}/changeProject`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify({ newProjectId }),
@ -25,7 +68,51 @@ const useFeatureApi = () => {
}
};
return { changeFeatureProject, errors };
const addTag = async (featureId: string, tag: ITag) => {
// TODO: Change this path to the new API when moved.
const path = `api/admin/features/${featureId}/tags`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify({ ...tag }),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteTag = async (
featureId: string,
type: string,
value: string
) => {
// TODO: Change this path to the new API when moved.
const path = `api/admin/features/${featureId}/tags/${type}/${value}`;
const req = createRequest(path, {
method: 'DELETE',
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
changeFeatureProject,
errors,
toggleFeatureEnvironmentOn,
toggleFeatureEnvironmentOff,
addTag,
deleteTag,
};
};
export default useFeatureApi;

View File

@ -15,7 +15,7 @@ interface IUseFeatureOptions {
const useFeature = (
projectId: string,
id: string,
options: IUseFeatureOptions
options: IUseFeatureOptions = {}
) => {
const fetcher = () => {
const path = formatApiPath(

View File

@ -0,0 +1,36 @@
import useSWR, { mutate } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import { ITagType } from '../../../../interfaces/tags';
const useTagTypes = () => {
const fetcher = async () => {
const path = formatApiPath(`api/admin/tag-types`);
const res = await fetch(path, {
method: 'GET',
});
return res.json();
};
const KEY = `api/admin/tag-types`;
const { data, error } = useSWR(KEY, fetcher);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
tagTypes: (data?.tagTypes as ITagType[]) || [],
error,
loading,
refetch,
};
};
export default useTagTypes;

View File

@ -0,0 +1,36 @@
import useSWR, { mutate } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import { ITag } from '../../../../interfaces/tags';
const useTags = (featureId: string) => {
const fetcher = async () => {
const path = formatApiPath(`api/admin/features/${featureId}/tags`);
const res = await fetch(path, {
method: 'GET',
});
return res.json();
};
const KEY = `api/admin/features/${featureId}/tags`;
const { data, error } = useSWR(KEY, fetcher);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
tags: (data?.tags as ITag[]) || [],
error,
loading,
refetch,
};
};
export default useTags;

View File

@ -0,0 +1,10 @@
export interface ITag {
value: string;
type: string;
}
export interface ITagType {
name: string;
description: string;
icon: string;
}