1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-15 17:50:48 +02:00

Merge branch 'master' into feat/API-token-improvement

This commit is contained in:
Youssef Khedher 2021-10-08 13:07:32 +01:00 committed by GitHub
commit 2d94ca707a
91 changed files with 3751 additions and 625 deletions

View File

@ -1,5 +1,5 @@
/* eslint-disable jest/no-conditional-expect */
/// <reference types="cypress" />
// Welcome to Cypress!
//
// This spec file contains a variety of sample tests
@ -14,7 +14,7 @@
let featureToggleName = '';
let enterprise = false;
let strategyId = '';
let defaultEnv = ':global:';
let defaultEnv = 'default';
describe('feature toggle', () => {
before(() => {
@ -242,4 +242,92 @@ describe('feature toggle', () => {
cy.get('[data-test=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@addStrategyToFeature');
});
it('Can add two variant to the feature', () => {
const variantName = 'my-new-variant';
const secondVariantName = 'my-second-variant';
cy.wait(500);
cy.visit(`/projects/default/features2/${featureToggleName}/variants`);
cy.intercept(
'PATCH',
`/api/admin/projects/default/features/${featureToggleName}`,
req => {
if (req.body.length === 1) {
expect(req.body[0].op).to.equal('add');
expect(req.body[0].path).to.match(/variants/);
expect(req.body[0].value.name).to.equal(variantName);
} else if (req.body.length === 2) {
expect(req.body[0].op).to.equal('replace');
expect(req.body[0].path).to.match(/weight/);
expect(req.body[0].value).to.equal(500);
expect(req.body[1].op).to.equal('add');
expect(req.body[1].path).to.match(/variants/);
expect(req.body[1].value.name).to.equal(secondVariantName);
}
}
).as('variantcreation');
cy.get('[data-test=ADD_VARIANT_BUTTON]').click();
cy.get('[data-test=VARIANT_NAME_INPUT]').type(variantName);
cy.get('[data-test=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@variantcreation');
cy.get('[data-test=ADD_VARIANT_BUTTON]').click();
cy.get('[data-test=VARIANT_NAME_INPUT]').type(secondVariantName);
cy.get('[data-test=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@variantcreation');
});
it('Can set weight to fixed value for one of the variants', () => {
const variantName = 'my-new-variant';
cy.wait(500);
cy.visit(`/projects/default/features2/${featureToggleName}/variants`);
cy.get('[data-test=VARIANT_EDIT_BUTTON]').first().click();
cy.get('[data-test=VARIANT_NAME_INPUT]')
.children()
.find('input')
.should('have.attr', 'disabled');
cy.get('[data-test=VARIANT_WEIGHT_TYPE]')
.children()
.find('input')
.check();
cy.get('[data-test=VARIANT_WEIGHT_INPUT]').clear().type('15');
cy.intercept(
'PATCH',
`/api/admin/projects/default/features/${featureToggleName}`,
req => {
expect(req.body[0].op).to.equal('replace');
expect(req.body[0].path).to.match(/weight/);
expect(req.body[0].value).to.equal(850);
expect(req.body[1].op).to.equal('replace');
expect(req.body[1].path).to.match(/weightType/);
expect(req.body[1].value).to.equal('fix');
expect(req.body[2].op).to.equal('replace');
expect(req.body[2].path).to.match(/weight/);
expect(req.body[2].value).to.equal(150);
}
).as('variantupdate');
cy.get('[data-test=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@variantupdate');
cy.get('[data-test=VARIANT_WEIGHT]')
.first()
.should('have.text', '15 %');
});
it(`can delete variant`, () => {
const variantName = 'to-be-deleted';
cy.wait(500);
cy.visit(`/projects/default/features2/${featureToggleName}/variants`);
cy.get('[data-test=ADD_VARIANT_BUTTON]').click();
cy.get('[data-test=VARIANT_NAME_INPUT]').type(variantName);
cy.get('[data-test=DIALOGUE_CONFIRM_ID]').click();
cy.intercept(
'PATCH',
`/api/admin/projects/default/features/${featureToggleName}`,
req => {
const e = req.body.find(e => e.op === 'remove');
expect(e.path).to.match(/variants/);
}
).as('delete');
cy.get(`[data-test=VARIANT_DELETE_BUTTON_${variantName}]`).click();
cy.get('[data-test=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@delete');
});
});

View File

@ -31,13 +31,13 @@
"start:ea": "UNLEASH_API=https://unleash4.herokuapp.com yarn run start",
"test": "react-scripts test",
"prepare": "yarn run build",
"e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env PASSWORD_AUTH=true",
"e2e": "yarn run cypress open --config baseUrl='http://localhost:3000' --env PASSWORD_AUTH=true,AUTH_TOKEN=$AUTH_TOKEN",
"e2e:enterprise": "yarn run cypress open --config baseUrl='http://localhost:3000' --env PASSWORD_AUTH=true,ENTERPRISE=true,AUTH_TOKEN=$AUTH_TOKEN"
},
"devDependencies": {
"@material-ui/core": "4.11.3",
"@material-ui/core": "4.12.3",
"@material-ui/icons": "4.11.2",
"@material-ui/lab": "4.0.0-alpha.57",
"@material-ui/lab": "4.0.0-alpha.60",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "12.1.2",
"@testing-library/user-event": "13.2.1",
@ -45,24 +45,24 @@
"@types/enzyme": "3.10.9",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/jest": "27.0.2",
"@types/node": "14.17.20",
"@types/node": "14.17.21",
"@types/react": "17.0.27",
"@types/react-dom": "17.0.9",
"@types/react-router-dom": "5.3.0",
"@types/react-router-dom": "5.3.1",
"@welldone-software/why-did-you-render": "6.2.1",
"array-move": "3.0.1",
"classnames": "2.3.1",
"craco": "0.0.3",
"css-loader": "6.3.0",
"cypress": "8.5.0",
"date-fns": "2.24.0",
"date-fns": "2.25.0",
"debounce": "1.2.1",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"enzyme-to-json": "3.6.2",
"fetch-mock": "9.11.0",
"http-proxy-middleware": "2.0.1",
"immutable": "4.0.0-rc.15",
"immutable": "4.0.0",
"lodash.clonedeep": "4.5.0",
"lodash.flow": "3.5.0",
"node-fetch": "2.6.5",
@ -82,7 +82,8 @@
"sass": "1.42.1",
"swr": "1.0.1",
"typescript": "4.4.3",
"web-vitals": "2.1.0"
"web-vitals": "2.1.1",
"fast-json-patch": "3.1.0"
},
"jest": {
"moduleNameMapper": {

View File

@ -78,7 +78,7 @@ export const useCommonStyles = makeStyles(theme => ({
bottom: '40px',
transform: 'translateY(400px)',
zIndex: 300,
position: 'relative',
position: 'fixed',
},
fadeInBottomEnter: {
transform: 'translateY(0)',

View File

@ -1,14 +1,13 @@
import { TextField, } from '@material-ui/core';
import { TextField } from '@material-ui/core';
import classNames from 'classnames';
import React, { useState, useEffect } from 'react';
import { styles as commonStyles } from '../../../component/common';
import { IApiTokenCreate } from '../../../hooks/api/actions/useApiTokensApi/useApiTokensApi';
import useEnvironments from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import useProjects from '../../../hooks/api/getters/useProjects/useProjects';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import ConditionallyRender from '../../common/ConditionallyRender';
import Dialogue from '../../common/Dialogue';
import MySelect from '../../common/select';
import GeneralSelect from '../../common/GeneralSelect/GeneralSelect';
import { useStyles } from './styles';
const ALL = '*';
@ -29,8 +28,8 @@ interface IDataError {
const INITIAL_DATA: IApiTokenCreate = {
username: '',
type: TYPE_CLIENT,
project: ALL
}
project: ALL,
};
const ApiTokenCreate = ({
showDialog,
@ -42,19 +41,26 @@ const ApiTokenCreate = ({
const [error, setError] = useState<IDataError>({});
const { projects } = useProjects();
const { environments } = useEnvironments();
const { uiConfig } = useUiConfig();
useEffect(() => {
if(environments && environments.length > 0 && data.type === TYPE_CLIENT && !data.environment) {
setData({...data, environment: environments[0].name})
if (
environments &&
environments.length > 0 &&
data.type === TYPE_CLIENT &&
!data.environment
) {
setData({ ...data, environment: environments[0].name });
}
}, [data, environments]);
const clear = () => {
const environment = environments && environments.length > 0 ? environments[0].name : undefined;
setData({...INITIAL_DATA, environment });
const environment =
environments && environments.length > 0
? environments[0].name
: undefined;
setData({ ...INITIAL_DATA, environment });
setError({});
}
};
const onCancel = (e: Event) => {
clear();
@ -62,75 +68,78 @@ const ApiTokenCreate = ({
};
const isValid = () => {
if(!data.username) {
setError({username: 'Username is required.'});
if (!data.username) {
setError({ username: 'Username is required.' });
return false;
} else {
setError({})
setError({});
return true;
}
}
};
const submit = async () => {
if(!isValid()) {
if (!isValid()) {
return;
}
try {
await createToken(data);
clear();
closeDialog();
} catch (error) {
setError({general: 'Unable to create new API token'});
setError({ general: 'Unable to create new API token' });
}
};
}
const setType = (event: React.ChangeEvent<{value: string }>) => {
const setType = (event: React.ChangeEvent<{ value: string }>) => {
const value = event.target.value;
if(value === TYPE_ADMIN) {
setData({...data, type: value, environment: ALL, project: ALL})
if (value === TYPE_ADMIN) {
setData({ ...data, type: value, environment: ALL, project: ALL });
} else {
setData({...data, type: value, environment: environments[0].name})
setData({
...data,
type: value,
environment: environments[0].name,
});
}
}
};
const setUsername = (event: React.ChangeEvent<{value: string }>) => {
const setUsername = (event: React.ChangeEvent<{ value: string }>) => {
const value = event.target.value;
setData({...data, username: value})
}
setData({ ...data, username: value });
};
const setProject = (event: React.ChangeEvent<{value: string }>) => {
const setProject = (event: React.ChangeEvent<{ value: string }>) => {
const value = event.target.value;
setData({...data, project: value})
}
setData({ ...data, project: value });
};
const setEnvironment = (event: React.ChangeEvent<{value: string }>) => {
const setEnvironment = (event: React.ChangeEvent<{ value: string }>) => {
const value = event.target.value;
setData({...data, environment: value})
}
setData({ ...data, environment: value });
};
const selectableProjects = [{id: '*', name: 'ALL'}, ...projects].map(i => ({
key: i.id,
label: i.name,
title: i.name,
}));
const selectableEnvs = data.type === TYPE_ADMIN ? [{key: '*', label: 'ALL'}] : environments.map(i => ({
key: i.name,
label: i.name,
title: i.name,
}));
const selectableProjects = [{ id: '*', name: 'ALL' }, ...projects].map(
i => ({
key: i.id,
label: i.name,
title: i.name,
})
);
const selectableEnvs =
data.type === TYPE_ADMIN
? [{ key: '*', label: 'ALL' }]
: environments.map(i => ({
key: i.name,
label: i.name,
title: i.name,
}));
const selectableTypes = [
{key: 'CLIENT', label: 'Client', title: 'Client SDK token'},
{key: 'ADMIN', label: 'Admin', title: 'Admin API token'}
]
{ key: 'CLIENT', label: 'Client', title: 'Client SDK token' },
{ key: 'ADMIN', label: 'Admin', title: 'Admin API token' },
];
return (
<Dialogue
@ -142,59 +151,60 @@ const ApiTokenCreate = ({
title="New API token"
>
<form
onSubmit={submit}
className={classNames(
styles.addApiKeyForm,
commonStyles.contentSpacing
)}
>
<TextField
value={data.username}
name="username"
onChange={setUsername}
onBlur={isValid}
label="Username"
style={{ width: '200px' }}
error={error.username !== undefined}
helperText={error.username}
variant="outlined"
size="small"
required
/>
<MySelect
disabled={false}
options={selectableTypes}
value={data.type}
onChange={setType}
label="Token Type"
id='api_key_type'
name="type" className={undefined} classes={undefined}
/>
<ConditionallyRender condition={uiConfig.flags.E} show={
<>
<MySelect
disabled={data.type === TYPE_ADMIN}
options={selectableProjects}
value={data.project}
onChange={setProject}
label="Project"
id='api_key_project'
name="project" className={undefined} classes={undefined}
/>
<MySelect
disabled={data.type === TYPE_ADMIN}
options={selectableEnvs}
value={data.environment}
required
onChange={setEnvironment}
label="Environment"
id='api_key_environment'
name="environment" className={undefined} classes={undefined}
/>
</>
} />
</form>
onSubmit={submit}
className={classNames(
styles.addApiKeyForm,
commonStyles.contentSpacing
)}
>
<TextField
value={data.username}
name="username"
onChange={setUsername}
onBlur={isValid}
label="Username"
style={{ width: '200px' }}
error={error.username !== undefined}
helperText={error.username}
variant="outlined"
size="small"
required
/>
<GeneralSelect
disabled={false}
options={selectableTypes}
value={data.type}
onChange={setType}
label="Token Type"
id="api_key_type"
name="type"
className={undefined}
classes={undefined}
/>
<GeneralSelect
disabled={data.type === TYPE_ADMIN}
options={selectableProjects}
value={data.project}
onChange={setProject}
label="Project"
id="api_key_project"
name="project"
className={undefined}
classes={undefined}
/>
<GeneralSelect
disabled={data.type === TYPE_ADMIN}
options={selectableEnvs}
value={data.environment}
required
onChange={setEnvironment}
label="Environment"
id="api_key_environment"
name="environment"
className={undefined}
classes={undefined}
/>
</form>
</Dialogue>
);
};

View File

@ -53,7 +53,7 @@ exports[`renders correctly with permissions 1`] = `
}
>
<div
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
className="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault"
style={
Object {
"marginRight": "8px",
@ -578,7 +578,7 @@ exports[`renders correctly without permission 1`] = `
}
>
<div
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
className="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault"
style={
Object {
"marginRight": "8px",

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { TextField, Grid } from '@material-ui/core';
import { useCommonStyles } from '../../common.styles';
import icons from './icon-names';
import MySelect from '../common/select';
import GeneralSelect from '../common/GeneralSelect/GeneralSelect';
function ApplicationUpdate({ application, storeApplicationMetaData }) {
const { appName, icon, url, description } = application;
@ -15,11 +15,17 @@ function ApplicationUpdate({ application, storeApplicationMetaData }) {
<Grid container style={{ marginTop: '1rem' }}>
<Grid item sm={12} xs={12} className={commonStyles.contentSpacingY}>
<Grid item>
<MySelect
<GeneralSelect
label="Icon"
options={icons.map(v => ({ key: v, label: v }))}
value={icon || 'apps'}
onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)}
onChange={e =>
storeApplicationMetaData(
appName,
'icon',
e.target.value
)
}
/>
</Grid>
<Grid item>
@ -31,7 +37,9 @@ function ApplicationUpdate({ application, storeApplicationMetaData }) {
type="url"
variant="outlined"
size="small"
onBlur={() => storeApplicationMetaData(appName, 'url', localUrl)}
onBlur={() =>
storeApplicationMetaData(appName, 'url', localUrl)
}
/>
</Grid>
<Grid item>
@ -42,7 +50,13 @@ function ApplicationUpdate({ application, storeApplicationMetaData }) {
size="small"
rows={2}
onChange={e => setLocalDescription(e.target.value)}
onBlur={() => storeApplicationMetaData(appName, 'description', localDescription)}
onBlur={() =>
storeApplicationMetaData(
appName,
'description',
localDescription
)
}
/>
</Grid>
</Grid>

View File

@ -16,7 +16,6 @@ const BreadcrumbNav = () => {
item =>
item !== 'create' &&
item !== 'edit' &&
item !== 'access' &&
item !== 'view' &&
item !== 'variants' &&
item !== 'logs' &&

View File

@ -15,7 +15,7 @@ interface IDialogue {
primaryButtonText?: string;
secondaryButtonText?: string;
open: boolean;
onClick: () => void;
onClick: (e: any) => void;
onClose: () => void;
style?: object;
title: string;

View File

@ -0,0 +1,71 @@
import React from 'react';
import { FormControl, InputLabel, MenuItem, Select } from '@material-ui/core';
import { SELECT_ITEM_ID } from '../../../testIds';
export interface ISelectOption {
key: string;
title?: string;
label?: string;
}
export interface ISelectMenuProps {
name: string;
id: string;
value?: string;
label?: string;
options: ISelectOption[];
style?: object;
onChange?: (
event: React.ChangeEvent<{ name?: string; value: unknown }>,
child: React.ReactNode
) => void;
disabled?: boolean;
className?: string;
classes?: any;
}
const GeneralSelect: React.FC<ISelectMenuProps> = ({
name,
value = '',
label = '',
options,
onChange,
id,
disabled = false,
className,
classes,
...rest
}) => {
const renderSelectItems = () =>
options.map(option => (
<MenuItem
key={option.key}
value={option.key}
title={option.title || ''}
data-test={`${SELECT_ITEM_ID}-${option.label}`}
>
{option.label}
</MenuItem>
));
return (
<FormControl variant="outlined" size="small" classes={classes}>
<InputLabel htmlFor={id} id={id}>
{label}
</InputLabel>
<Select
name={name}
disabled={disabled}
onChange={onChange}
className={className}
label={label}
id={id}
value={value}
{...rest}
>
{renderSelectItems()}
</Select>
</FormControl>
);
};
export default GeneralSelect;

View File

@ -1,9 +1,8 @@
import { TextField } from '@material-ui/core';
import { useStyles } from './Input.styles.ts';
interface IInputProps {
interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
placeholder?: string;
error?: boolean;
errorText?: string;
style?: Object;

View File

@ -0,0 +1,53 @@
import { Button, Tooltip } from '@material-ui/core';
import { OverridableComponent } from '@material-ui/core/OverridableComponent';
import { Lock } from '@material-ui/icons';
import { useContext } from 'react';
import AccessContext from '../../../contexts/AccessContext';
import ConditionallyRender from '../ConditionallyRender';
interface IPermissionIconButtonProps extends OverridableComponent<any> {
permission: string;
tooltip: string;
onClick?: (e: any) => void;
disabled?: boolean;
}
const PermissionButton: React.FC<IPermissionIconButtonProps> = ({
permission,
tooltip = 'Click to perform action',
onClick,
children,
disabled,
...rest
}) => {
const { hasAccess } = useContext(AccessContext);
const access = hasAccess(permission);
const tooltipText = access
? tooltip
: "You don't have access to perform this operation";
return (
<Tooltip title={tooltipText} arrow>
<span>
<Button
onClick={onClick}
disabled={disabled || !access}
variant="contained"
color="primary"
{...rest}
endIcon={
<ConditionallyRender
condition={!access}
show={<Lock />}
/>
}
>
{children}
</Button>
</span>
</Tooltip>
);
};
export default PermissionButton;

View File

@ -0,0 +1,39 @@
import { IconButton, Tooltip } from '@material-ui/core';
import { OverridableComponent } from '@material-ui/core/OverridableComponent';
import { useContext } from 'react';
import AccessContext from '../../../contexts/AccessContext';
interface IPermissionIconButtonProps extends OverridableComponent<any> {
permission: string;
Icon: React.ElementType;
tooltip: string;
onClick?: (e: any) => void;
}
const PermissionIconButton: React.FC<IPermissionIconButtonProps> = ({
permission,
Icon,
tooltip,
onClick,
children,
...rest
}) => {
const { hasAccess } = useContext(AccessContext);
const access = hasAccess(permission);
const tooltipText = access
? tooltip
: "You don't have access to perform this operation";
return (
<Tooltip title={tooltipText} arrow>
<span>
<IconButton onClick={onClick} disabled={!access} {...rest}>
{children}
</IconButton>
</span>
</Tooltip>
);
};
export default PermissionIconButton;

View File

@ -1,11 +1,14 @@
import { IconButton, Tooltip, Button, useMediaQuery } from '@material-ui/core';
import { useMediaQuery } from '@material-ui/core';
import ConditionallyRender from '../ConditionallyRender';
import PermissionButton from '../PermissionButton/PermissionButton';
import PermissionIconButton from '../PermissionIconButton/PermissionIconButton';
interface IResponsiveButtonProps {
Icon: React.ElementType;
onClick: () => void;
tooltip?: string;
disabled?: boolean;
permission?: string;
maxWidth: string;
}
@ -16,35 +19,39 @@ const ResponsiveButton: React.FC<IResponsiveButtonProps> = ({
tooltip,
disabled = false,
children,
permission,
...rest
}) => {
const smallScreen = useMediaQuery(`(max-width:${maxWidth})`);
return (
<Tooltip title={tooltip ? tooltip : ''} arrow>
<span>
<ConditionallyRender
condition={smallScreen}
show={
<IconButton disabled={disabled} onClick={onClick} data-loading {...rest}>
<Icon />
</IconButton>
}
elseShow={
<Button
onClick={onClick}
color="primary"
variant="contained"
disabled={disabled}
data-loading
{...rest}
>
{children}
</Button>
}
/>
</span>
</Tooltip>
<ConditionallyRender
condition={smallScreen}
show={
<PermissionIconButton
disabled={disabled}
onClick={onClick}
permission={permission}
data-loading
{...rest}
>
<Icon />
</PermissionIconButton>
}
elseShow={
<PermissionButton
onClick={onClick}
permission={permission}
color="primary"
variant="contained"
disabled={disabled}
data-loading
{...rest}
>
{children}
</PermissionButton>
}
/>
);
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import GeneralSelect from '../GeneralSelect/GeneralSelect';
import useTagTypes from '../../../hooks/api/getters/useTagTypes/useTagTypes';
interface ITagSelect extends React.SelectHTMLAttributes<HTMLSelectElement> {
value: string;
onChange: (val: any) => void;
}
const TagSelect = ({ value, types, onChange, ...rest }: ITagSelect) => {
const { tagTypes } = useTagTypes();
const options = tagTypes.map(tagType => ({
key: tagType.name,
label: tagType.name,
title: tagType.name,
}));
return (
<GeneralSelect
label="Tag type"
name="tag-select"
id="tag-select"
options={options}
value={value}
onChange={onChange}
{...rest}
/>
);
};
export default TagSelect;

View File

@ -72,7 +72,7 @@ export function updateWeight(variants, totalWeight) {
}
if (!variableVariantCount) {
throw new Error('There must be atleast one variable variant');
throw new Error('There must be at least one variable variant');
}
const percentage = parseInt(remainingPercentage / variableVariantCount);

View File

@ -12,6 +12,8 @@ import useLoading from '../../../hooks/useLoading';
import useToast from '../../../hooks/useToast';
import EnvironmentTypeSelector from '../form/EnvironmentTypeSelector/EnvironmentTypeSelector';
import Input from '../../common/Input/Input';
import useEnvironments from '../../../hooks/api/getters/useEnvironments/useEnvironments';
import { Alert } from '@material-ui/lab';
const NAME_EXISTS_ERROR = 'Error: Environment';
@ -23,6 +25,7 @@ const CreateEnvironment = () => {
const history = useHistory();
const styles = useStyles();
const { validateEnvName, createEnvironment, loading } = useEnvironmentApi();
const { environments } = useEnvironments();
const ref = useLoading(loading);
const { toast, setToastData } = useToast();
@ -36,6 +39,8 @@ const CreateEnvironment = () => {
const goBack = () => history.goBack();
const canCreateMoreEnvs = environments.length < 5;
const validateEnvironmentName = async () => {
if (envName.length === 0) {
setNameError('Environment Id can not be empty.');
@ -85,6 +90,7 @@ const CreateEnvironment = () => {
/>
}
elseShow={
<ConditionallyRender condition={canCreateMoreEnvs} show={
<div ref={ref}>
<p className={styles.helperText} data-loading>
Environments allow you to manage your product
@ -152,6 +158,16 @@ const CreateEnvironment = () => {
</div>
</form>
</div>
} elseShow={
<>
<Alert severity="error">
<p>Currently Unleash does not support more than 5 environments. If you need more please reach out.</p>
</Alert>
<br />
<Button onClick={goBack} variant="contained" color="primary">Go back</Button>
</>
} />
}
/>
{toast}

View File

@ -0,0 +1,76 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
borderRadius: '10px',
backgroundColor: '#fff',
padding: '2rem 2rem 2rem 2rem',
marginBottom: '1rem',
flexDirection: 'column',
width: '50%',
position: 'relative',
},
[theme.breakpoints.down(1000)]: {
container: {
width: '100%',
},
},
headerContainer: {
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
margin: 0,
},
bodyContainer: {
display: 'flex',
align: 'items',
marginTop: '1rem',
height: '100%',
},
trueCountContainer: {
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
},
trueCount: {
width: '10px',
height: '10px',
borderRadius: '50%',
marginRight: '0.75rem',
backgroundColor: theme.palette.primary.main,
},
falseCount: {
width: '10px',
height: '10px',
borderRadius: '50%',
marginRight: '0.75rem',
backgroundColor: theme.palette.grey[300],
},
paragraph: {
fontSize: theme.fontSizes.smallBody,
},
textContainer: {
marginRight: '1rem',
maxWidth: '150px',
},
primaryMetric: {
width: '100%',
},
icon: {
fill: theme.palette.grey[300],
height: '75px',
width: '75px',
},
chartContainer: {
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
}));

View File

@ -0,0 +1,114 @@
import classNames from 'classnames';
import PercentageCircle from '../../../../common/PercentageCircle/PercentageCircle';
import { useStyles } from './FeatureEnvironmentMetrics.styles';
import { IEnvironmentMetrics } from '../../../../../interfaces/environments';
import PieChartIcon from '@material-ui/icons/PieChart';
import { useMediaQuery } from '@material-ui/core';
interface IFeatureEnvironmentProps {
className?: string;
primaryMetric?: boolean;
metric: IEnvironmentMetrics;
}
const FeatureEnvironmentMetrics = ({
className,
primaryMetric,
metric,
}: IFeatureEnvironmentProps) => {
const styles = useStyles();
const smallScreen = useMediaQuery(`(max-width:1000px)`);
const containerClasses = classNames(styles.container, className, {
[styles.primaryMetric]: primaryMetric,
});
const calculatePercentage = () => {
const total = metric.yes + metric.no;
if (total === 0) {
return 0;
}
return Math.round((metric.yes / total) * 100);
};
let primaryStyles = {};
if (primaryMetric) {
if (smallScreen) {
primaryStyles = {
width: '60px',
height: '60px',
};
} else {
primaryStyles = {
width: '120px',
height: '120px',
};
}
}
if (metric.yes === 0 && metric.no === 0) {
return (
<div className={containerClasses}>
<div className={styles.headerContainer}>
<h2 className={styles.title}>Traffic in {metric.name}</h2>
</div>
<div className={styles.bodyContainer}>
<div className={styles.textContainer}>
<p className={styles.paragraph}>
No metrics available for this environment.
</p>
</div>
<div className={styles.chartContainer}>
<PieChartIcon className={styles.icon} />
</div>
</div>
</div>
);
}
return (
<div className={containerClasses}>
<div className={styles.headerContainer}>
<h2 className={styles.title}>Traffic in {metric.name}</h2>
</div>
<div className={styles.bodyContainer}>
<div className={styles.textContainer}>
<div className={styles.trueCountContainer}>
<div>
<div className={styles.trueCount} />
</div>
<p className={styles.paragraph}>
{metric.yes} users received this feature
</p>
</div>
<div className={styles.trueCountContainer}>
<div>
<div className={styles.falseCount} />
</div>
<p className={styles.paragraph}>
{metric.no} users did not receive this feature
</p>
</div>
</div>
<div className={styles.chartContainer}>
<PercentageCircle
percentage={calculatePercentage()}
styles={{
height: '60px',
width: '60px',
marginLeft: '1rem',
...primaryStyles,
}}
/>
</div>
</div>
</div>
);
};
export default FeatureEnvironmentMetrics;

View File

@ -2,9 +2,21 @@ import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: { display: 'flex', width: '100%' },
[theme.breakpoints.down(800)]: {
container: {
flexDirection: 'column',
},
trafficContainer: {
marginTop: '1rem',
},
},
mainContent: {
display: 'flex',
flexDirection: 'column',
width: '100%',
},
trafficContainer: {
display: 'flex',
flexWrap: 'wrap',
},
}));

View File

@ -1,17 +1,25 @@
import FeatureViewMetaData from './FeatureViewMetaData/FeatureViewMetaData';
import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData';
import FeatureOverviewStrategies from './FeatureOverviewStrategies/FeatureOverviewStrategies';
import { useStyles } from './FeatureOverview.styles';
import FeatureOverviewTags from './FeatureOverviewTags/FeatureOverviewTags';
import FeatureViewStale from './FeatureOverviewStale/FeatureOverviewStale';
import { useStyles } from './FeatureOverview.styles';
import FeatureOverviewMetrics from './FeatureOverviewMetrics/FeatureOverviewMetrics';
const FeatureOverview = () => {
const styles = useStyles();
return (
<div className={styles.container}>
<div className={styles.sidebar}>
<FeatureViewMetaData />
<div>
<FeatureOverviewMetaData />
<FeatureViewStale />
<FeatureOverviewTags />
</div>
<div className={styles.mainContent}>
<div className={styles.trafficContainer}>
<FeatureOverviewMetrics />
</div>
<FeatureOverviewStrategies />
</div>
</div>

View File

@ -1,15 +1,16 @@
import { capitalize, IconButton } from '@material-ui/core';
import classnames from 'classnames';
import { useParams } from 'react-router-dom';
import { Link } 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 { useStyles } from './FeatureOverviewMetadata.styles';
import { Edit } from '@material-ui/icons';
import { IFeatureViewParams } from '../../../../../interfaces/params';
const FeatureViewMetaData = () => {
const FeatureOverviewMetaData = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
@ -30,13 +31,16 @@ const FeatureViewMetaData = () => {
<div className={styles.body}>
<span className={styles.bodyItem}>Project: {project}</span>
<ConditionallyRender
condition
condition={description}
show={
<span className={styles.bodyItem}>
<div>Description:</div>
<div className={styles.descriptionContainer}>
<p>{description}</p>
<IconButton>
<IconButton
component={Link}
to={`/projects/${projectId}/features2/${featureId}/settings`}
>
<Edit />
</IconButton>
</div>
@ -44,10 +48,15 @@ const FeatureViewMetaData = () => {
}
elseShow={
<span>
No description.{' '}
<IconButton>
<Edit />
</IconButton>
<div className={styles.descriptionContainer}>
No description.{' '}
<IconButton
component={Link}
to={`/projects/${projectId}/features2/${featureId}/settings`}
>
<Edit />
</IconButton>
</div>
</span>
}
/>
@ -56,4 +65,4 @@ const FeatureViewMetaData = () => {
);
};
export default FeatureViewMetaData;
export default FeatureOverviewMetaData;

View File

@ -11,6 +11,12 @@ export const useStyles = makeStyles(theme => ({
minWidth: '350px',
marginRight: '1rem',
},
[theme.breakpoints.down(800)]: {
container: {
width: '100%',
maxWidth: 'none',
},
},
metaDataHeader: {
display: 'flex',
alignItems: 'center',

View File

@ -0,0 +1,11 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
firstContainer: {
width: 'calc(50% - 1rem)',
marginRight: '1rem',
},
[theme.breakpoints.down(1000)]: {
firstContainer: { width: '100%', marginRight: '0' },
},
}));

View File

@ -0,0 +1,141 @@
import { useParams } from 'react-router';
import { useState, useEffect } from 'react';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import { IEnvironmentMetrics } from '../../../../../interfaces/environments';
import FeatureEnvironmentMetrics from '../FeatureEnvironmentMetrics/FeatureEnvironmentMetrics';
import { useStyles } from './FeatureOverviewMetrics.styles';
const data = {
version: 1,
maturity: 'experimental',
lastHourUsage: [
{
environment: 'default',
timestamp: '2021-10-07 10:00:00',
yes: 250,
no: 60,
},
{
environment: 'production',
timestamp: '2021-10-07 10:00:00',
yes: 200,
no: 500,
},
{
environment: 'development',
timestamp: '2021-10-07 10:00:00',
yes: 0,
no: 0,
},
],
seenApplications: ['web', 'backend-api', 'commerce'],
};
const FeatureOverviewMetrics = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const [featureMetrics, setFeatureMetrics] = useState<IEnvironmentMetrics[]>(
[]
);
useEffect(() => {
const featureMetricList = feature?.environments.map(env => {
const metrics = data.lastHourUsage.find(
metric => metric.environment === env.name
);
if (!metrics) {
return {
name: env.name,
yes: 0,
no: 0,
timestamp: '',
};
}
return {
name: env.name,
yes: metrics.yes,
no: metrics.no,
timestamp: metrics.timestamp,
};
});
setFeatureMetrics(featureMetricList);
/* Update on useSWR metrics change */
/* eslint-disable-next-line */
}, []);
const renderFeatureMetrics = () => {
if (featureMetrics.length === 0) {
return null;
}
if (featureMetrics.length === 1) {
return (
<FeatureEnvironmentMetrics
primaryMetric
metric={featureMetrics[0]}
/>
);
}
if (featureMetrics.length === 2) {
return featureMetrics.map((metric, index) => {
if (index === 0) {
return (
<FeatureEnvironmentMetrics
className={styles.firstContainer}
key={metric.name}
metric={metric}
/>
);
}
return (
<FeatureEnvironmentMetrics
key={metric.name}
metric={metric}
/>
);
});
}
/* We display maxium three environments metrics */
if (featureMetrics.length >= 3) {
return featureMetrics.slice(0, 3).map((metric, index) => {
if (index === 0) {
return (
<FeatureEnvironmentMetrics
primaryMetric
key={metric.name}
metric={metric}
/>
);
}
if (index === 1) {
return (
<FeatureEnvironmentMetrics
className={styles.firstContainer}
key={metric.name}
metric={metric}
/>
);
}
return (
<FeatureEnvironmentMetrics
key={metric.name}
metric={metric}
/>
);
});
}
};
return renderFeatureMetrics();
};
export default FeatureOverviewMetrics;

View File

@ -0,0 +1,59 @@
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',
},
[theme.breakpoints.down(800)]: {
container: {
width: '100%',
maxWidth: 'none',
},
},
staleHeaderContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '1rem',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
},
staleHeader: {
display: 'flex',
alignItems: 'center',
},
header: {
fontSize: theme.fontSizes.subHeader,
fontWeight: 'normal',
margin: 0,
},
body: {
display: 'flex',
flexDirection: 'column',
padding: '1rem',
},
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],
},
staleButton: {
display: 'flex',
},
}));

View File

@ -0,0 +1,50 @@
import { useStyles } from './FeatureOverviewStale.styles';
import classnames from 'classnames';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import { useParams } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import PermissionIconButton from '../../../../common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE } from '../../../../AccessProvider/permissions';
import { Check, Close } from '@material-ui/icons';
import { useState } from 'react';
import StaleDialog from './StaleDialog/StaleDialog';
const FeatureOverviewStale = () => {
const styles = useStyles();
const [openStaleDialog, setOpenStaleDialog] = useState(false);
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const FlipStateButton = () => (feature.stale ? <Close /> : <Check />);
return (
<div className={classnames(styles.container)}>
<div className={styles.staleHeaderContainer}>
<div className={styles.staleHeader}>
<h3 className={styles.header}>Status</h3>
</div>
</div>
<div className={styles.body}>
<span className={styles.bodyItem}>
Feature is {feature.stale ? 'stale' : 'active'}
</span>
<div className={styles.staleButton}>
<PermissionIconButton
onClick={() => setOpenStaleDialog(true)}
permission={UPDATE_FEATURE}
tooltip="Flip status"
>
<FlipStateButton />
</PermissionIconButton>
</div>
</div>
<StaleDialog
stale={feature.stale}
open={openStaleDialog}
setOpen={setOpenStaleDialog}
/>
</div>
);
};
export default FeatureOverviewStale;

View File

@ -0,0 +1,67 @@
import useFeatureApi from '../../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import { useParams } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
import { DialogContentText } from '@material-ui/core';
import ConditionallyRender from '../../../../../common/ConditionallyRender/ConditionallyRender';
import Dialogue from '../../../../../common/Dialogue';
import useFeature from '../../../../../../hooks/api/getters/useFeature/useFeature';
interface IStaleDialogProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
stale: boolean;
}
const StaleDialog = ({ open, setOpen, stale }: IStaleDialogProps) => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { patchFeatureToggle } = useFeatureApi();
const { refetch } = useFeature(projectId, featureId);
const toggleToStaleContent = (
<DialogContentText>
Setting a toggle to stale marks it for cleanup
</DialogContentText>
);
const toggleToActiveContent = (
<DialogContentText>
Setting a toggle to active marks it as in active use
</DialogContentText>
);
const toggleActionText = stale ? 'active' : 'stale';
const onSubmit = async e => {
e.stopPropagation();
const patch = [{ op: 'replace', path: '/stale', value: !stale }];
await patchFeatureToggle(projectId, featureId, patch);
refetch();
setOpen(false);
};
const onCancel = () => {
setOpen(false);
};
return (
<>
<Dialogue
open={open}
secondaryButtonText={'Cancel'}
primaryButtonText={`Flip to ${toggleActionText}`}
title={`Set feature status to ${toggleActionText}`}
onClick={onSubmit}
onClose={onCancel}
>
<>
<ConditionallyRender
condition={stale}
show={toggleToActiveContent}
elseShow={toggleToStaleContent}
/>
</>
</Dialogue>
</>
);
};
export default StaleDialog;

View File

@ -1,77 +1,10 @@
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',
padding: '0.25rem 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

@ -1,14 +1,9 @@
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 { useHistory, useParams } from 'react-router-dom';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
import useToast from '../../../../../../hooks/useToast';
import FeatureViewEnvironment from '../../../FeatureViewEnvironment/FeatureViewEnvironment';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
@ -20,13 +15,9 @@ const FeatureOverviewEnvironment = ({
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 styles = useStyles();
const history = useHistory();
const handleClick = () => {
history.push(
@ -48,125 +39,12 @@ const FeatureOverviewEnvironment = ({
});
};
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>
<FeatureViewEnvironment env={env}>
<div className={styles.strategiesContainer}>
{renderStrategies()}
</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>
</FeatureViewEnvironment>
);
};

View File

@ -12,6 +12,7 @@ export const useStyles = makeStyles(theme => ({
display: 'flex',
alignItems: 'center',
padding: '0.75rem',
cursor: 'pointer',
fontSize: theme.fontSizes.bodySize,
},
cardHeader: {

View File

@ -2,6 +2,7 @@ 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 { UPDATE_FEATURE } from '../../../../AccessProvider/permissions';
import ResponsiveButton from '../../../../common/ResponsiveButton/ResponsiveButton';
import FeatureOverviewEnvironment from './FeatureOverviewEnvironment/FeatureOverviewEnvironment';
import { useStyles } from './FeatureOverviewStrategies.styles';
@ -9,7 +10,7 @@ import { useStyles } from './FeatureOverviewStrategies.styles';
const FeatureOverviewStrategies = () => {
const styles = useStyles();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature, refetch } = useFeature(projectId, featureId);
const { feature } = useFeature(projectId, featureId);
if (!feature) return null;
@ -17,13 +18,7 @@ const FeatureOverviewStrategies = () => {
const renderEnvironments = () => {
return environments?.map(env => {
return (
<FeatureOverviewEnvironment
env={env}
key={env.name}
refetch={refetch}
/>
);
return <FeatureOverviewEnvironment env={env} key={env.name} />;
});
};
@ -35,7 +30,9 @@ const FeatureOverviewStrategies = () => {
<div className={styles.actions}>
<ResponsiveButton
maxWidth="700px"
permission={UPDATE_FEATURE}
Icon={Add}
tooltip="Add new strategy"
className={styles.addStrategyButton}
component={Link}
to={`/projects/${projectId}/features2/${featureId}/strategies?addStrategy=true`}

View File

@ -0,0 +1,9 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
dialogFormContent: {
['& > *']: {
margin: '0.5rem 0',
},
},
}));

View File

@ -0,0 +1,111 @@
import { DialogContentText } from '@material-ui/core';
import { useParams } from 'react-router';
import { useState } from 'react';
import { IFeatureViewParams } from '../../../../../../interfaces/params';
import Dialogue from '../../../../../common/Dialogue';
import Input from '../../../../../common/Input/Input';
import { SyntheticEvent } from 'react-router/node_modules/@types/react';
import { useStyles } from './AddTagDialog.styles';
import { trim } from '../../../../../common/util';
import TagSelect from '../../../../../common/TagSelect/TagSelect';
import useFeatureApi from '../../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useTags from '../../../../../../hooks/api/getters/useTags/useTags';
import useToast from '../../../../../../hooks/useToast';
interface IAddTagDialogProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
interface IDefaultTag {
type: string;
value: string;
[index: string]: string;
}
const AddTagDialog = ({ open, setOpen }: IAddTagDialogProps) => {
const DEFAULT_TAG: IDefaultTag = { type: 'simple', value: '' };
const styles = useStyles();
const { featureId } = useParams<IFeatureViewParams>();
const { addTagToFeature } = useFeatureApi();
const { refetch } = useTags(featureId);
const [errors, setErrors] = useState({ tagError: '' });
const { toast, setToastData } = useToast();
const [tag, setTag] = useState(DEFAULT_TAG);
const onCancel = () => {
setOpen(false);
setErrors({ tagError: '' });
setTag(DEFAULT_TAG);
};
const setValue = (field: string, value: string) => {
const newTag = { ...tag };
newTag[field] = trim(value);
setTag(newTag);
};
const onSubmit = async (evt: SyntheticEvent) => {
evt.preventDefault();
if (!tag.type) {
tag.type = 'simple';
}
try {
await addTagToFeature(featureId, tag);
setOpen(false);
setTag(DEFAULT_TAG);
refetch();
setToastData({
type: 'success',
show: true,
text: 'Successfully created tag',
});
} catch (e) {
setErrors({ tagError: e.message });
}
};
return (
<>
<Dialogue
open={open}
secondaryButtonText="Cancel"
primaryButtonText="Add tag"
title="Add tags to feature toggle"
onClick={onSubmit}
onClose={onCancel}
>
<>
<DialogContentText>
Tags allows you to group features together
</DialogContentText>
<form onSubmit={onSubmit}>
<section className={styles.dialogFormContent}>
<TagSelect
name="type"
value={tag.type}
onChange={e => setValue('type', e.target.value)}
/>
<br />
<Input
label="Value"
name="value"
placeholder="Your tag"
value={tag.value}
error={Boolean(errors.tagError)}
errorText={errors.tagError}
onChange={e =>
setValue('value', e.target.value)
}
/>
</section>
</form>
</>
</Dialogue>
{toast}
</>
);
};
export default AddTagDialog;

View File

@ -12,10 +12,16 @@ export const useStyles = makeStyles(theme => ({
marginRight: '1rem',
marginTop: '1rem',
},
[theme.breakpoints.down(800)]: {
container: {
width: '100%',
maxWidth: 'none',
},
},
tagheaderContainer: {
display: 'flex',
alignItems: 'center',
padding: '1.5rem',
padding: '0.5rem 1rem',
justifyContent: 'space-between',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
},
@ -37,4 +43,9 @@ export const useStyles = makeStyles(theme => ({
tagContent: {
padding: '1.5rem',
},
tagChip: {
marginRight: '0.25rem',
marginTop: '0.5rem',
fontSize: theme.fontSizes.smallBody,
},
}));

View File

@ -1,5 +1,5 @@
import { Chip } from '@material-ui/core';
import { Label } from '@material-ui/icons';
import { Add, Label } from '@material-ui/icons';
import { useParams } from 'react-router-dom';
import useTags from '../../../../../hooks/api/getters/useTags/useTags';
import { IFeatureViewParams } from '../../../../../interfaces/params';
@ -11,22 +11,48 @@ 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';
import AddTagDialog from './AddTagDialog/AddTagDialog';
import { useState } from 'react';
import Dialogue from '../../../../common/Dialogue';
import { ITag } from '../../../../../interfaces/tags';
import useToast from '../../../../../hooks/useToast';
import { UPDATE_FEATURE } from '../../../../AccessProvider/permissions';
import PermissionIconButton from '../../../../common/PermissionIconButton/PermissionIconButton';
import ConditionallyRender from '../../../../common/ConditionallyRender';
const FeatureOverviewTags = () => {
const [openTagDialog, setOpenTagDialog] = useState(false);
const [showDelDialog, setShowDelDialog] = useState(false);
const [selectedTag, setSelectedTag] = useState<ITag>({
value: '',
type: '',
});
const styles = useStyles();
const { featureId } = useParams<IFeatureViewParams>();
const { tags, refetch } = useTags(featureId);
const { tagTypes } = useTagTypes();
const { deleteTag } = useFeatureApi();
const { deleteTagFromFeature } = useFeatureApi();
const { toast, setToastData } = useToast();
const handleDelete = async (type: string, value: string) => {
const handleDelete = async () => {
try {
await deleteTag(featureId, type, value);
await deleteTagFromFeature(
featureId,
selectedTag.type,
selectedTag.value
);
refetch();
setToastData({
type: 'success',
show: true,
text: 'Successfully deleted tag',
});
} catch (e) {
// TODO: Handle error
console.log(e);
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
@ -72,10 +98,13 @@ const FeatureOverviewTags = () => {
const renderTag = t => (
<Chip
icon={tagIcon(t.type)}
style={{ marginRight: '3px', fontSize: '0.8em' }}
className={styles.tagChip}
label={t.value}
key={`${t.type}:${t.value}`}
onDelete={() => handleDelete(t.type, t.value)}
onDelete={() => {
setShowDelDialog(true);
setSelectedTag({ type: t.type, value: t.value });
}}
/>
);
@ -83,17 +112,41 @@ const FeatureOverviewTags = () => {
<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>
<AddTagDialog open={openTagDialog} setOpen={setOpenTagDialog} />
<PermissionIconButton
onClick={() => setOpenTagDialog(true)}
permission={UPDATE_FEATURE}
tooltip="Add tag"
>
<Add />
</IconButton> */}
</PermissionIconButton>
</div>
<div className={styles.tagContent}>{tags.map(renderTag)}</div>
<Dialogue
open={showDelDialog}
onClose={() => {
setShowDelDialog(false);
setSelectedTag({ type: '', value: '' });
}}
onClick={() => {
setShowDelDialog(false);
handleDelete();
setSelectedTag({ type: '', value: '' });
}}
title="Are you sure you want to delete this tag?"
/>
<div className={styles.tagContent}>
<ConditionallyRender
condition={tags.length > 0}
show={tags.map(renderTag)}
elseShow={<p>No tags to display</p>}
/>
</div>
{toast}
</div>
);
};

View File

@ -0,0 +1,27 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
innerContainer: {
display: 'flex',
},
bodyContainer: {
padding: 0,
},
listContainer: {
width: '20%',
borderRight: `1px solid ${theme.palette.grey[300]}`,
padding: '1rem 0',
},
listItem: {
padding: '0.75rem 2rem',
},
innerBodyContainer: {
padding: '2rem',
display: 'flex',
flexDirection: 'column',
width: '350px',
['& > *']: {
margin: '0.5rem 0',
},
},
}));

View File

@ -0,0 +1,55 @@
import { useState } from 'react';
import PageContent from '../../../common/PageContent';
import { useStyles } from './FeatureSettings.styles';
import { List, ListItem } from '@material-ui/core';
import ConditionallyRender from '../../../common/ConditionallyRender';
import FeatureSettingsMetadata from './FeatureSettingsMetadata/FeatureSettingsMetadata';
import FeatureSettingsProject from './FeatureSettingsProject/FeatureSettingsProject';
const METADATA = 'metadata';
const PROJECT = 'project';
const FeatureSettings = () => {
const styles = useStyles();
const [settings, setSettings] = useState(METADATA);
return (
<PageContent headerContent="Settings" bodyClass={styles.bodyContainer}>
<div className={styles.innerContainer}>
<div className={styles.listContainer}>
<List className={styles.list}>
<ListItem
className={styles.listItem}
button
onClick={() => setSettings(METADATA)}
>
Metadata
</ListItem>
<ListItem
className={styles.listItem}
button
onClick={() => setSettings(PROJECT)}
>
Project
</ListItem>
</List>
</div>
<div className={styles.innerBodyContainer}>
<ConditionallyRender
condition={settings === METADATA}
show={<FeatureSettingsMetadata />}
/>
<ConditionallyRender
condition={settings === PROJECT}
show={<FeatureSettingsProject />}
/>
</div>
</div>
</PageContent>
);
};
export default FeatureSettings;

View File

@ -0,0 +1,103 @@
import { useState, useEffect, useContext } from 'react';
import * as jsonpatch from 'fast-json-patch';
import { TextField } from '@material-ui/core';
import PermissionButton from '../../../../common/PermissionButton/PermissionButton';
import FeatureTypeSelect from './FeatureTypeSelect/FeatureTypeSelect';
import { useParams } from 'react-router';
import AccessContext from '../../../../../contexts/AccessContext';
import { UPDATE_FEATURE } from '../../../../AccessProvider/permissions';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import useToast from '../../../../../hooks/useToast';
import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import ConditionallyRender from '../../../../common/ConditionallyRender';
const FeatureSettingsMetadata = () => {
const { hasAccess } = useContext(AccessContext);
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { refetch, feature } = useFeature(projectId, featureId);
const [description, setDescription] = useState(feature.description);
const [type, setType] = useState(feature.type);
const editable = hasAccess(UPDATE_FEATURE, projectId);
const { toast, setToastData } = useToast();
const [dirty, setDirty] = useState(false);
const { patchFeatureToggle } = useFeatureApi();
useEffect(() => {
setType(feature.type);
setDescription(feature.description);
}, [feature]);
useEffect(() => {
if (description !== feature.description || type !== feature.type) {
setDirty(true);
return;
}
setDirty(false);
/* eslint-disable-next-line */
}, [description, type]);
const createPatch = () => {
const comparison = { ...feature, type, description };
const patch = jsonpatch.compare(feature, comparison);
return patch;
};
const handleSubmit = async () => {
try {
const patch = createPatch();
await patchFeatureToggle(projectId, featureId, patch);
setToastData({
show: true,
type: 'success',
text: 'Successfully updated feature toggle metadata',
});
setDirty(false);
refetch();
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
return (
<>
<FeatureTypeSelect
value={type}
id="feature-type-select"
onChange={e => setType(e.target.value)}
label="Feature type"
editable={editable}
/>
<TextField
label="Description"
required
multiline
rows={4}
variant="outlined"
value={description}
onChange={e => setDescription(e.target.value)}
/>
<ConditionallyRender
condition={dirty}
show={
<PermissionButton
tooltip="Save changes"
permission={UPDATE_FEATURE}
onClick={handleSubmit}
>
Save changes
</PermissionButton>
}
/>
{toast}
</>
);
};
export default FeatureSettingsMetadata;

View File

@ -0,0 +1,37 @@
import useFeatureTypes from '../../../../../../hooks/api/getters/useFeatureTypes/useFeatureTypes';
import GeneralSelect from '../../../../../common/GeneralSelect/GeneralSelect';
const FeatureTypeSelect = ({
editable,
value,
id,
label,
onChange,
...rest
}) => {
const { featureTypes } = useFeatureTypes();
const options = featureTypes.map(t => ({
key: t.id,
label: t.name,
title: t.description,
}));
if (!options.some(o => o.key === value)) {
options.push({ key: value, label: value });
}
return (
<GeneralSelect
disabled={!editable}
options={options}
value={value}
onChange={onChange}
label={label}
id={id}
{...rest}
/>
);
};
export default FeatureTypeSelect;

View File

@ -0,0 +1,57 @@
import useProjects from '../../../../../../hooks/api/getters/useProjects/useProjects';
import { IProject } from '../../../../../../interfaces/project';
import GeneralSelect from '../../../../../common/GeneralSelect/GeneralSelect';
interface IFeatureProjectSelect {
enabled: boolean;
value: string;
onChange: (e: any) => void;
filter: (project: string) => void;
}
const FeatureProjectSelect = ({
enabled,
value,
onChange,
filter,
}: IFeatureProjectSelect) => {
const { projects } = useProjects();
if (!enabled) {
return null;
}
const formatOption = (project: IProject) => {
return {
key: project.id,
label: project.name,
title: project.description,
};
};
let options;
if (filter) {
options = projects
.filter(project => {
return filter(project.id);
})
.map(formatOption);
} else {
options = projects.map(formatOption);
}
if (value && !options.find(o => o.key === value)) {
options.push({ key: value, label: value });
}
return (
<GeneralSelect
label="Project"
options={options}
value={value}
onChange={onChange}
/>
);
};
export default FeatureProjectSelect;

View File

@ -0,0 +1,98 @@
import { useState, useEffect, useContext } from 'react';
import { useHistory, useParams } from 'react-router';
import AccessContext from '../../../../../contexts/AccessContext';
import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import useUser from '../../../../../hooks/api/getters/useUser/useUser';
import useToast from '../../../../../hooks/useToast';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import { projectFilterGenerator } from '../../../../../utils/project-filter-generator';
import {
CREATE_FEATURE,
UPDATE_FEATURE,
} from '../../../../AccessProvider/permissions';
import ConditionallyRender from '../../../../common/ConditionallyRender';
import PermissionButton from '../../../../common/PermissionButton/PermissionButton';
import FeatureProjectSelect from './FeatureProjectSelect/FeatureProjectSelect';
import FeatureSettingsProjectConfirm from './FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm';
const FeatureSettingsProject = () => {
const { hasAccess } = useContext(AccessContext);
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature, refetch } = useFeature(projectId, featureId);
const [project, setProject] = useState(feature.project);
const [dirty, setDirty] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const editable = hasAccess(UPDATE_FEATURE, projectId);
const { permissions } = useUser();
const { changeFeatureProject } = useFeatureApi();
const { toast, setToastData } = useToast();
const history = useHistory();
useEffect(() => {
if (project !== feature.project) {
setDirty(true);
return;
}
setDirty(false);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [project]);
const updateProject = async () => {
const newProject = project;
try {
await changeFeatureProject(projectId, featureId, newProject);
refetch();
setToastData({
show: true,
type: 'success',
text: 'Successfully updated toggle project.',
});
setDirty(false);
setShowConfirmDialog(false);
history.replace(
`/projects/${newProject}/features2/${featureId}/settings`
);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
return (
<>
<FeatureProjectSelect
value={project}
onChange={e => setProject(e.target.value)}
label="Project"
enabled={editable}
filter={projectFilterGenerator({ permissions }, CREATE_FEATURE)}
/>
<ConditionallyRender
condition={dirty}
show={
<PermissionButton
permission={UPDATE_FEATURE}
tooltip="Update feature"
onClick={() => setShowConfirmDialog(true)}
>
Save changes
</PermissionButton>
}
/>
<FeatureSettingsProjectConfirm
projectId={project}
open={showConfirmDialog}
feature={feature}
onClose={() => setShowConfirmDialog(false)}
onClick={updateProject}
/>
{toast}
</>
);
};
export default FeatureSettingsProject;

View File

@ -0,0 +1,46 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
compatability: {
padding: '1rem',
border: `1px solid ${theme.palette.grey[300]}`,
marginTop: '1rem',
display: 'flex',
alignItems: 'center',
},
iconContainer: {
width: '50px',
height: '50px',
backgroundColor: theme.palette.success.main,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
errorIconContainer: {
width: '50px',
height: '50px',
backgroundColor: theme.palette.error.main,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
topContent: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
check: {
fill: '#fff',
width: '30px',
height: '30px',
},
paragraph: {
marginTop: '1rem',
},
cloud: {
fill: theme.palette.grey[500],
marginRight: '0.5rem',
},
}));

View File

@ -0,0 +1,122 @@
import { List, ListItem } from '@material-ui/core';
import { Check, Error, Cloud } from '@material-ui/icons';
import { useState, useEffect } from 'react';
import useProject from '../../../../../../hooks/api/getters/useProject/useProject';
import {
IFeatureEnvironment,
IFeatureToggle,
} from '../../../../../../interfaces/featureToggle';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
import Dialogue from '../../../../../common/Dialogue';
import { useStyles } from './FeatureSettingsProjectConfirm.styles';
interface IFeatureSettingsProjectConfirm {
projectId: string;
open: boolean;
onClose: () => void;
onClick: (args: any) => void;
feature: IFeatureToggle;
}
const FeatureSettingsProjectConfirm = ({
projectId,
open,
onClose,
onClick,
feature,
}: IFeatureSettingsProjectConfirm) => {
const { project } = useProject(projectId);
const [incompatibleEnvs, setIncompatibleEnvs] = useState([]);
const styles = useStyles();
useEffect(() => {
calculateCompatability();
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [projectId, project.name]);
const calculateCompatability = () => {
const featureEnvWithStrategies = feature.environments
.filter((env: IFeatureEnvironment) => {
return env.strategies.length > 0;
})
.map((env: IFeatureEnvironment) => env.name);
const destinationProjectActiveEnvironments = project.environments;
let incompatible: string[] = [];
featureEnvWithStrategies.forEach((env: string) => {
if (destinationProjectActiveEnvironments.indexOf(env) === -1) {
incompatible = [...incompatible, env];
}
});
setIncompatibleEnvs(incompatible);
};
return (
<Dialogue
open={open}
onClose={onClose}
onClick={onClick}
title="Confirm change project"
primaryButtonText="Change project"
secondaryButtonText="Cancel"
>
Are you sure you want to change the project for this feature toggle?
<ConditionallyRender
condition={incompatibleEnvs?.length === 0}
show={
<div className={styles.compatability}>
This feature toggle is 100% compatible with the new
project.
<div className={styles.iconContainer}>
<Check className={styles.check} />
</div>
</div>
}
elseShow={
<div className={styles.compatability}>
<div>
<div className={styles.topContent}>
<p>
{' '}
This feature toggle is not compatible with
the new project destination.
</p>
<div className={styles.iconContainer}>
<div className={styles.errorIconContainer}>
<Error className={styles.check} />
</div>
</div>
</div>
<p className={styles.paragraph}>
This feature toggle has strategy configuration
in an environment that is not activated in the
target project:
</p>
<List>
{incompatibleEnvs.map(env => {
return (
<ListItem key={env}>
<Cloud className={styles.cloud} />
{env}
</ListItem>
);
})}
</List>
<p className={styles.paragraph}>
You may still move the feature toggle, but the
strategies in these environment will not run
while these environments are inactive in the
target project.
</p>
</div>
</div>
}
/>
</Dialogue>
);
};
export default FeatureSettingsProjectConfirm;

View File

@ -16,6 +16,7 @@ import cloneDeep from 'lodash.clonedeep';
import FeatureStrategyCreateExecution from '../../FeatureStrategyCreateExecution/FeatureStrategyCreateExecution';
import { PRODUCTION } from '../../../../../../constants/environmentTypes';
import { ADD_NEW_STRATEGY_SAVE_ID } from '../../../../../../testIds';
import useFeature from '../../../../../../hooks/api/getters/useFeature/useFeature';
interface IFeatureStrategiesConfigure {
setToastData: React.Dispatch<React.SetStateAction<IToastType>>;
@ -27,6 +28,8 @@ const FeatureStrategiesConfigure = ({
const history = useHistory();
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { refetch } = useFeature(projectId, featureId);
const [productionGuard, setProductionGuard] = useState(false);
const styles = useStyles();
@ -92,6 +95,7 @@ const FeatureStrategiesConfigure = ({
text: 'Successfully added strategy.',
});
history.replace(history.location.pathname);
refetch();
} catch (e) {
setToastData({
show: true,

View File

@ -37,4 +37,7 @@ export const useStyles = makeStyles(theme => ({
fill: '#fff',
transition: 'color 0.4s ease',
},
environmentList: {
marginTop: 0,
},
}));

View File

@ -3,7 +3,6 @@ import { FEATURE_STRATEGIES_DRAG_TYPE } from '../../FeatureStrategiesList/Featur
import { DropTargetMonitor, useDrop } from 'react-dnd';
import { Fragment } from 'react';
import useStrategies from '../../../../../../hooks/api/getters/useStrategies/useStrategies';
import { useStyles } from './FeatureStrategiesEnvironmentList.styles';
import classnames from 'classnames';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
@ -15,7 +14,9 @@ import useProductionGuardMarkup from './useProductionGuardMarkup';
import FeatureStrategyEditable from '../FeatureStrategyEditable/FeatureStrategyEditable';
import { PRODUCTION } from '../../../../../../constants/environmentTypes';
import { getStrategyObject } from '../../../../../../utils/get-strategy-object';
import FeatureViewEnvironment from '../../../FeatureViewEnvironment/FeatureViewEnvironment';
import { useStyles } from './FeatureStrategiesEnvironmentList.styles';
interface IFeatureStrategiesEnvironmentListProps {
strategies: IFeatureStrategy[];
}
@ -44,6 +45,7 @@ const FeatureStrategiesEnvironmentList = ({
setExpandedSidebar,
expandedSidebar,
featureId,
activeEnvironment,
} = useFeatureStrategiesEnvironmentList(strategies);
const [{ isOver }, drop] = useDrop({
@ -130,15 +132,24 @@ const FeatureStrategiesEnvironmentList = ({
const strategiesContainerClasses = classnames({
[styles.strategiesContainer]: !expandedSidebar,
});
return (
<ConditionallyRender
condition={!configureNewStrategy}
show={
<div className={classes} ref={drop}>
<div className={strategiesContainerClasses}>
{renderStrategies()}
</div>
<FeatureViewEnvironment
env={activeEnvironment}
className={styles.environmentList}
>
<div className={strategiesContainerClasses}>
<ConditionallyRender
condition={
activeEnvironment.strategies.length > 0
}
show={renderStrategies()}
/>
</div>
</FeatureViewEnvironment>
{dropboxMarkup}
{toast}
{delDialogueMarkup}

View File

@ -133,6 +133,7 @@ const useFeatureStrategiesEnvironmentList = (
setExpandedSidebar,
expandedSidebar,
featureId,
activeEnvironment,
};
};

View File

@ -16,6 +16,7 @@ export const useStyles = makeStyles(theme => ({
width: '85%',
},
},
environmentsHeader: {
padding: '2rem 2rem 1rem 2rem',
display: 'flex',

View File

@ -59,6 +59,8 @@ const FeatureStrategiesEnvironments = () => {
if (addStrategy) {
setExpandedSidebar(true);
}
console.log(feature);
if (!feature) return;
if (environmentTab) {
const env = feature.environments.find(
@ -73,6 +75,7 @@ const FeatureStrategiesEnvironments = () => {
return;
}
if (feature?.environments?.length === 0) return;
setActiveEnvironment(feature?.environments[activeTabIdx]);
/*eslint-disable-next-line */
}, [feature]);
@ -97,6 +100,7 @@ const FeatureStrategiesEnvironments = () => {
}
/*eslint-disable-next-line */
}, [feature]);
if (!feature) return null;
const renderTabs = () => {
return featureCache?.environments?.map((env, index) => {
@ -312,11 +316,11 @@ const FeatureStrategiesEnvironments = () => {
<Button
variant="contained"
color="primary"
onClick={() =>
onClick={() => {
setExpandedSidebar(
prev => !prev
)
}
);
}}
>
Add your first strategy
</Button>
@ -370,7 +374,7 @@ const FeatureStrategiesEnvironments = () => {
}
Icon={Add}
maxWidth="700px"
disabled={!hasAccess(UPDATE_FEATURE)}
permission={UPDATE_FEATURE}
>
Add new strategy
</ResponsiveButton>

View File

@ -1,18 +1,12 @@
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';
import FeatureOverviewVariants from './FeatureVariantsList/FeatureVariantsList';
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 />
<FeatureOverviewVariants />
</div>
);
};

View File

@ -0,0 +1,345 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
FormControl,
FormControlLabel,
Grid,
Switch,
TextField,
InputAdornment,
Button,
Tooltip,
} from '@material-ui/core';
import { Info } from '@material-ui/icons';
import { weightTypes } from '../../../../variant/enums';
import OverrideConfig from './OverrideConfig/OverrideConfig';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
import GeneralSelect from '../../../../../common/GeneralSelect/GeneralSelect';
import { useCommonStyles } from '../../../../../../common.styles';
import Dialogue from '../../../../../common/Dialogue';
import { trim, modalStyles } from '../../../../../common/util';
const payloadOptions = [
{ key: 'string', label: 'string' },
{ key: 'json', label: 'json' },
{ key: 'csv', label: 'csv' },
];
const EMPTY_PAYLOAD = { type: 'string', value: '' };
const AddVariant = ({
showDialog,
closeDialog,
save,
validateName,
editVariant,
title,
editing,
}) => {
const [data, setData] = useState({});
const [payload, setPayload] = useState(EMPTY_PAYLOAD);
const [overrides, setOverrides] = useState([]);
const [error, setError] = useState({});
const commonStyles = useCommonStyles();
const clear = () => {
if (editVariant) {
setData({
name: editVariant.name,
weight: editVariant.weight / 10,
weightType: editVariant.weightType || weightTypes.VARIABLE,
stickiness: editVariant.stickiness,
});
if (editVariant.payload) {
setPayload(editVariant.payload);
}
if (editVariant.overrides) {
setOverrides(editVariant.overrides);
} else {
setOverrides([]);
}
} else {
setData({});
setPayload(EMPTY_PAYLOAD);
setOverrides([]);
}
setError({});
};
useEffect(() => {
clear();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editVariant]);
const setVariantValue = e => {
const { name, value } = e.target;
setData({
...data,
[name]: trim(value),
});
};
const setVariantWeightType = e => {
const { checked, name } = e.target;
const weightType = checked ? weightTypes.FIX : weightTypes.VARIABLE;
setData({
...data,
[name]: weightType,
});
};
const submit = async e => {
setError({});
e.preventDefault();
const validationError = validateName(data.name);
if (validationError) {
setError(validationError);
return;
}
try {
const variant = {
name: data.name,
weight: data.weight * 10,
weightType: data.weightType,
stickiness: data.stickiness,
payload: payload.value ? payload : undefined,
overrides: overrides
.map(o => ({
contextName: o.contextName,
values: o.values,
}))
.filter(o => o.values && o.values.length > 0),
};
await save(variant);
clear();
closeDialog();
} catch (error) {
if (error.message.includes('duplicate value')) {
setError({ name: 'A variant with that name already exists.' });
} else {
const msg = error.message || 'Could not add variant';
setError({ general: msg });
}
}
};
const onPayload = e => {
e.preventDefault();
setPayload({
...payload,
[e.target.name]: e.target.value,
});
};
const onCancel = e => {
e.preventDefault();
clear();
closeDialog();
};
const updateOverrideType = index => e => {
e.preventDefault();
setOverrides(
overrides.map((o, i) => {
if (i === index) {
o[e.target.name] = e.target.value;
}
return o;
})
);
};
const updateOverrideValues = (index, values) => {
setOverrides(
overrides.map((o, i) => {
if (i === index) {
o.values = values;
}
return o;
})
);
};
const removeOverride = index => e => {
e.preventDefault();
setOverrides(overrides.filter((o, i) => i !== index));
};
const onAddOverride = e => {
e.preventDefault();
setOverrides([
...overrides,
...[{ contextName: 'userId', values: [] }],
]);
};
const isFixWeight = data.weightType === weightTypes.FIX;
return (
<Dialogue
open={showDialog}
contentLabel="Add variant modal"
style={modalStyles}
onClose={onCancel}
onClick={submit}
primaryButtonText="Save"
secondaryButtonText="Cancel"
title={title}
fullWidth
maxWidth="md"
>
<form onSubmit={submit} className={commonStyles.contentSpacingY}>
<p style={{ color: 'red' }}>{error.general}</p>
<TextField
label="Variant name"
name="name"
placeholder=""
className={commonStyles.fullWidth}
style={{ maxWidth: '350px' }}
helperText={error.name}
value={data.name || ''}
error={Boolean(error.name)}
variant="outlined"
required
size="small"
type="name"
disabled={editing}
onChange={setVariantValue}
data-test={'VARIANT_NAME_INPUT'}
/>
<br />
<Grid container>
<Grid item md={4}>
<TextField
id="weight"
label="Weight"
name="weight"
variant="outlined"
size="small"
placeholder=""
data-test={'VARIANT_WEIGHT_INPUT'}
InputProps={{
endAdornment: (
<InputAdornment position="start">
%
</InputAdornment>
),
}}
style={{ marginRight: '0.8rem' }}
value={data.weight || ''}
error={Boolean(error.weight)}
type="number"
disabled={!isFixWeight}
onChange={setVariantValue}
/>
</Grid>
<Grid item md={6}>
<FormControl>
<FormControlLabel
control={
<Switch
name="weightType"
checked={isFixWeight}
data-test={'VARIANT_WEIGHT_TYPE'}
onChange={setVariantWeightType}
/>
}
label="Custom percentage"
/>
</FormControl>
</Grid>
</Grid>
<p style={{ marginBottom: '1rem' }}>
<strong>Payload </strong>
<Tooltip
title="Passed to the variant object. Can be anything
(json, value, csv)"
>
<Info
style={{
width: '18.5px',
height: '18.5px',
color: 'grey',
}}
/>
</Tooltip>
</p>
<Grid container>
<Grid item md={2} sm={2} xs={4}>
<GeneralSelect
name="type"
label="Type"
className={commonStyles.fullWidth}
value={payload.type}
options={payloadOptions}
onChange={onPayload}
style={{ minWidth: '100px', width: '100%' }}
data-test={'VARIANT_PAYLOAD_TYPE'}
/>
</Grid>
<Grid item md={8} sm={8} xs={6}>
<TextField
rows={1}
label="Value"
name="value"
className={commonStyles.fullWidth}
value={payload.value}
onChange={onPayload}
variant="outlined"
size="small"
data-test={'VARIANT_PAYLOAD_VALUE'}
/>
</Grid>
</Grid>
<ConditionallyRender
condition={overrides.length > 0}
show={
<p style={{ marginBottom: '1rem' }}>
<strong>Overrides </strong>
<Tooltip title="Here you can specify which users should get this variant.">
<Info
style={{
width: '18.5px',
height: '18.5px',
color: 'grey',
}}
/>
</Tooltip>
</p>
}
/>
<OverrideConfig
overrides={overrides}
removeOverride={removeOverride}
updateOverrideType={updateOverrideType}
updateOverrideValues={updateOverrideValues}
updateValues={updateOverrideValues}
/>
<Button
onClick={onAddOverride}
variant="contained"
color="primary"
>
Add override
</Button>{' '}
</form>
</Dialogue>
);
};
AddVariant.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
save: PropTypes.func.isRequired,
validateName: PropTypes.func.isRequired,
editVariant: PropTypes.object,
title: PropTypes.string,
uiConfig: PropTypes.object,
};
export default AddVariant;

View File

@ -0,0 +1,123 @@
import { connect } from 'react-redux';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Grid, IconButton, TextField } from '@material-ui/core';
import { Delete } from '@material-ui/icons';
import { useStyles } from './OverrideConfig.styles.js';
import { Autocomplete } from '@material-ui/lab';
import GeneralSelect from '../../../../../../common/GeneralSelect/GeneralSelect';
import { useCommonStyles } from '../../../../../../../common.styles';
import ConditionallyRender from '../../../../../../common/ConditionallyRender';
import InputListField from '../../../../../../common/input-list-field.jsx';
const OverrideConfig = ({
overrides,
updateOverrideType,
updateOverrideValues,
removeOverride,
contextDefinitions,
}) => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const contextNames = contextDefinitions.map(c => ({
key: c.name,
label: c.name,
}));
const updateValues = i => values => {
updateOverrideValues(i, values);
};
const updateSelectValues = i => (e, options) => {
updateOverrideValues(i, options ? options : []);
};
return overrides.map((o, i) => {
const definition = contextDefinitions.find(
c => c.name === o.contextName
);
const legalValues = definition ? definition.legalValues : [];
return (
<Grid container key={`override=${i}`} alignItems="center">
<Grid
item
md={3}
sm={3}
xs={3}
className={styles.contextFieldSelect}
>
<GeneralSelect
name="contextName"
label="Context Field"
value={o.contextName}
options={contextNames}
classes={{
root: classnames(commonStyles.fullWidth),
}}
onChange={updateOverrideType(i)}
/>
</Grid>
<Grid md={7} sm={7} xs={6} item>
<ConditionallyRender
condition={legalValues && legalValues.length > 0}
show={
<Autocomplete
multiple
id={`override-select-${i}`}
getOptionSelected={(option, value) => {
return option === value;
}}
options={legalValues}
onChange={updateSelectValues(i)}
getOptionLabel={option => option}
defaultValue={o.values}
value={o.values}
style={{ width: '100%' }}
filterSelectedOptions
size="small"
renderInput={params => (
<TextField
{...params}
variant="outlined"
label="Legal values"
style={{ width: '100%' }}
/>
)}
/>
}
elseShow={
<InputListField
label="Values (v1, v2, ...)"
name="values"
placeholder=""
classes={{ root: commonStyles.fullWidth }}
values={o.values}
updateValues={updateValues(i)}
/>
}
/>
</Grid>
<Grid item md={1}>
<IconButton onClick={removeOverride(i)}>
<Delete />
</IconButton>
</Grid>
</Grid>
);
});
};
OverrideConfig.propTypes = {
overrides: PropTypes.array.isRequired,
updateOverrideType: PropTypes.func.isRequired,
updateOverrideValues: PropTypes.func.isRequired,
removeOverride: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
contextDefinitions: state.context.toJS(),
});
export default connect(mapStateToProps, {})(OverrideConfig);

View File

@ -0,0 +1,7 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
contextFieldSelect: {
marginRight: '8px',
},
}));

View File

@ -0,0 +1,506 @@
import classnames from 'classnames';
import * as jsonpatch from 'fast-json-patch';
import styles from './variants.module.scss';
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
} from '@material-ui/core';
import AddVariant from './AddFeatureVariant/AddFeatureVariant';
import { useContext, useEffect, useState } from 'react';
import useFeature from '../../../../../hooks/api/getters/useFeature/useFeature';
import { useParams } from 'react-router';
import { IFeatureViewParams } from '../../../../../interfaces/params';
import AccessContext from '../../../../../contexts/AccessContext';
import FeatureVariantListItem from './FeatureVariantsListItem/FeatureVariantsListItem';
import { UPDATE_FEATURE } from '../../../../AccessProvider/permissions';
import ConditionallyRender from '../../../../common/ConditionallyRender';
import useUnleashContext from '../../../../../hooks/api/getters/useUnleashContext/useUnleashContext';
import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect';
import { IFeatureVariant } from '../../../../../interfaces/featureToggle';
import useFeatureApi from '../../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from '../../../../../hooks/useToast';
import { updateWeight } from '../../../../common/util';
import cloneDeep from 'lodash.clonedeep';
import useDeleteVariantMarkup from './FeatureVariantsListItem/useDeleteVariantMarkup';
const FeatureOverviewVariants = () => {
const { hasAccess } = useContext(AccessContext);
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature, refetch } = useFeature(projectId, featureId);
const [variants, setVariants] = useState<IFeatureVariant[]>([]);
const [editing, setEditing] = useState(false);
const { context } = useUnleashContext();
const { toast, setToastData } = useToast();
const { patchFeatureToggle } = useFeatureApi();
const [editVariant, setEditVariant] = useState({});
const [showAddVariant, setShowAddVariant] = useState(false);
const [stickinessOptions, setStickinessOptions] = useState([]);
const [delDialog, setDelDialog] = useState({ name: '', show: false });
useEffect(() => {
if (feature) {
setClonedVariants(feature.variants);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [feature.variants]);
useEffect(() => {
const options = [
'default',
...context.filter(c => c.stickiness).map(c => c.name),
];
setStickinessOptions(options);
}, [context]);
const editable = hasAccess(UPDATE_FEATURE, projectId);
const setClonedVariants = clonedVariants =>
setVariants(cloneDeep(clonedVariants));
const handleCloseAddVariant = () => {
setShowAddVariant(false);
setEditing(false);
setEditVariant({});
};
const renderVariants = () => {
return variants.map(variant => {
return (
<FeatureVariantListItem
key={variant.name}
variant={variant}
editVariant={(name: string) => {
const v = { ...variants.find(v => v.name === name) };
setEditVariant(v);
setEditing(true);
setShowAddVariant(true);
}}
setDelDialog={setDelDialog}
editable={editable}
/>
);
});
};
const renderStickiness = () => {
if (!variants || variants.length < 2) {
return null;
}
const value = variants[0].stickiness || 'default';
const options = stickinessOptions.map(c => ({ key: c, label: c }));
// guard on stickiness being disabled for context field.
if (!stickinessOptions.includes(value)) {
options.push({ key: value, label: value });
}
const onChange = event => {
updateStickiness(event.target.value);
};
return (
<section style={{ paddingTop: '16px' }}>
<GeneralSelect
label="Stickiness"
options={options}
value={value}
onChange={onChange}
/>
&nbsp;&nbsp;
<small
className={classnames(styles.paragraph, styles.helperText)}
style={{ display: 'block', marginTop: '0.5rem' }}
>
By overriding the stickiness you can control which parameter
is used to ensure consistent traffic
allocation across variants.{' '}
<a
href="https://docs.getunleash.io/advanced/toggle_variants"
target="_blank"
rel="noreferrer"
>
Read more
</a>
</small>
</section>
);
};
const updateStickiness = async (stickiness: string) => {
const newVariants = [...variants].map(variant => {
return { ...variant, stickiness };
});
const patch = createPatch(newVariants);
if (patch.length === 0) return;
try {
await patchFeatureToggle(projectId, featureId, patch);
refetch();
setToastData({
show: true,
type: 'success',
text: 'Successfully updated variant stickiness',
});
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
const removeVariant = async (name: string) => {
console.log(`Removing variant ${name}`);
let updatedVariants = variants.filter(v => v.name !== name);
try {
await updateVariants(
updatedVariants,
'Successfully removed variant'
);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
}
};
const updateVariant = async (variant: IFeatureVariant) => {
const updatedVariants = cloneDeep(variants);
const variantIdxToUpdate = updatedVariants.findIndex(
(v: IFeatureVariant) => v.name === variant.name
);
updatedVariants[variantIdxToUpdate] = variant;
await updateVariants(updatedVariants, 'Successfully updated variant');
};
const saveNewVariant = async (variant: IFeatureVariant) => {
let stickiness = 'default';
if (variants?.length > 0) {
stickiness = variants[0].stickiness || 'default';
}
variant.stickiness = stickiness;
await updateVariants(
[...variants, variant],
'Successfully added a variant'
);
};
const updateVariants = async (
variants: IFeatureVariant[],
successText: string
) => {
const newVariants = updateWeight(variants, 1000);
const patch = createPatch(newVariants);
if (patch.length === 0) return;
await patchFeatureToggle(projectId, featureId, patch);
refetch();
setToastData({
show: true,
type: 'success',
text: successText,
});
};
const validateName = (name: string) => {
if (!name) {
return { name: 'Name is required' };
}
};
const delDialogueMarkup = useDeleteVariantMarkup({
show: delDialog.show,
onClick: e => {
removeVariant(delDialog.name);
setDelDialog({ name: '', show: false });
setToastData({
show: true,
type: 'success',
text: `Successfully deleted variant`,
});
},
onClose: () => setDelDialog({ show: false, name: '' }),
});
const createPatch = (newVariants: IFeatureVariant[]) => {
const patch = jsonpatch
.compare(feature.variants, newVariants)
.map(patch => {
return { ...patch, path: `/variants${patch.path}` };
});
return patch;
};
return (
<section style={{ padding: '16px' }}>
<Typography variant="body1">
Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using
variants you should use the{' '}
<code style={{ color: 'navy' }}>getVariant()</code> method in
the Client SDK.
</Typography>
<ConditionallyRender
condition={variants?.length > 0}
show={
<Table className={styles.variantTable}>
<TableHead>
<TableRow>
<TableCell>Variant name</TableCell>
<TableCell className={styles.labels} />
<TableCell>Weight</TableCell>
<TableCell>Weight Type</TableCell>
<TableCell className={styles.actions} />
</TableRow>
</TableHead>
<TableBody>{renderVariants()}</TableBody>
</Table>
}
elseShow={<p>No variants defined.</p>}
/>
<br />
<ConditionallyRender
condition={editable}
show={
<div>
<Button
title="Add variant"
onClick={() => {
setEditing(false);
setEditVariant({});
setShowAddVariant(true);
}}
variant="contained"
color="primary"
className={styles.addVariantButton}
data-test={'ADD_VARIANT_BUTTON'}
>
Add variant
</Button>
{renderStickiness()}
</div>
}
/>
<AddVariant
showDialog={showAddVariant}
closeDialog={handleCloseAddVariant}
save={async (variantToSave: IFeatureVariant) => {
if (!editing) {
return saveNewVariant(variantToSave);
} else {
return updateVariant(variantToSave);
}
}}
editing={editing}
validateName={validateName}
editVariant={editVariant}
title={editing ? 'Edit variant' : 'Add variant'}
/>
{toast}
{delDialogueMarkup}
</section>
);
};
export default FeatureOverviewVariants;
// class UpdateVariantComponent extends Component {
// constructor(props) {
// super(props);
// this.state = { ...initialState };
// }
// closeDialog = () => {
// this.setState({ ...initialState });
// };
// openAddVariant = e => {
// e.preventDefault();
// this.setState({
// showDialog: true,
// editVariant: undefined,
// editIndex: undefined,
// title: 'Add variant',
// });
// };
// openEditVariant = (e, index, variant) => {
// e.preventDefault();
// if (this.props.editable) {
// this.setState({
// showDialog: true,
// editVariant: variant,
// editIndex: index,
// title: 'Edit variant',
// });
// }
// };
// validateName = name => {
// if (!name) {
// return { name: 'Name is required' };
// }
// };
// onRemoveVariant = (e, index) => {
// e.preventDefault();
// try {
// this.props.removeVariant(index);
// } catch (e) {
// console.log('An exception was caught.');
// }
// };
// renderVariant = (variant, index) => (
// <VariantViewComponent
// key={variant.name}
// variant={variant}
// editVariant={e => this.openEditVariant(e, index, variant)}
// removeVariant={e => this.onRemoveVariant(e, index)}
// editable={this.props.editable}
// />
// );
// renderVariants = variants => (
// <Table className={styles.variantTable}>
// <TableHead>
// <TableRow>
// <TableCell>Variant name</TableCell>
// <TableCell className={styles.labels} />
// <TableCell>Weight</TableCell>
// <TableCell>Weight Type</TableCell>
// <TableCell className={styles.actions} />
// </TableRow>
// </TableHead>
// <TableBody>{variants.map(this.renderVariant)}</TableBody>
// </Table>
// );
// renderStickiness = variants => {
// const { updateStickiness, stickinessOptions } = this.props;
// if (!variants || variants.length < 2) {
// return null;
// }
// const value = variants[0].stickiness || 'default';
// const options = stickinessOptions.map(c => ({ key: c, label: c }));
// // guard on stickiness being disabled for context field.
// if (!stickinessOptions.includes(value)) {
// options.push({ key: value, label: value });
// }
// const onChange = event => updateStickiness(event.target.value);
// return (
// <section style={{ paddingTop: '16px' }}>
// <GeneralSelect
// label="Stickiness"
// options={options}
// value={value}
// onChange={onChange}
// />
// &nbsp;&nbsp;
// <small
// className={classnames(styles.paragraph, styles.helperText)}
// style={{ display: 'block', marginTop: '0.5rem' }}
// >
// By overriding the stickiness you can control which parameter
// you want to be used in order to ensure consistent traffic
// allocation across variants.{' '}
// <a
// href="https://docs.getunleash.io/advanced/toggle_variants"
// target="_blank"
// rel="noreferrer"
// >
// Read more
// </a>
// </small>
// </section>
// );
// };
// render() {
// const { showDialog, editVariant, editIndex, title } = this.state;
// const { variants, addVariant, updateVariant } = this.props;
// const saveVariant = editVariant
// ? updateVariant.bind(null, editIndex)
// : addVariant;
// return (
// <section style={{ padding: '16px' }}>
// <Typography variant="body1">
// Variants allows you to return a variant object if the
// feature toggle is considered enabled for the current
// request. When using variants you should use the{' '}
// <code style={{ color: 'navy' }}>getVariant()</code> method
// in the Client SDK.
// </Typography>
// <ConditionallyRender
// condition={variants.length > 0}
// show={this.renderVariants(variants)}
// elseShow={<p>No variants defined.</p>}
// />
// <br />
// <ConditionallyRender
// condition={this.props.editable}
// show={
// <div>
// <Button
// title="Add variant"
// onClick={this.openAddVariant}
// variant="contained"
// color="primary"
// className={styles.addVariantButton}
// >
// Add variant
// </Button>
// {this.renderStickiness(variants)}
// </div>
// }
// />
// <AddVariant
// showDialog={showDialog}
// closeDialog={this.closeDialog}
// save={saveVariant}
// validateName={this.validateName}
// editVariant={editVariant}
// title={title}
// />
// </section>
// );
// }
// }
// UpdateVariantComponent.propTypes = {
// variants: PropTypes.array.isRequired,
// addVariant: PropTypes.func.isRequired,
// removeVariant: PropTypes.func.isRequired,
// updateVariant: PropTypes.func.isRequired,
// updateStickiness: PropTypes.func.isRequired,
// editable: PropTypes.bool.isRequired,
// stickinessOptions: PropTypes.array,
// };
// export default UpdateVariantComponent;

View File

@ -0,0 +1,74 @@
import { IconButton, Chip, TableCell, TableRow } from '@material-ui/core';
import { Edit, Delete } from '@material-ui/icons';
import styles from '../variants.module.scss';
import { IFeatureVariant } from '../../../../../../interfaces/featureToggle';
import ConditionallyRender from '../../../../../common/ConditionallyRender';
import { weightTypes } from '../../../../variant/enums';
interface IFeatureVariantListItem {
variant: IFeatureVariant;
editVariant: any;
setDelDialog: any;
editable: boolean;
}
const FeatureVariantListItem = ({
variant,
editVariant,
setDelDialog,
editable,
}: IFeatureVariantListItem) => {
const { FIX } = weightTypes;
return (
<TableRow>
<TableCell onClick={editVariant} data-test={'VARIANT_NAME'}>{variant.name}</TableCell>
<TableCell className={styles.chipContainer}>
<ConditionallyRender
condition={variant.payload}
show={<Chip label="Payload" />}
/>
<ConditionallyRender
condition={
variant.overrides && variant.overrides.length > 0
}
show={
<Chip
style={{
backgroundColor: 'rgba(173, 216, 230, 0.2)',
}}
label="Overrides"
/>
}
/>
</TableCell>
<TableCell data-test={'VARIANT_WEIGHT'}>{variant.weight / 10.0} %</TableCell>
<TableCell data-test={'VARIANT_WEIGHT_TYPE'}>
{variant.weightType === FIX ? 'Fix' : 'Variable'}
</TableCell>
<ConditionallyRender
condition={editable}
show={
<TableCell className={styles.actions}>
<div className={styles.actionsContainer}>
<IconButton data-test={'VARIANT_EDIT_BUTTON'} onClick={() => editVariant(variant.name)}>
<Edit />
</IconButton>
<IconButton data-test={`VARIANT_DELETE_BUTTON_${variant.name}`} onClick={e => {
e.stopPropagation();
setDelDialog({show: true, name: variant.name });
}}>
<Delete />
</IconButton>
</div>
</TableCell>
}
elseShow={<TableCell className={styles.actions} />}
/>
</TableRow>
);
};
export default FeatureVariantListItem;

View File

@ -0,0 +1,31 @@
import { Alert } from '@material-ui/lab';
import Dialogue from '../../../../../common/Dialogue';
interface IUseDeleteVariantMarkupProps {
show: boolean;
onClick: () => void;
onClose: () => void;
}
const useDeleteVariantMarkup = ({
show,
onClick,
onClose,
}: IUseDeleteVariantMarkupProps) => {
return (
<Dialogue
title="Are you sure you want to delete this variant?"
open={show}
primaryButtonText="Delete variant"
secondaryButtonText="Cancel"
onClick={onClick}
onClose={onClose}
>
<Alert severity="error">
Deleting this variant will change which variant users receive.
</Alert>
</Dialogue>
);
};
export default useDeleteVariantMarkup;

View File

@ -0,0 +1,100 @@
.variantTable {
width: 100%;
max-width: 700px;
th,
td {
text-align: center;
}
th:first-of-type,
td:first-of-type {
text-align: left;
width: 100%;
}
tbody tr:hover {
background-color: rgba(173, 216, 230, 0.2);
}
}
@media (max-width: 600px) {
th.labels {
display: none;
}
td.labels {
display: none;
}
}
th.labels {
text-align: right;
}
td.labels {
text-align: right;
vertical-align: top;
}
th.actions {
text-align: right;
}
td.actions {
height: 100%;
text-align: right;
vertical-align: top;
}
.actionsContainer {
display: flex;
align-items: center;
}
.modal {
max-width: 90%;
width: 600px;
position: absolute !important;
}
@media (max-width: 600px) {
.modal {
top: 0 !important;
}
}
.tooltip {
i {
font-size: 18px;
}
}
.inputWeight {
text-align: right;
}
.flexCenter {
display: flex;
justify-content: center;
align-items: center;
}
.flex {
display: flex;
align-items: center;
}
.marginL10 {
margin-left: 10px;
}
.addVariantButton {
margin: 1rem 0;
}
.paragraph {
max-width: 400px;
}
.helperText {
display: block;
margin-top: 0.5rem;
}

View File

@ -7,7 +7,12 @@ export const useStyles = makeStyles(theme => ({
borderRadius: '10px',
marginBottom: '1rem',
},
innerContainer: { padding: '2rem' },
innerContainer: {
padding: '1rem 2rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
separator: {
width: '100%',
backgroundColor: theme.palette.grey[200],

View File

@ -1,24 +1,57 @@
import { Tabs, Tab } from '@material-ui/core';
import { Route, useHistory, useParams } from 'react-router-dom';
import { useState } from 'react';
import { Archive, FileCopy } from '@material-ui/icons';
import { Route, useHistory, useParams, Link } from 'react-router-dom';
import useFeatureApi from '../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useFeature from '../../../hooks/api/getters/useFeature/useFeature';
import useTabs from '../../../hooks/useTabs';
import useToast from '../../../hooks/useToast';
import { IFeatureViewParams } from '../../../interfaces/params';
import { UPDATE_FEATURE } from '../../AccessProvider/permissions';
import Dialogue from '../../common/Dialogue';
import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton';
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 FeatureSettings from './FeatureSettings/FeatureSettings';
const FeatureView2 = () => {
const { projectId, featureId } = useParams<IFeatureViewParams>();
const { feature } = useFeature(projectId, featureId);
const { a11yProps } = useTabs(0);
const { archiveFeatureToggle } = useFeatureApi();
const { toast, setToastData } = useToast();
const [showDelDialog, setShowDelDialog] = useState(false);
const styles = useStyles();
const history = useHistory();
const basePath = `/projects/${projectId}/features2/${featureId}`;
const archiveToggle = async () => {
try {
await archiveFeatureToggle(projectId, featureId);
setToastData({
text: 'Feature archived',
type: 'success',
show: true,
});
setShowDelDialog(false);
history.push(`/projects/${projectId}`);
} catch (e) {
setToastData({
show: true,
type: 'error',
text: e.toString(),
});
setShowDelDialog(false);
}
};
const handleCancel = () => setShowDelDialog(false);
const tabData = [
{
title: 'Overview',
@ -41,6 +74,7 @@ const FeatureView2 = () => {
name: 'Event log',
},
{ title: 'Variants', path: `${basePath}/variants`, name: 'Variants' },
{ title: 'Settings', path: `${basePath}/settings`, name: 'Settings' },
];
const renderTabs = () => {
@ -65,6 +99,23 @@ const FeatureView2 = () => {
<div className={styles.header}>
<div className={styles.innerContainer}>
<h2 className={styles.featureViewHeader}>{feature.name}</h2>
<div className={styles.actions}>
<PermissionIconButton
permission={UPDATE_FEATURE}
tooltip="Copy"
component={Link}
to={`${history.location.pathname}/copy`}
>
<FileCopy />
</PermissionIconButton>
<PermissionIconButton
permission={UPDATE_FEATURE}
tooltip="Archive feature toggle"
onClick={() => setShowDelDialog(true)}
>
<Archive />
</PermissionIconButton>
</div>
</div>
<div className={styles.separator} />
<div className={styles.tabContainer}>
@ -98,6 +149,21 @@ const FeatureView2 = () => {
path={`/projects/:projectId/features2/:featureId/variants`}
component={FeatureVariants}
/>
<Route
path={`/projects/:projectId/features2/:featureId/settings`}
component={FeatureSettings}
/>
<Dialogue
onClick={() => archiveToggle()}
open={showDelDialog}
onClose={handleCancel}
primaryButtonText="Archive toggle"
secondaryButtonText="Cancel"
title="Archive feature toggle"
>
Are you sure you want to archive this feature toggle?
</Dialogue>
{toast}
</>
);
};

View File

@ -1,12 +1,108 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
environmentContainer: {
alignItems: 'center',
width: '100%',
borderRadius: '10px',
backgroundColor: '#fff',
container: {
marginTop: '2rem',
marginBottom: '5rem',
border: `1px solid ${theme.palette.grey[300]}`,
borderRadius: '5px',
position: 'relative',
},
header: {
display: 'flex',
padding: '1.5rem',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: `1px solid ${theme.palette.grey[300]}`,
padding: '1rem',
},
headerInfo: {
marginRight: '1rem',
},
icon: {
fill: '#fff',
height: '14px',
width: '14px',
},
strategiesContainer: {
padding: '1rem 0',
['& > *']: {
margin: '0.5rem 0',
},
},
environmentIdentifier: {
position: 'absolute',
right: '0',
left: '0',
margin: '0 auto',
width: '150px',
top: '-25px',
display: 'flex',
background: '#fff',
border: `1px solid ${theme.palette.primary.light}`,
borderRadius: '25px',
padding: '0.25rem 1rem',
minWidth: '150px',
color: theme.palette.primary.light,
alignItems: 'center',
justifyContent: 'center',
},
environmentTitle: {
maxWidth: '100px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
},
textContainer: {
display: 'flex',
alignItems: 'center',
},
environmentBadgeParagraph: {
fontSize: theme.fontSizes.smallBody,
},
iconContainer: {
padding: '0.25rem',
borderRadius: '50%',
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
background: theme.palette.primary.light,
border: `1px solid ${theme.palette.primary.light}`,
marginRight: '0.5rem',
},
body: {
padding: '1rem',
},
disabledEnvContainer: {
color: theme.palette.grey[500],
border: `1px solid ${theme.palette.grey[400]}`,
backgroundColor: '#fff',
},
disabledIconContainer: {
border: `1px solid ${theme.palette.grey[400]}`,
background: theme.palette.grey[400],
},
iconDisabled: {
fill: '#fff',
},
toggleText: {
fontSize: theme.fontSizes.smallBody,
wordBreak: 'break-all',
maxWidth: '300px',
},
toggleLink: {
color: theme.palette.primary.main,
fontSize: theme.fontSizes.smallBody,
wordBreak: 'break-word',
},
headerDisabledEnv: {
border: 'none',
},
[theme.breakpoints.down(1000)]: {
toggleLink: {
marginTop: '1rem',
display: 'block',
},
},
}));

View File

@ -1,14 +1,153 @@
import { Switch } from '@material-ui/core';
import { useStyles } from './FeatureViewEnvironment.styles';
import React from 'react';
import { Cloud } from '@material-ui/icons';
import { useParams, Link } from 'react-router-dom';
import { Switch, Tooltip } from '@material-ui/core';
import classNames from 'classnames';
import ConditionallyRender from '../../../common/ConditionallyRender';
import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from '../../../../hooks/useToast';
import { FC } from 'react';
import { IFeatureEnvironment } from '../../../../interfaces/featureToggle';
import { IFeatureViewParams } from '../../../../interfaces/params';
const FeatureViewEnvironment = ({ env }: any) => {
import { useStyles } from './FeatureViewEnvironment.styles';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
interface IFeatureViewEnvironmentProps {
env: IFeatureEnvironment;
className?: string;
}
const FeatureViewEnvironment: FC<IFeatureViewEnvironmentProps> = ({
env,
children,
className,
}: IFeatureViewEnvironmentProps) => {
const { featureId, projectId } = useParams<IFeatureViewParams>();
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const styles = useStyles();
const { refetch, feature } = useFeature(projectId, featureId);
const { toast, setToastData } = useToast();
if (!env) return null;
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 environmentIdentifierClasses = classNames(
styles.environmentIdentifier,
{ [styles.disabledEnvContainer]: !env?.enabled }
);
const containerClasses = classNames(styles.container, className);
const currentEnv = feature?.environments?.find(
featureEnv => featureEnv.name === env.name
);
return (
<div style={{ width: '100%', marginBottom: '1rem' }}>
<div className={styles.environmentContainer}>
<Switch value={env.enabled} checked={env.enabled} /> Toggle in{' '}
{env.name} is {env.enabled ? 'enabled' : 'disabled'}
<div className={containerClasses}>
<div className={environmentIdentifierClasses}>
<div className={iconContainerClasses}>
<Cloud className={iconClasses} />
</div>
<p className={styles.environmentBadgeParagraph}>{env.type}</p>
</div>
<div className={styles.header}>
<div className={styles.headerInfo}>
<Tooltip title={env.name}>
<p className={styles.environmentTitle}>{env.name}</p>
</Tooltip>
</div>
<div className={styles.environmentStatus}>
<ConditionallyRender
condition={env?.strategies?.length > 0}
show={
<div className={styles.textContainer}>
<Switch
value={env.enabled}
checked={env.enabled}
onChange={toggleEnvironment}
/>{' '}
<span className={styles.toggleText}>
The feature toggle is{' '}
{env.enabled ? 'enabled' : 'disabled'} in{' '}
{env.name}
</span>
</div>
}
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={currentEnv.strategies.length > 0}
show={<div className={styles.body}>{children}</div>}
/>
{toast}
</div>
);
};

View File

@ -2,9 +2,9 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { DialogContentText, Button, TextField } from '@material-ui/core';
import TagTypeSelect from '../feature/tag-type-select-container';
import Dialogue from '../common/Dialogue';
import { trim } from '../common/util';
import TagSelect from '../common/TagSelect/TagSelect';
import styles from './add-tag-dialog-component.module.scss';
@ -82,7 +82,7 @@ class AddTagDialogComponent extends Component {
</DialogContentText>
<form onSubmit={this.onSubmit}>
<section className={styles.dialogueFormContent}>
<TagTypeSelect
<TagSelect
name="type"
value={tag.type}
onChange={v =>

View File

@ -19,31 +19,23 @@ import { trim } from '../../../common/util';
import ConditionallyRender from '../../../common/ConditionallyRender';
import { Alert } from '@material-ui/lab';
import { getTogglePath } from '../../../../utils/route-path-helpers';
import useFeatureApi from '../../../../hooks/api/actions/useFeatureApi/useFeatureApi';
import useFeature from '../../../../hooks/api/getters/useFeature/useFeature';
const CopyFeature = props => {
// static displayName = `AddFeatureComponent-${getDisplayName(Component)}`;
const [replaceGroupId, setReplaceGroupId] = useState(true);
const [apiError, setApiError] = useState('');
const [copyToggle, setCopyToggle] = useState();
const [nameError, setNameError] = useState(undefined);
const [newToggleName, setNewToggleName] = useState();
const { cloneFeatureToggle } = useFeatureApi();
const inputRef = useRef();
const { name } = useParams();
const copyToggleName = name;
const { features } = props;
const { name: copyToggleName, id: projectId } = useParams();
const { feature } = useFeature(projectId, copyToggleName);
useEffect(() => {
const copyToggle = features.find(item => item.name === copyToggleName);
if (copyToggle) {
setCopyToggle(copyToggle);
inputRef.current?.focus();
} else {
props.fetchFeatureToggles();
}
/* eslint-disable-next-line */
}, [features.length]);
inputRef.current?.focus();
}, []);
const setValue = evt => {
const value = trim(evt.target.value);
@ -71,31 +63,21 @@ const CopyFeature = props => {
return;
}
const { history } = props;
copyToggle.name = newToggleName;
if (replaceGroupId) {
copyToggle.strategies.forEach(s => {
if (s.parameters && s.parameters.groupId) {
s.parameters.groupId = newToggleName;
}
});
}
try {
props
.createFeatureToggle(copyToggle)
.then(() =>
history.push(
getTogglePath(copyToggle.project, copyToggle.name)
)
);
await cloneFeatureToggle(
projectId,
copyToggleName,
{ name: newToggleName, replaceGroupId }
);
props.history.push(
getTogglePath(projectId, newToggleName)
)
} catch (e) {
setApiError(e);
}
};
if (!copyToggle) return <span>Toggle not found</span>;
if (!feature || !feature.name) return <span>Toggle not found</span>;
return (
<Paper
@ -103,7 +85,7 @@ const CopyFeature = props => {
style={{ overflow: 'visible' }}
>
<div className={styles.header}>
<h1>Copy&nbsp;{copyToggle.name}</h1>
<h1>Copy&nbsp;{copyToggleName}</h1>
</div>
<ConditionallyRender
condition={apiError}
@ -114,9 +96,9 @@ const CopyFeature = props => {
You are about to create a new feature toggle by cloning the
configuration of feature toggle&nbsp;
<Link
to={getTogglePath(copyToggle.project, copyToggle.name)}
to={getTogglePath(projectId, copyToggleName)}
>
{copyToggle.name}
{copyToggleName}
</Link>
. You must give the new feature toggle a unique name before
you can proceed.
@ -157,10 +139,7 @@ const CopyFeature = props => {
};
CopyFeature.propTypes = {
copyToggle: PropTypes.object,
history: PropTypes.object.isRequired,
createFeatureToggle: PropTypes.func.isRequired,
fetchFeatureToggles: PropTypes.func.isRequired,
validateName: PropTypes.func.isRequired,
};

View File

@ -1,24 +1,15 @@
import { connect } from 'react-redux';
import CopyFeatureComponent from './CopyFeature';
import {
createFeatureToggles,
validateName,
fetchFeatureToggles,
validateName
} from '../../../../store/feature-toggle/actions';
const mapStateToProps = (state, props) => ({
history: props.history,
features: state.features.toJS(),
copyToggle: state.features
.toJS()
.find(toggle => toggle.name === props.copyToggleName),
});
const mapDispatchToProps = dispatch => ({
validateName,
createFeatureToggle: featureToggle =>
createFeatureToggles(featureToggle)(dispatch),
fetchFeatureToggles: () => fetchFeatureToggles()(dispatch),
});
const FormAddContainer = connect(

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import MySelect from '../common/select';
import GeneralSelect from '../common/GeneralSelect/GeneralSelect';
class FeatureTypeSelectComponent extends Component {
componentDidMount() {
@ -34,7 +34,7 @@ class FeatureTypeSelectComponent extends Component {
}
return (
<MySelect
<GeneralSelect
disabled={!editable}
options={options}
value={value}

View File

@ -1,6 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import MySelect from '../common/select';
import GeneralSelect from '../common/GeneralSelect/GeneralSelect';
class ProjectSelectComponent extends Component {
componentDidMount() {
@ -41,7 +41,7 @@ class ProjectSelectComponent extends Component {
}
return (
<MySelect
<GeneralSelect
label="Project"
options={options}
value={value}

View File

@ -4,12 +4,12 @@ import { IconButton, TextField } from '@material-ui/core';
import { Autocomplete } from '@material-ui/lab';
import { Delete } from '@material-ui/icons';
import MySelect from '../../../../common/select';
import InputListField from '../../../../common/input-list-field';
import ConditionallyRender from '../../../../common/ConditionallyRender/ConditionallyRender';
import { useCommonStyles } from '../../../../../common.styles';
import { useStyles } from './StrategyConstraintInputField.styles';
import { CONSTRAINT_AUTOCOMPLETE_ID } from '../../../../../testIds';
import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect';
const constraintOperators = [
{ key: 'IN', label: 'IN' },
@ -85,7 +85,7 @@ const StrategyConstraintInputField = ({
return (
<tr className={commonStyles.contentSpacingY}>
<td className={styles.tableCell}>
<MySelect
<GeneralSelect
name="contextName"
label="Context Field"
options={constraintContextNames}
@ -97,7 +97,7 @@ const StrategyConstraintInputField = ({
/>
</td>
<td className={styles.tableCell}>
<MySelect
<GeneralSelect
name="operator"
label="Operator"
options={constraintOperators}

View File

@ -1,29 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import MySelect from '../common/select';
class TagTypeSelectComponent extends Component {
componentDidMount() {
const { fetchTagTypes } = this.props;
if (fetchTagTypes) {
this.props.fetchTagTypes();
}
}
render() {
// eslint-disable-next-line no-unused-vars
const { value, types, onChange, fetchTagTypes, ...rest } = this.props;
const options = types.map(t => ({ key: t.name, label: t.name, title: t.name }));
return <MySelect label="Tag type" options={options} value={value} onChange={onChange} {...rest} />;
}
}
TagTypeSelectComponent.propTypes = {
value: PropTypes.string,
types: PropTypes.array.isRequired,
fetchTagTypes: PropTypes.func,
onChange: PropTypes.func.isRequired,
};
export default TagTypeSelectComponent;

View File

@ -1,12 +0,0 @@
import { connect } from 'react-redux';
import TagTypeSelectComponent from './tag-type-select-component';
import { fetchTagTypes } from '../../store/tag-type/actions';
const mapStateToProps = (state, ownProps) => ({
types: state.tagTypes.toJS(),
...ownProps,
});
const FormAddContainer = connect(mapStateToProps, { fetchTagTypes })(TagTypeSelectComponent);
export default FormAddContainer;

View File

@ -12,12 +12,13 @@ import {
} from '@material-ui/core';
import { Info } from '@material-ui/icons';
import Dialog from '../../../common/Dialogue';
import MySelect from '../../../common/select';
import { modalStyles, trim } from '../../../common/util';
import { weightTypes } from '../enums';
import OverrideConfig from './OverrideConfig/OverrideConfig';
import { useCommonStyles } from '../../../../common.styles';
import ConditionallyRender from '../../../common/ConditionallyRender';
import GeneralSelect from '../../../common/GeneralSelect/GeneralSelect';
const payloadOptions = [
{ key: 'string', label: 'string' },
@ -264,7 +265,7 @@ const AddVariant = ({
</p>
<Grid container>
<Grid item md={2} sm={2} xs={4}>
<MySelect
<GeneralSelect
name="type"
label="Type"
className={commonStyles.fullWidth}

View File

@ -4,12 +4,12 @@ import classnames from 'classnames';
import PropTypes from 'prop-types';
import { Grid, IconButton, TextField } from '@material-ui/core';
import { Delete } from '@material-ui/icons';
import MySelect from '../../../../common/select';
import InputListField from '../../../../common/input-list-field';
import ConditionallyRender from '../../../../common/ConditionallyRender/ConditionallyRender';
import { useCommonStyles } from '../../../../../common.styles';
import { useStyles } from './OverrideConfig.styles.js';
import { Autocomplete } from '@material-ui/lab';
import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect';
const OverrideConfig = ({
overrides,
@ -48,7 +48,7 @@ const OverrideConfig = ({
xs={3}
className={styles.contextFieldSelect}
>
<MySelect
<GeneralSelect
name="contextName"
label="Context Field"
value={o.contextName}

View File

@ -14,8 +14,9 @@ import {
Typography,
} from '@material-ui/core';
import AddVariant from './AddVariant/AddVariant';
import MySelect from '../../common/select';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import GeneralSelect from '../../common/GeneralSelect/GeneralSelect';
const initialState = {
showDialog: false,
@ -114,7 +115,7 @@ class UpdateVariantComponent extends Component {
return (
<section style={{ paddingTop: '16px' }}>
<MySelect
<GeneralSelect
label="Stickiness"
options={options}
value={value}
@ -130,7 +131,8 @@ class UpdateVariantComponent extends Component {
allocation across variants.{' '}
<a
href="https://docs.getunleash.io/advanced/toggle_variants"
target="_blank" rel="noreferrer"
target="_blank"
rel="noreferrer"
>
Read more
</a>

View File

@ -86,12 +86,64 @@ exports[`renders correctly with one feature 1`] = `
<div
className="selectContainer"
>
<FeatureTypeSelect
editable={true}
label="Feature type"
onChange={[Function]}
value="release"
/>
<div
className="MuiFormControl-root"
>
<label
className="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-shrink MuiInputLabel-marginDense MuiInputLabel-outlined MuiFormLabel-filled"
data-shrink={true}
>
Feature type
</label>
<div
className="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-formControl MuiInputBase-marginDense MuiOutlinedInput-marginDense"
onClick={[Function]}
>
<div
aria-haspopup="listbox"
className="MuiSelect-root MuiSelect-select MuiSelect-selectMenu MuiSelect-outlined MuiInputBase-input MuiOutlinedInput-input MuiInputBase-inputMarginDense MuiOutlinedInput-inputMarginDense"
onBlur={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onMouseDown={[Function]}
role="button"
tabIndex={0}
>
Release
</div>
<input
aria-hidden={true}
className="MuiSelect-nativeInput"
onAnimationStart={[Function]}
onChange={[Function]}
required={false}
tabIndex={-1}
value="release"
/>
<svg
aria-hidden={true}
className="MuiSvgIcon-root MuiSelect-icon MuiSelect-iconOutlined"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M7 10l5 5 5-5z"
/>
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-20 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-22 PrivateNotchedOutline-legendNotched-23"
>
<span>
Feature type
</span>
</legend>
</fieldset>
</div>
</div>
 
<div
className="MuiFormControl-root"

View File

@ -21,11 +21,15 @@ jest.mock('../update-strategies-container', () => ({
__esModule: true,
default: 'UpdateStrategiesComponent',
}));
jest.mock('../../feature-type-select-container', () => 'FeatureTypeSelect');
jest.mock(
'../../../feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect',
() => 'FeatureTypeSelect'
);
jest.mock('../../../common/ProjectSelect', () => 'ProjectSelect');
jest.mock('../../tag-type-select-container', () => 'TagTypeSelect');
jest.mock('../../../common/TagSelect/TagSelect', () => 'TagSelect');
jest.mock('../../feature-tag-component', () => 'FeatureTagComponent');
jest.mock('../../add-tag-dialog-container', () => 'AddTagDialog');
jest.spyOn(console, 'error').mockImplementation();
test('renders correctly with one feature', () => {
const feature = {
@ -60,6 +64,7 @@ test('renders correctly with one feature', () => {
},
}),
},
featureTypes: { toJS: () => [{ id: 'release', name: 'Release' }] },
};
const mockReducer = state => state;

View File

@ -11,7 +11,7 @@ export const Footer = () => {
return (
<footer className={styles.footer}>
<Grid container justify="center" spacing={10}>
<Grid container justifyContent="center" spacing={10}>
<Grid item md={4} xs={12}>
<ShowApiDetailsContainer />
</Grid>

View File

@ -5,7 +5,7 @@ exports[`should render DrawerMenu 1`] = `
className="makeStyles-footer-1"
>
<div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 MuiGrid-justify-xs-center"
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 MuiGrid-justify-content-xs-center"
>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-4"
@ -407,7 +407,7 @@ exports[`should render DrawerMenu with "features" selected 1`] = `
className="makeStyles-footer-1"
>
<div
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 MuiGrid-justify-xs-center"
className="MuiGrid-root MuiGrid-container MuiGrid-spacing-xs-10 MuiGrid-justify-content-xs-center"
>
<div
className="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 MuiGrid-grid-md-4"

View File

@ -184,6 +184,15 @@ Array [
"title": "Copy",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"menu": Object {},
"parent": "/projects/:id/features/:name/:activeTab",
"path": "/projects/:id/features2/:name/:activeTab/copy",
"title": "Copy",
"type": "protected",
},
Object {
"component": [Function],
"flags": "E",

View File

@ -221,6 +221,15 @@ export const routes = [
layout: 'main',
menu: {},
},
{
path: '/projects/:id/features2/:name/:activeTab/copy',
parent: '/projects/:id/features/:name/:activeTab',
title: 'Copy',
component: CopyFeatureToggle,
type: 'protected',
layout: 'main',
menu: {},
},
{
path: '/projects/:projectId/features2/:featureId',
parent: '/projects',

View File

@ -6,7 +6,8 @@ import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import ApiError from '../../common/ApiError/ApiError';
import ConditionallyRender from '../../common/ConditionallyRender';
import { useStyles } from './Project.styles';
import { Tab, Tabs } from '@material-ui/core';
import { IconButton, Tab, Tabs } from '@material-ui/core';
import { Edit } from '@material-ui/icons';
import useToast from '../../../hooks/useToast';
import useQueryParams from '../../../hooks/useQueryParams';
import { useEffect } from 'react';
@ -59,6 +60,15 @@ const Project = () => {
/* eslint-disable-next-line */
}, []);
const goToTabWithName = (name: string) => {
const index = tabData.findIndex(t => t.name === name);
if(index >= 0) {
const tab = tabData[index];
history.push(tab.path);
setActiveTab(index);
}
}
const tabData = [
{
title: 'Overview',
@ -127,8 +137,11 @@ const Project = () => {
<div ref={ref}>
<div className={styles.header}>
<div className={styles.innerContainer}>
<h2 data-loading className={commonStyles.title}>
<h2 data-loading className={commonStyles.title} style={{margin: 0}}>
Project: {project?.name}{' '}
<IconButton onClick={() => goToTabWithName('settings')}>
<Edit />
</IconButton>
</h2>
<p>{project?.description}</p>
</div>

View File

@ -24,6 +24,7 @@ export const useStyles = makeStyles(theme => ({
fontWeight: 'normal',
fontSize: '1rem',
},
projectIcon: {
margin: '1rem auto',
width: '80px',

View File

@ -29,16 +29,24 @@ interface ProjectEnvironmentListProps {
projectId: string;
}
const ProjectEnvironmentList = ({projectId}: ProjectEnvironmentListProps) => {
const ProjectEnvironmentList = ({ projectId }: ProjectEnvironmentListProps) => {
const { hasAccess } = useContext(AccessContext);
// api state
const { toast, setToastData } = useToast();
const { uiConfig } = useUiConfig();
const { environments, loading, error, refetch: refetchEnvs } = useEnvironments();
const {
environments,
loading,
error,
refetch: refetchEnvs,
} = useEnvironments();
const { project, refetch: refetchProject } = useProject(projectId);
const { removeEnvironmentFromProject, addEnvironmentToProject } = useProjectApi();
const { removeEnvironmentFromProject, addEnvironmentToProject } =
useProjectApi();
console.log(project);
// local state
const [selectedEnv, setSelectedEnv] = useState<ProjectEnvironment>();
const [confirmName, setConfirmName] = useState('');
@ -49,7 +57,7 @@ const ProjectEnvironmentList = ({projectId}: ProjectEnvironmentListProps) => {
const refetch = () => {
refetchEnvs();
refetchProject();
}
};
const renderError = () => {
return (
@ -62,74 +70,100 @@ const ProjectEnvironmentList = ({projectId}: ProjectEnvironmentListProps) => {
};
const errorMsg = (enable: boolean): string => {
return `Got an API error when trying to ${enable ? 'enable' : 'disable'} the environment.`
}
return `Got an API error when trying to ${
enable ? 'enable' : 'disable'
} the environment.`;
};
const toggleEnv = async (env: ProjectEnvironment) => {
if(env.enabled) {
if (env.enabled) {
setSelectedEnv(env);
} else {
try {
await addEnvironmentToProject(projectId, env.name);
setToastData({ text: 'Environment successfully enabled.', type: 'success', show: true});
setToastData({
text: 'Environment successfully enabled.',
type: 'success',
show: true,
});
} catch (error) {
setToastData({text: errorMsg(true), type: 'error', show: true});
setToastData({
text: errorMsg(true),
type: 'error',
show: true,
});
}
}
refetch();
}
};
const handleDisableEnvironment = async () => {
if(selectedEnv && confirmName===selectedEnv.name) {
if (selectedEnv && confirmName === selectedEnv.name) {
try {
await removeEnvironmentFromProject(projectId, selectedEnv.name);
setSelectedEnv(undefined);
setConfirmName('');
setToastData({ text: 'Environment successfully disabled.', type: 'success', show: true});
setToastData({
text: 'Environment successfully disabled.',
type: 'success',
show: true,
});
} catch (e) {
setToastData({ text: errorMsg(false), type: 'error', show: true});
setToastData({
text: errorMsg(false),
type: 'error',
show: true,
});
}
refetch();
}
}
}
};
const handleCancelDisableEnvironment = () => {
setSelectedEnv(undefined);
setConfirmName('');
}
};
const envs = environments.map(e => ({
name: e.name,
enabled: (project?.environments).includes(e.name),
enabled: project?.environments.includes(e.name),
}));
const hasPermission = hasAccess(UPDATE_PROJECT, projectId);
const genLabel = (env: ProjectEnvironment) => (
<>
<code>{env.name}</code> environment is <strong>{env.enabled ? 'enabled' : 'disabled'}</strong>
<code>{env.name}</code> environment is{' '}
<strong>{env.enabled ? 'enabled' : 'disabled'}</strong>
</>
);
const renderEnvironments = () => {
if(!uiConfig.flags.E) {
return <p>Feature not enabled.</p>
if (!uiConfig.flags.E) {
return <p>Feature not enabled.</p>;
}
return (
<FormGroup>
{envs.map(env => (
<FormControlLabel key={env.name} label={genLabel(env)} control={
<Switch size="medium" disabled={!hasPermission} checked={env.enabled} onChange={toggleEnv.bind(this, env)} />
}
<FormControlLabel
key={env.name}
label={genLabel(env)}
control={
<Switch
size="medium"
disabled={!hasPermission}
checked={env.enabled}
onChange={toggleEnv.bind(this, env)}
/>
}
/>
))}
</FormGroup>
);
};
return (
<div ref={ref}>
<PageContent
@ -168,7 +202,9 @@ const ProjectEnvironmentList = ({projectId}: ProjectEnvironmentListProps) => {
env={selectedEnv}
open={!!selectedEnv}
handleDisableEnvironment={handleDisableEnvironment}
handleCancelDisableEnvironment={handleCancelDisableEnvironment}
handleCancelDisableEnvironment={
handleCancelDisableEnvironment
}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>

View File

@ -1,7 +1,8 @@
import { TextField, Checkbox, FormControlLabel } from '@material-ui/core';
import PropTypes from 'prop-types';
import MySelect from '../../../../common/select';
import { styles as commonStyles } from '../../../../common';
import GeneralSelect from '../../../../common/GeneralSelect/GeneralSelect';
const paramTypesOptions = [
{ key: 'string', label: 'string' },
@ -26,7 +27,7 @@ const StrategyParameter = ({ set, input = {}, index }) => {
variant="outlined"
size="small"
/>
<MySelect
<GeneralSelect
label="Type"
options={paramTypesOptions}
value={input.type || 'string'}

View File

@ -131,7 +131,7 @@ exports[`renders correctly with one strategy 1`] = `
className="MuiListItemAvatar-root"
>
<div
className="MuiAvatar-root MuiAvatar-circle MuiAvatar-colorDefault"
className="MuiAvatar-root MuiAvatar-circular MuiAvatar-colorDefault"
>
<svg
aria-hidden={true}

View File

@ -26,7 +26,7 @@
}
.content {
width: 1100px;
width: 1250px;
margin-left: auto;
margin-right: auto;
margin-top: 16px;

View File

@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
import { TextField } from '@material-ui/core';
import styles from './Tag.module.scss';
import { FormButtons } from '../common';
import TagTypeSelect from '../feature/tag-type-select-container';
import PageContent from '../common/PageContent/PageContent';
import TagSelect from '../common/TagSelect/TagSelect';
class AddTagComponent extends Component {
constructor(props) {
@ -62,10 +62,12 @@ class AddTagComponent extends Component {
<section className={styles.container}>
<form onSubmit={this.onSubmit}>
<p style={{ color: 'red' }}>{errors.general}</p>
<TagTypeSelect
<TagSelect
name="type"
value={tag.type}
onChange={v => this.setValue('type', v.target.value)}
onChange={v =>
this.setValue('type', v.target.value)
}
className={styles.select}
/>
<TextField
@ -78,11 +80,16 @@ class AddTagComponent extends Component {
value={tag.value}
error={errors.value !== undefined}
helperText={errors.value}
onChange={v => this.setValue('value', v.target.value)}
onChange={v =>
this.setValue('value', v.target.value)
}
className={styles.textfield}
/>
<div className={styles.formbuttons}>
<FormButtons submitText={submitText} onCancel={this.onCancel} />
<FormButtons
submitText={submitText}
onCancel={this.onCancel}
/>
</div>
</form>
</section>

View File

@ -68,7 +68,7 @@ const useFeatureApi = () => {
}
};
const addTag = async (featureId: string, tag: ITag) => {
const addTagToFeature = 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, {
@ -85,7 +85,7 @@ const useFeatureApi = () => {
}
};
const deleteTag = async (
const deleteTagFromFeature = async (
featureId: string,
type: string,
value: string
@ -105,13 +105,74 @@ const useFeatureApi = () => {
}
};
const archiveFeatureToggle = async (
projectId: string,
featureId: string
) => {
const path = `api/admin/projects/${projectId}/features/${featureId}`;
const req = createRequest(path, {
method: 'DELETE',
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const patchFeatureToggle = async (
projectId: string,
featureId: string,
patchPayload: any
) => {
const path = `api/admin/projects/${projectId}/features/${featureId}`;
const req = createRequest(path, {
method: 'PATCH',
body: JSON.stringify(patchPayload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const cloneFeatureToggle = async (
projectId: string,
featureId: string,
payload: {name: string, replaceGroupId: boolean}
) => {
const path = `api/admin/projects/${projectId}/features/${featureId}/clone`;
const req = createRequest(
path,
{ method: 'POST', body: JSON.stringify(payload) },
);
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
changeFeatureProject,
errors,
toggleFeatureEnvironmentOn,
toggleFeatureEnvironmentOff,
addTag,
deleteTag,
addTagToFeature,
deleteTagFromFeature,
archiveFeatureToggle,
patchFeatureToggle,
cloneFeatureToggle
};
};

View File

@ -17,13 +17,29 @@ const useFeature = (
id: string,
options: IUseFeatureOptions = {}
) => {
const fetcher = () => {
const fetcher = async () => {
const path = formatApiPath(
`api/admin/projects/${projectId}/features/${id}`
);
return fetch(path, {
const res = await fetch(path, {
method: 'GET',
}).then(res => res.json());
});
// If the status code is not in the range 200-299,
// we still try to parse and throw it.
if (!res.ok) {
const error = new Error('An error occurred while fetching the data.')
// Attach extra info to the error object.
// @ts-ignore
error.info = await res.json();
// @ts-ignore
error.status = res.status;
throw error;
}
return res.json()
};
const FEATURE_CACHE_KEY = `api/admin/projects/${projectId}/features/${id}`;

View File

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

View File

@ -24,3 +24,10 @@ export interface IEnvironmentResponse {
export interface ISortOrderPayload {
[index: string]: number;
}
export interface IEnvironmentMetrics {
name: string;
yes: number;
no: number;
timestamp: string;
}

View File

@ -0,0 +1,6 @@
export interface IFeatureType {
id: string;
name: string;
description: string;
lifetimeDays: number;
}

View File

@ -1581,15 +1581,15 @@
"@types/yargs" "^16.0.0"
chalk "^4.0.0"
"@material-ui/core@4.11.3":
version "4.11.3"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.11.3.tgz#f22e41775b0bd075e36a7a093d43951bf7f63850"
integrity sha512-Adt40rGW6Uds+cAyk3pVgcErpzU/qxc7KBR94jFHBYretU4AtWZltYcNsbeMn9tXL86jjVL1kuGcIHsgLgFGRw==
"@material-ui/core@4.12.3":
version "4.12.3"
resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.3.tgz#80d665caf0f1f034e52355c5450c0e38b099d3ca"
integrity sha512-sdpgI/PL56QVsEJldwEe4FFaFTLUqN+rd7sSZiRCdx2E/C7z5yK0y/khAWVBH24tXwto7I1hCzNWfJGZIYJKnw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/styles" "^4.11.3"
"@material-ui/system" "^4.11.3"
"@material-ui/types" "^5.1.0"
"@material-ui/styles" "^4.11.4"
"@material-ui/system" "^4.12.1"
"@material-ui/types" "5.1.0"
"@material-ui/utils" "^4.11.2"
"@types/react-transition-group" "^4.2.0"
clsx "^1.0.4"
@ -1606,10 +1606,10 @@
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/lab@4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw==
"@material-ui/lab@4.0.0-alpha.60":
version "4.0.0-alpha.60"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.60.tgz#5ad203aed5a8569b0f1753945a21a05efa2234d2"
integrity sha512-fadlYsPJF+0fx2lRuyqAuJj7hAS1tLDdIEEdov5jlrpb5pp4b+mRDUqQTUxi4inRZHS1bEXpU8QWUhO6xX88aA==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.11.2"
@ -1617,14 +1617,14 @@
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"
"@material-ui/styles@^4.11.3":
version "4.11.3"
resolved "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.3.tgz"
integrity sha512-HzVzCG+PpgUGMUYEJ2rTEmQYeonGh41BYfILNFb/1ueqma+p1meSdu4RX6NjxYBMhf7k+jgfHFTTz+L1SXL/Zg==
"@material-ui/styles@^4.11.4":
version "4.11.4"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.4.tgz#eb9dfccfcc2d208243d986457dff025497afa00d"
integrity sha512-KNTIZcnj/zprG5LW0Sao7zw+yG3O35pviHzejMdcSGCdWbiO8qzRgOYL8JAxAsWBKOKYwVZxXtHWaB5T2Kvxew==
dependencies:
"@babel/runtime" "^7.4.4"
"@emotion/hash" "^0.8.0"
"@material-ui/types" "^5.1.0"
"@material-ui/types" "5.1.0"
"@material-ui/utils" "^4.11.2"
clsx "^1.0.4"
csstype "^2.5.2"
@ -1639,19 +1639,19 @@
jss-plugin-vendor-prefixer "^10.5.1"
prop-types "^15.7.2"
"@material-ui/system@^4.11.3":
version "4.11.3"
resolved "https://registry.npmjs.org/@material-ui/system/-/system-4.11.3.tgz"
integrity sha512-SY7otguNGol41Mu2Sg6KbBP1ZRFIbFLHGK81y4KYbsV2yIcaEPOmsCK6zwWlp+2yTV3J/VwT6oSBARtGIVdXPw==
"@material-ui/system@^4.12.1":
version "4.12.1"
resolved "https://registry.yarnpkg.com/@material-ui/system/-/system-4.12.1.tgz#2dd96c243f8c0a331b2bb6d46efd7771a399707c"
integrity sha512-lUdzs4q9kEXZGhbN7BptyiS1rLNHe6kG9o8Y307HCvF4sQxbCgpL2qi+gUk+yI8a2DNk48gISEQxoxpgph0xIw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.11.2"
csstype "^2.5.2"
prop-types "^15.7.2"
"@material-ui/types@^5.1.0":
"@material-ui/types@5.1.0":
version "5.1.0"
resolved "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz"
resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2"
integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==
"@material-ui/utils@^4.11.2":
@ -2107,10 +2107,10 @@
resolved "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz"
integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
"@types/node@14.17.20":
version "14.17.20"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.20.tgz#74cc80438fd0467dc4377ee5bbad89a886df3c10"
integrity sha512-gI5Sl30tmhXsqkNvopFydP7ASc4c2cLfGNQrVKN3X90ADFWFsPEsotm/8JHSUJQKTHbwowAHtcJPeyVhtKv0TQ==
"@types/node@14.17.21":
version "14.17.21"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.21.tgz#6359d8cf73481e312a43886fa50afc70ce5592c6"
integrity sha512-zv8ukKci1mrILYiQOwGSV4FpkZhyxQtuFWGya2GujWg+zVAeRQ4qbaMmWp9vb9889CFA8JECH7lkwCL6Ygg8kA==
"@types/node@^14.14.31":
version "14.17.19"
@ -2159,10 +2159,10 @@
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react-router-dom@5.3.0":
version "5.3.0"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.0.tgz#8c4e0aa0ccaf638ba965829ad29a10ac3cbe2212"
integrity sha512-svUzpEpKDwK8nmfV2vpZNSsiijFNKY8+gUqGqvGGOVrXvX58k1JIJubZa5igkwacbq/0umphO5SsQn/BQsnKpw==
"@types/react-router-dom@5.3.1":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.1.tgz#76700ccce6529413ec723024b71f01fc77a4a980"
integrity sha512-UvyRy73318QI83haXlaMwmklHHzV9hjl3u71MmM6wYNu0hOVk9NLTa0vGukf8zXUqnwz4O06ig876YSPpeK28A==
dependencies:
"@types/history" "*"
"@types/react" "*"
@ -4631,10 +4631,10 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
date-fns@2.24.0:
version "2.24.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.24.0.tgz#7d86dc0d93c87b76b63d213b4413337cfd1c105d"
integrity sha512-6ujwvwgPID6zbI0o7UbURi2vlLDR9uP26+tW6Lg+Ji3w7dd0i3DOcjcClLjLPranT60SSEFBwdSyYwn/ZkPIuw==
date-fns@2.25.0:
version "2.25.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.25.0.tgz#8c5c8f1d958be3809a9a03f4b742eba894fc5680"
integrity sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w==
dayjs@^1.10.4:
version "1.10.7"
@ -5787,6 +5787,11 @@ fast-glob@^3.1.1:
micromatch "^4.0.2"
picomatch "^2.2.1"
fast-json-patch@3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.0.tgz#ec8cd9b9c4c564250ec8b9140ef7a55f70acaee6"
integrity sha512-IhpytlsVTRndz0hU5t0/MGzS/etxLlfrpG5V5M9mVbuj9TrJLWaMfsox9REM5rkuGX0T+5qjpe8XA1o0gZ42nA==
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
@ -6706,10 +6711,10 @@ immer@8.0.1:
resolved "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz"
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
immutable@4.0.0-rc.15:
version "4.0.0-rc.15"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0-rc.15.tgz#c30056f05eaaf5650fd15230586688fdd15c54bc"
integrity sha512-v8+A3sNyaieoP9dHegl3tEYnIZa7vqNiSv0U6D7YddiZi34VjKy4GsIxrRHj2d8+CS3MeiVja5QyNe4JO/aEXA==
immutable@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23"
integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==
import-cwd@^2.0.0:
version "2.1.0"
@ -12742,10 +12747,10 @@ wbuf@^1.1.0, wbuf@^1.7.3:
dependencies:
minimalistic-assert "^1.0.0"
web-vitals@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.0.tgz#ebf5428875ab5bfc1056c2e80cd177001287de7b"
integrity sha512-npEyJP8jHf3J71t1tRTEtz9FeKp8H2udWJUUq5ykfPhhstr//TUxiYhIEzLNwk4zv2ybAilMn7v7N6Mxmuitmg==
web-vitals@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.1.tgz#9ae64fa9054b8865a3fa093cd8db302676822c2a"
integrity sha512-6i/cE+7l095Etvjo2kbtVC8OXzLc9D8XMIWBPWAt2CME/7qmIMZWQwVoKDD277poVHNdPcLgW5Jruhbi8+8Vcw==
webidl-conversions@^3.0.0:
version "3.0.1"