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:
commit
2d94ca707a
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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": {
|
||||
|
@ -78,7 +78,7 @@ export const useCommonStyles = makeStyles(theme => ({
|
||||
bottom: '40px',
|
||||
transform: 'translateY(400px)',
|
||||
zIndex: 300,
|
||||
position: 'relative',
|
||||
position: 'fixed',
|
||||
},
|
||||
fadeInBottomEnter: {
|
||||
transform: 'translateY(0)',
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -16,7 +16,6 @@ const BreadcrumbNav = () => {
|
||||
item =>
|
||||
item !== 'create' &&
|
||||
item !== 'edit' &&
|
||||
item !== 'access' &&
|
||||
item !== 'view' &&
|
||||
item !== 'variants' &&
|
||||
item !== 'logs' &&
|
||||
|
@ -15,7 +15,7 @@ interface IDialogue {
|
||||
primaryButtonText?: string;
|
||||
secondaryButtonText?: string;
|
||||
open: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (e: any) => void;
|
||||
onClose: () => void;
|
||||
style?: object;
|
||||
title: string;
|
||||
|
@ -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;
|
@ -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;
|
||||
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
32
frontend/src/component/common/TagSelect/TagSelect.tsx
Normal file
32
frontend/src/component/common/TagSelect/TagSelect.tsx
Normal 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;
|
@ -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);
|
||||
|
@ -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}
|
||||
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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',
|
@ -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' },
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -12,6 +12,7 @@ export const useStyles = makeStyles(theme => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
},
|
||||
cardHeader: {
|
||||
|
@ -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`}
|
||||
|
@ -0,0 +1,9 @@
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
dialogFormContent: {
|
||||
['& > *']: {
|
||||
margin: '0.5rem 0',
|
||||
},
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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,
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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,
|
||||
|
@ -37,4 +37,7 @@ export const useStyles = makeStyles(theme => ({
|
||||
fill: '#fff',
|
||||
transition: 'color 0.4s ease',
|
||||
},
|
||||
environmentList: {
|
||||
marginTop: 0,
|
||||
},
|
||||
}));
|
||||
|
@ -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}
|
||||
|
@ -133,6 +133,7 @@ const useFeatureStrategiesEnvironmentList = (
|
||||
setExpandedSidebar,
|
||||
expandedSidebar,
|
||||
featureId,
|
||||
activeEnvironment,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -16,6 +16,7 @@ export const useStyles = makeStyles(theme => ({
|
||||
width: '85%',
|
||||
},
|
||||
},
|
||||
|
||||
environmentsHeader: {
|
||||
padding: '2rem 2rem 1rem 2rem',
|
||||
display: 'flex',
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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);
|
@ -0,0 +1,7 @@
|
||||
import { makeStyles } from '@material-ui/styles';
|
||||
|
||||
export const useStyles = makeStyles(theme => ({
|
||||
contextFieldSelect: {
|
||||
marginRight: '8px',
|
||||
},
|
||||
}));
|
@ -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}
|
||||
/>
|
||||
|
||||
<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}
|
||||
// />
|
||||
//
|
||||
// <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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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],
|
||||
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 =>
|
||||
|
@ -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 {copyToggle.name}</h1>
|
||||
<h1>Copy {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
|
||||
<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,
|
||||
};
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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;
|
@ -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;
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -24,6 +24,7 @@ export const useStyles = makeStyles(theme => ({
|
||||
fontWeight: 'normal',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
|
||||
projectIcon: {
|
||||
margin: '1rem auto',
|
||||
width: '80px',
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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'}
|
||||
|
@ -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}
|
||||
|
@ -26,7 +26,7 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 1100px;
|
||||
width: 1250px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 16px;
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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}`;
|
||||
|
@ -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;
|
@ -24,3 +24,10 @@ export interface IEnvironmentResponse {
|
||||
export interface ISortOrderPayload {
|
||||
[index: string]: number;
|
||||
}
|
||||
|
||||
export interface IEnvironmentMetrics {
|
||||
name: string;
|
||||
yes: number;
|
||||
no: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
6
frontend/src/interfaces/featureTypes.ts
Normal file
6
frontend/src/interfaces/featureTypes.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface IFeatureType {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
lifetimeDays: number;
|
||||
}
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user