diff --git a/frontend/cypress/integration/feature-toggle/feature.spec.js b/frontend/cypress/integration/feature-toggle/feature.spec.js index e36eb442d2..f9a1488bd3 100644 --- a/frontend/cypress/integration/feature-toggle/feature.spec.js +++ b/frontend/cypress/integration/feature-toggle/feature.spec.js @@ -1,5 +1,5 @@ +/* eslint-disable jest/no-conditional-expect */ /// - // 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'); + }); }); diff --git a/frontend/package.json b/frontend/package.json index e07c9b9f57..767c382fd6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/common.styles.js b/frontend/src/common.styles.js index 25764ca2a1..a8605de4e4 100644 --- a/frontend/src/common.styles.js +++ b/frontend/src/common.styles.js @@ -78,7 +78,7 @@ export const useCommonStyles = makeStyles(theme => ({ bottom: '40px', transform: 'translateY(400px)', zIndex: 300, - position: 'relative', + position: 'fixed', }, fadeInBottomEnter: { transform: 'translateY(0)', diff --git a/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx b/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx index 62b65cd513..a82cd6e1ca 100644 --- a/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx +++ b/frontend/src/component/api-token/ApiTokenCreate/ApiTokenCreate.tsx @@ -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({}); 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 (
- - - - - - - } /> - - + onSubmit={submit} + className={classNames( + styles.addApiKeyForm, + commonStyles.contentSpacing + )} + > + + + + +
); }; diff --git a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap index aa39a68aa9..bf0c1bff96 100644 --- a/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap +++ b/frontend/src/component/application/__tests__/__snapshots__/application-edit-component-test.js.snap @@ -53,7 +53,7 @@ exports[`renders correctly with permissions 1`] = ` } >
- ({ key: v, label: v }))} value={icon || 'apps'} - onChange={e => storeApplicationMetaData(appName, 'icon', e.target.value)} + onChange={e => + storeApplicationMetaData( + appName, + 'icon', + e.target.value + ) + } /> @@ -31,7 +37,9 @@ function ApplicationUpdate({ application, storeApplicationMetaData }) { type="url" variant="outlined" size="small" - onBlur={() => storeApplicationMetaData(appName, 'url', localUrl)} + onBlur={() => + storeApplicationMetaData(appName, 'url', localUrl) + } /> @@ -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 + ) + } /> diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx index 8c7ff41fd1..e746891fa8 100644 --- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -16,7 +16,6 @@ const BreadcrumbNav = () => { item => item !== 'create' && item !== 'edit' && - item !== 'access' && item !== 'view' && item !== 'variants' && item !== 'logs' && diff --git a/frontend/src/component/common/Dialogue/Dialogue.tsx b/frontend/src/component/common/Dialogue/Dialogue.tsx index 1f8751bd58..e795fe854f 100644 --- a/frontend/src/component/common/Dialogue/Dialogue.tsx +++ b/frontend/src/component/common/Dialogue/Dialogue.tsx @@ -15,7 +15,7 @@ interface IDialogue { primaryButtonText?: string; secondaryButtonText?: string; open: boolean; - onClick: () => void; + onClick: (e: any) => void; onClose: () => void; style?: object; title: string; diff --git a/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx new file mode 100644 index 0000000000..c0cc0c551e --- /dev/null +++ b/frontend/src/component/common/GeneralSelect/GeneralSelect.tsx @@ -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 = ({ + name, + value = '', + label = '', + options, + onChange, + id, + disabled = false, + className, + classes, + ...rest +}) => { + const renderSelectItems = () => + options.map(option => ( + + {option.label} + + )); + + return ( + + + {label} + + + + ); +}; + +export default GeneralSelect; diff --git a/frontend/src/component/common/Input/Input.tsx b/frontend/src/component/common/Input/Input.tsx index 0a89576af8..ebf4d1c407 100644 --- a/frontend/src/component/common/Input/Input.tsx +++ b/frontend/src/component/common/Input/Input.tsx @@ -1,9 +1,8 @@ import { TextField } from '@material-ui/core'; import { useStyles } from './Input.styles.ts'; -interface IInputProps { +interface IInputProps extends React.InputHTMLAttributes { label: string; - placeholder?: string; error?: boolean; errorText?: string; style?: Object; diff --git a/frontend/src/component/common/PermissionButton/PermissionButton.tsx b/frontend/src/component/common/PermissionButton/PermissionButton.tsx new file mode 100644 index 0000000000..ada0e5de40 --- /dev/null +++ b/frontend/src/component/common/PermissionButton/PermissionButton.tsx @@ -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 { + permission: string; + tooltip: string; + onClick?: (e: any) => void; + disabled?: boolean; +} + +const PermissionButton: React.FC = ({ + 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 ( + + + + + + ); +}; + +export default PermissionButton; diff --git a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx new file mode 100644 index 0000000000..06ea26cad7 --- /dev/null +++ b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx @@ -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 { + permission: string; + Icon: React.ElementType; + tooltip: string; + onClick?: (e: any) => void; +} + +const PermissionIconButton: React.FC = ({ + 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 ( + + + + {children} + + + + ); +}; + +export default PermissionIconButton; diff --git a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx index a6e49f57fe..9e1fcf20da 100644 --- a/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx +++ b/frontend/src/component/common/ResponsiveButton/ResponsiveButton.tsx @@ -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 = ({ tooltip, disabled = false, children, + permission, ...rest }) => { const smallScreen = useMediaQuery(`(max-width:${maxWidth})`); return ( - - - - - - } - elseShow={ - - } - /> - - + + + + } + elseShow={ + + {children} + + } + /> ); }; diff --git a/frontend/src/component/common/TagSelect/TagSelect.tsx b/frontend/src/component/common/TagSelect/TagSelect.tsx new file mode 100644 index 0000000000..469e39f15b --- /dev/null +++ b/frontend/src/component/common/TagSelect/TagSelect.tsx @@ -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 { + 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 ( + + ); +}; + +export default TagSelect; diff --git a/frontend/src/component/common/util.js b/frontend/src/component/common/util.js index 0836d9c2f5..01bca94b6f 100644 --- a/frontend/src/component/common/util.js +++ b/frontend/src/component/common/util.js @@ -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); diff --git a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx index cdc3030ba6..1c9228824e 100644 --- a/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx +++ b/frontend/src/component/environments/CreateEnvironment/CreateEnvironment.tsx @@ -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={ +

Environments allow you to manage your product @@ -152,6 +158,16 @@ const CreateEnvironment = () => {

+ } elseShow={ + <> + +

Currently Unleash does not support more than 5 environments. If you need more please reach out.

+
+
+ + + } /> + } /> {toast} diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics.styles.ts new file mode 100644 index 0000000000..c5bd1a8936 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics.tsx new file mode 100644 index 0000000000..cb06d739b2 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureEnvironmentMetrics/FeatureEnvironmentMetrics.tsx @@ -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 ( +
+
+

Traffic in {metric.name}

+
+ +
+
+

+ No metrics available for this environment. +

+
+ +
+ +
+
+
+ ); + } + + return ( +
+
+

Traffic in {metric.name}

+
+ +
+
+
+
+
+
+

+ {metric.yes} users received this feature +

+
+ +
+
+
+
+

+ {metric.no} users did not receive this feature +

+
+
+
+ +
+
+
+ ); +}; + +export default FeatureEnvironmentMetrics; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.styles.ts index 1af8d4ceaa..4c793a4dda 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.styles.ts @@ -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', + }, })); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.tsx index c0e8ca75d7..e4fc9b8f10 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverview.tsx @@ -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 (
-
- +
+ +
+
+ +
diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureViewMetaData/FeatureViewMetaData.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx similarity index 66% rename from frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureViewMetaData/FeatureViewMetaData.tsx rename to frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx index f3c3a4239a..8339296409 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureViewMetaData/FeatureViewMetaData.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetaData.tsx @@ -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(); @@ -30,13 +31,16 @@ const FeatureViewMetaData = () => {
Project: {project}
Description:

{description}

- +
@@ -44,10 +48,15 @@ const FeatureViewMetaData = () => { } elseShow={ - No description.{' '} - - - +
+ No description.{' '} + + + +
} /> @@ -56,4 +65,4 @@ const FeatureViewMetaData = () => { ); }; -export default FeatureViewMetaData; +export default FeatureOverviewMetaData; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureViewMetaData/FeatureViewMetadata.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetadata.styles.ts similarity index 88% rename from frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureViewMetaData/FeatureViewMetadata.styles.ts rename to frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetadata.styles.ts index bdf7d9e4a8..fac3208c18 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureViewMetaData/FeatureViewMetadata.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetaData/FeatureOverviewMetadata.styles.ts @@ -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', diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetrics/FeatureOverviewMetrics.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetrics/FeatureOverviewMetrics.styles.ts new file mode 100644 index 0000000000..b99eeeed69 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetrics/FeatureOverviewMetrics.styles.ts @@ -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' }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetrics/FeatureOverviewMetrics.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetrics/FeatureOverviewMetrics.tsx new file mode 100644 index 0000000000..848a4b678e --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewMetrics/FeatureOverviewMetrics.tsx @@ -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(); + const { feature } = useFeature(projectId, featureId); + const [featureMetrics, setFeatureMetrics] = useState( + [] + ); + + 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 ( + + ); + } + + if (featureMetrics.length === 2) { + return featureMetrics.map((metric, index) => { + if (index === 0) { + return ( + + ); + } + return ( + + ); + }); + } + + /* We display maxium three environments metrics */ + if (featureMetrics.length >= 3) { + return featureMetrics.slice(0, 3).map((metric, index) => { + if (index === 0) { + return ( + + ); + } + + if (index === 1) { + return ( + + ); + } + + return ( + + ); + }); + } + }; + + return renderFeatureMetrics(); +}; + +export default FeatureOverviewMetrics; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/FeatureOverviewStale.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/FeatureOverviewStale.styles.ts new file mode 100644 index 0000000000..86c2ccb450 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/FeatureOverviewStale.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/FeatureOverviewStale.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/FeatureOverviewStale.tsx new file mode 100644 index 0000000000..1506313866 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/FeatureOverviewStale.tsx @@ -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(); + const { feature } = useFeature(projectId, featureId); + + const FlipStateButton = () => (feature.stale ? : ); + + return ( +
+
+
+

Status

+
+
+
+ + Feature is {feature.stale ? 'stale' : 'active'} + +
+ setOpenStaleDialog(true)} + permission={UPDATE_FEATURE} + tooltip="Flip status" + > + + +
+
+ +
+ ); +}; + +export default FeatureOverviewStale; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/StaleDialog/StaleDialog.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/StaleDialog/StaleDialog.tsx new file mode 100644 index 0000000000..d646589ba5 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStale/StaleDialog/StaleDialog.tsx @@ -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>; + stale: boolean; +} + +const StaleDialog = ({ open, setOpen, stale }: IStaleDialogProps) => { + const { projectId, featureId } = useParams(); + const { patchFeatureToggle } = useFeatureApi(); + const { refetch } = useFeature(projectId, featureId); + + const toggleToStaleContent = ( + + Setting a toggle to stale marks it for cleanup + + ); + const toggleToActiveContent = ( + + Setting a toggle to active marks it as in active use + + ); + + 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 ( + <> + + <> + + + + + ); +}; + +export default StaleDialog; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts index b8ba159492..b1a363f490 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.styles.ts @@ -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', - }, })); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx index 4fb8b4e4b1..fbdc71de0d 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx @@ -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(); - 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 ( -
-
-
- -
-

{env.type}

+ +
+ {renderStrategies()}
- -
-
-

{env.name}

-
-
- 0} - show={ - <> - {' '} - - This environment is{' '} - {env.enabled ? 'enabled' : 'disabled'} - - - } - elseShow={ - <> -

- No strategies configured for environment. -

- - Configure strategies for {env.name} - - - } - /> -
-
- - -
- {renderStrategies()} -
-
- } - /> - {toast} -
+ ); }; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewStrategyCard/FeatureOverviewStrategyCard.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewStrategyCard/FeatureOverviewStrategyCard.styles.ts index cf610c3638..ae3b92be3b 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewStrategyCard/FeatureOverviewStrategyCard.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewEnvironment/FeatureOverviewStrategyCard/FeatureOverviewStrategyCard.styles.ts @@ -12,6 +12,7 @@ export const useStyles = makeStyles(theme => ({ display: 'flex', alignItems: 'center', padding: '0.75rem', + cursor: 'pointer', fontSize: theme.fontSizes.bodySize, }, cardHeader: { diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewStrategies.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewStrategies.tsx index 3ea0cfe782..d4d8e5cedb 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewStrategies.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewStrategies/FeatureOverviewStrategies.tsx @@ -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(); - 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 ( - - ); + return ; }); }; @@ -35,7 +30,9 @@ const FeatureOverviewStrategies = () => {
({ + dialogFormContent: { + ['& > *']: { + margin: '0.5rem 0', + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/AddTagDialog/AddTagDialog.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/AddTagDialog/AddTagDialog.tsx new file mode 100644 index 0000000000..c8e60430b2 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/AddTagDialog/AddTagDialog.tsx @@ -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>; +} + +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(); + 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 ( + <> + + <> + + Tags allows you to group features together + +
+
+ setValue('type', e.target.value)} + /> +
+ + setValue('value', e.target.value) + } + /> +
+
+ +
+ {toast} + + ); +}; + +export default AddTagDialog; diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.styles.ts index f5562e2443..36e7520b30 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.styles.ts @@ -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, + }, })); diff --git a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.tsx b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.tsx index d0b8601933..fd8c0729bb 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureOverview/FeatureOverviewTags/FeatureOverviewTags.tsx @@ -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({ + value: '', + type: '', + }); const styles = useStyles(); const { featureId } = useParams(); 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 => ( handleDelete(t.type, t.value)} + onDelete={() => { + setShowDelDialog(true); + setSelectedTag({ type: t.type, value: t.value }); + }} /> ); @@ -83,17 +112,41 @@ const FeatureOverviewTags = () => {
-
- - {/* + + setOpenTagDialog(true)} + permission={UPDATE_FEATURE} + tooltip="Add tag" + > - */} +
-
{tags.map(renderTag)}
+ { + setShowDelDialog(false); + setSelectedTag({ type: '', value: '' }); + }} + onClick={() => { + setShowDelDialog(false); + handleDelete(); + setSelectedTag({ type: '', value: '' }); + }} + title="Are you sure you want to delete this tag?" + /> + +
+ 0} + show={tags.map(renderTag)} + elseShow={

No tags to display

} + /> +
+ {toast}
); }; diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettings.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettings.styles.ts new file mode 100644 index 0000000000..7137b7a66b --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettings.styles.ts @@ -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', + }, + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettings.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettings.tsx new file mode 100644 index 0000000000..f08bf03c17 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettings.tsx @@ -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 ( + +
+
+ + setSettings(METADATA)} + > + Metadata + + setSettings(PROJECT)} + > + Project + + +
+ +
+ } + /> + } + /> +
+
+
+ ); +}; + +export default FeatureSettings; diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx new file mode 100644 index 0000000000..f4b39ae383 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureSettingsMetadata.tsx @@ -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(); + 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 ( + <> + setType(e.target.value)} + label="Feature type" + editable={editable} + /> + + setDescription(e.target.value)} + /> + + Save changes + + } + /> + + {toast} + + ); +}; + +export default FeatureSettingsMetadata; diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect.tsx new file mode 100644 index 0000000000..739096cb0c --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsMetadata/FeatureTypeSelect/FeatureTypeSelect.tsx @@ -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 ( + + ); +}; + +export default FeatureTypeSelect; diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect.tsx new file mode 100644 index 0000000000..a176348f8e --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureProjectSelect/FeatureProjectSelect.tsx @@ -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 ( + + ); +}; + +export default FeatureProjectSelect; diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx new file mode 100644 index 0000000000..9d8372e220 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProject.tsx @@ -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(); + 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 ( + <> + setProject(e.target.value)} + label="Project" + enabled={editable} + filter={projectFilterGenerator({ permissions }, CREATE_FEATURE)} + /> + setShowConfirmDialog(true)} + > + Save changes + + } + /> + setShowConfirmDialog(false)} + onClick={updateProject} + /> + {toast} + + ); +}; + +export default FeatureSettingsProject; diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.styles.ts new file mode 100644 index 0000000000..521a39c3c9 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.styles.ts @@ -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', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.tsx b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.tsx new file mode 100644 index 0000000000..c1f78224b0 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureSettings/FeatureSettingsProject/FeatureSettingsProjectConfirm/FeatureSettingsProjectConfirm.tsx @@ -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 ( + + Are you sure you want to change the project for this feature toggle? + + This feature toggle is 100% compatible with the new + project. +
+ +
+
+ } + elseShow={ +
+
+
+

+ {' '} + This feature toggle is not compatible with + the new project destination. +

+
+
+ +
+
+
+ +

+ This feature toggle has strategy configuration + in an environment that is not activated in the + target project: +

+ + {incompatibleEnvs.map(env => { + return ( + + + {env} + + ); + })} + +

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

+
+
+ } + /> + + ); +}; + +export default FeatureSettingsProjectConfirm; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx index 3e06d0b0d4..4b6697c2b3 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesConfigure/FeatureStrategiesConfigure.tsx @@ -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>; @@ -27,6 +28,8 @@ const FeatureStrategiesConfigure = ({ const history = useHistory(); const { projectId, featureId } = useParams(); + 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, diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts index f7448e01be..2aab924370 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.styles.ts @@ -37,4 +37,7 @@ export const useStyles = makeStyles(theme => ({ fill: '#fff', transition: 'color 0.4s ease', }, + environmentList: { + marginTop: 0, + }, })); diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx index b4b6935c8b..32861d235e 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/FeatureStrategiesEnvironmentList.tsx @@ -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 ( -
- {renderStrategies()} -
+ +
+ 0 + } + show={renderStrategies()} + /> +
+
{dropboxMarkup} {toast} {delDialogueMarkup} diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts index f200ac77fd..7796c54729 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironmentList/useFeatureStrategiesEnvironmentList.ts @@ -133,6 +133,7 @@ const useFeatureStrategiesEnvironmentList = ( setExpandedSidebar, expandedSidebar, featureId, + activeEnvironment, }; }; diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts index fcef882b64..f2137f05d3 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.styles.ts @@ -16,6 +16,7 @@ export const useStyles = makeStyles(theme => ({ width: '85%', }, }, + environmentsHeader: { padding: '2rem 2rem 1rem 2rem', display: 'flex', diff --git a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx index 4d0f1f587a..d7f2be9f95 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureStrategies/FeatureStrategiesEnvironments/FeatureStrategiesEnvironments.tsx @@ -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 = () => { @@ -370,7 +374,7 @@ const FeatureStrategiesEnvironments = () => { } Icon={Add} maxWidth="700px" - disabled={!hasAccess(UPDATE_FEATURE)} + permission={UPDATE_FEATURE} > Add new strategy diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariants.tsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariants.tsx index df223addc7..7b4654645c 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariants.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariants.tsx @@ -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(); - const { feature } = useFeature(projectId, featureId); - const history = useHistory(); return (
- +
); }; diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx new file mode 100644 index 0000000000..0c20848aa2 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/AddFeatureVariant.tsx @@ -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 ( + +
+

{error.general}

+ +
+ + + + % + + ), + }} + style={{ marginRight: '0.8rem' }} + value={data.weight || ''} + error={Boolean(error.weight)} + type="number" + disabled={!isFixWeight} + onChange={setVariantValue} + /> + + + + + } + label="Custom percentage" + /> + + + +

+ Payload + + + +

+ + + + + + + + + 0} + show={ +

+ Overrides + + + +

+ } + /> + + {' '} + +
+ ); +}; + +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; diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.jsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.jsx new file mode 100644 index 0000000000..053487dd87 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.jsx @@ -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 ( + + + + + + 0} + show={ + { + 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 => ( + + )} + /> + } + elseShow={ + + } + /> + + + + + + + + ); + }); +}; + +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); diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.styles.js b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.styles.js new file mode 100644 index 0000000000..8025622536 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/AddFeatureVariant/OverrideConfig/OverrideConfig.styles.js @@ -0,0 +1,7 @@ +import { makeStyles } from '@material-ui/styles'; + +export const useStyles = makeStyles(theme => ({ + contextFieldSelect: { + marginRight: '8px', + }, +})); diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx new file mode 100644 index 0000000000..33b2b802bd --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx @@ -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(); + const { feature, refetch } = useFeature(projectId, featureId); + const [variants, setVariants] = useState([]); + 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 ( + { + 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 ( +
+ +    + + By overriding the stickiness you can control which parameter + is used to ensure consistent traffic + allocation across variants.{' '} + + Read more + + +
+ ); + }; + + 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 ( +
+ + 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{' '} + getVariant() method in + the Client SDK. + + + 0} + show={ + + + + Variant name + + Weight + Weight Type + + + + {renderVariants()} +
+ } + elseShow={

No variants defined.

} + /> + +
+ + + {renderStickiness()} +
+ } + /> + { + if (!editing) { + return saveNewVariant(variantToSave); + } else { + return updateVariant(variantToSave); + } + }} + editing={editing} + validateName={validateName} + editVariant={editVariant} + title={editing ? 'Edit variant' : 'Add variant'} + /> + + {toast} + {delDialogueMarkup} + + ); +}; + +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) => ( +// this.openEditVariant(e, index, variant)} +// removeVariant={e => this.onRemoveVariant(e, index)} +// editable={this.props.editable} +// /> +// ); + +// renderVariants = variants => ( +// +// +// +// Variant name +// +// Weight +// Weight Type +// +// +// +// {variants.map(this.renderVariant)} +//
+// ); + +// 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 ( +//
+// +//    +// +// By overriding the stickiness you can control which parameter +// you want to be used in order to ensure consistent traffic +// allocation across variants.{' '} +// +// Read more +// +// +//
+// ); +// }; + +// render() { +// const { showDialog, editVariant, editIndex, title } = this.state; +// const { variants, addVariant, updateVariant } = this.props; +// const saveVariant = editVariant +// ? updateVariant.bind(null, editIndex) +// : addVariant; + +// return ( +//
+// +// 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{' '} +// getVariant() method +// in the Client SDK. +// + +// 0} +// show={this.renderVariants(variants)} +// elseShow={

No variants defined.

} +// /> + +//
+// +// +// {this.renderStickiness(variants)} +//
+// } +// /> + +// +// +// ); +// } +// } + +// 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; diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/FeatureVariantsListItem.tsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/FeatureVariantsListItem.tsx new file mode 100644 index 0000000000..fefb3e0e70 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/FeatureVariantsListItem.tsx @@ -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 ( + + {variant.name} + + } + /> + 0 + } + show={ + + } + /> + + {variant.weight / 10.0} % + + {variant.weightType === FIX ? 'Fix' : 'Variable'} + + +
+ editVariant(variant.name)}> + + + { + e.stopPropagation(); + setDelDialog({show: true, name: variant.name }); + }}> + + +
+ + } + elseShow={} + /> +
+ + ); +}; + +export default FeatureVariantListItem; diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/useDeleteVariantMarkup.tsx b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/useDeleteVariantMarkup.tsx new file mode 100644 index 0000000000..bf34927723 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/useDeleteVariantMarkup.tsx @@ -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 ( + + + Deleting this variant will change which variant users receive. + + + ); +}; + +export default useDeleteVariantMarkup; diff --git a/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/variants.module.scss b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/variants.module.scss new file mode 100644 index 0000000000..8110253b45 --- /dev/null +++ b/frontend/src/component/feature/FeatureView2/FeatureVariants/FeatureVariantsList/variants.module.scss @@ -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; +} diff --git a/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts index c94e87b054..92f162088d 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureView2.styles.ts @@ -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], diff --git a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx index c069384399..83cde19f3b 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureView2.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureView2.tsx @@ -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(); 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 = () => {

{feature.name}

+
+ + + + setShowDelDialog(true)} + > + + +
@@ -98,6 +149,21 @@ const FeatureView2 = () => { path={`/projects/:projectId/features2/:featureId/variants`} component={FeatureVariants} /> + + archiveToggle()} + open={showDelDialog} + onClose={handleCancel} + primaryButtonText="Archive toggle" + secondaryButtonText="Cancel" + title="Archive feature toggle" + > + Are you sure you want to archive this feature toggle? + + {toast} ); }; diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts index 2ca3a68722..7cc1f0aba3 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts +++ b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.styles.ts @@ -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', + }, }, })); diff --git a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx index 921f394d3f..5433a990ff 100644 --- a/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView2/FeatureViewEnvironment/FeatureViewEnvironment.tsx @@ -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 = ({ + env, + children, + className, +}: IFeatureViewEnvironmentProps) => { + const { featureId, projectId } = useParams(); + 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 ( -
-
- Toggle in{' '} - {env.name} is {env.enabled ? 'enabled' : 'disabled'} +
+
+
+ +
+

{env.type}

+
+
+ +

{env.name}

+
+
+
+ 0} + show={ +
+ {' '} + + The feature toggle is{' '} + {env.enabled ? 'enabled' : 'disabled'} in{' '} + {env.name} + +
+ } + elseShow={ + <> +

+ No strategies configured for environment. +

+ + Configure strategies for {env.name} + + + } + /> +
+
+ + 0} + show={
{children}
} + /> + + {toast}
); }; diff --git a/frontend/src/component/feature/add-tag-dialog-component.jsx b/frontend/src/component/feature/add-tag-dialog-component.jsx index 2cf061ec09..b2f0fc2be1 100644 --- a/frontend/src/component/feature/add-tag-dialog-component.jsx +++ b/frontend/src/component/feature/add-tag-dialog-component.jsx @@ -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 {
- diff --git a/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx b/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx index 1a5994cf59..347dfae21a 100644 --- a/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx +++ b/frontend/src/component/feature/create/CopyFeature/CopyFeature.jsx @@ -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 Toggle not found; + if (!feature || !feature.name) return Toggle not found; return ( { style={{ overflow: 'visible' }} >
-

Copy {copyToggle.name}

+

Copy {copyToggleName}

{ You are about to create a new feature toggle by cloning the configuration of feature toggle  - {copyToggle.name} + {copyToggleName} . 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, }; diff --git a/frontend/src/component/feature/create/CopyFeature/index.jsx b/frontend/src/component/feature/create/CopyFeature/index.jsx index 427bec1b56..a1e07db14d 100644 --- a/frontend/src/component/feature/create/CopyFeature/index.jsx +++ b/frontend/src/component/feature/create/CopyFeature/index.jsx @@ -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( diff --git a/frontend/src/component/feature/feature-type-select-component.jsx b/frontend/src/component/feature/feature-type-select-component.jsx index 6b185ef969..16c4018d37 100644 --- a/frontend/src/component/feature/feature-type-select-component.jsx +++ b/frontend/src/component/feature/feature-type-select-component.jsx @@ -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 ( - - - ({ key: t.name, label: t.name, title: t.name })); - - return ; - } -} - -TagTypeSelectComponent.propTypes = { - value: PropTypes.string, - types: PropTypes.array.isRequired, - fetchTagTypes: PropTypes.func, - onChange: PropTypes.func.isRequired, -}; - -export default TagTypeSelectComponent; diff --git a/frontend/src/component/feature/tag-type-select-container.jsx b/frontend/src/component/feature/tag-type-select-container.jsx deleted file mode 100644 index e1748dee7c..0000000000 --- a/frontend/src/component/feature/tag-type-select-container.jsx +++ /dev/null @@ -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; diff --git a/frontend/src/component/feature/variant/AddVariant/AddVariant.jsx b/frontend/src/component/feature/variant/AddVariant/AddVariant.jsx index 90d210aff8..e7b70c7ccc 100644 --- a/frontend/src/component/feature/variant/AddVariant/AddVariant.jsx +++ b/frontend/src/component/feature/variant/AddVariant/AddVariant.jsx @@ -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 = ({

- - - Read more diff --git a/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap b/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap index c3fb5e087c..b9afd05668 100644 --- a/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap +++ b/frontend/src/component/feature/view/__tests__/__snapshots__/view-component-test.jsx.snap @@ -86,12 +86,64 @@ exports[`renders correctly with one feature 1`] = `
- +
+ +
+
+ Release +
+ + + + +
+ + + Feature type + + +
+
+
 
({ __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; diff --git a/frontend/src/component/menu/Footer/Footer.jsx b/frontend/src/component/menu/Footer/Footer.jsx index b4c3017f57..e0eecc33dc 100644 --- a/frontend/src/component/menu/Footer/Footer.jsx +++ b/frontend/src/component/menu/Footer/Footer.jsx @@ -11,7 +11,7 @@ export const Footer = () => { return (
diff --git a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts index f76c95ca3e..87fa316087 100644 --- a/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts +++ b/frontend/src/hooks/api/actions/useFeatureApi/useFeatureApi.ts @@ -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 }; }; diff --git a/frontend/src/hooks/api/getters/useFeature/useFeature.ts b/frontend/src/hooks/api/getters/useFeature/useFeature.ts index de83e101f8..8c96c83938 100644 --- a/frontend/src/hooks/api/getters/useFeature/useFeature.ts +++ b/frontend/src/hooks/api/getters/useFeature/useFeature.ts @@ -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}`; diff --git a/frontend/src/hooks/api/getters/useFeatureTypes/useFeatureTypes.ts b/frontend/src/hooks/api/getters/useFeatureTypes/useFeatureTypes.ts new file mode 100644 index 0000000000..2962450a8a --- /dev/null +++ b/frontend/src/hooks/api/getters/useFeatureTypes/useFeatureTypes.ts @@ -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; diff --git a/frontend/src/interfaces/environments.ts b/frontend/src/interfaces/environments.ts index 3d24546654..6abd9b49e8 100644 --- a/frontend/src/interfaces/environments.ts +++ b/frontend/src/interfaces/environments.ts @@ -24,3 +24,10 @@ export interface IEnvironmentResponse { export interface ISortOrderPayload { [index: string]: number; } + +export interface IEnvironmentMetrics { + name: string; + yes: number; + no: number; + timestamp: string; +} diff --git a/frontend/src/interfaces/featureTypes.ts b/frontend/src/interfaces/featureTypes.ts new file mode 100644 index 0000000000..ef247545c7 --- /dev/null +++ b/frontend/src/interfaces/featureTypes.ts @@ -0,0 +1,6 @@ +export interface IFeatureType { + id: string; + name: string; + description: string; + lifetimeDays: number; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6911685392..cc8e256b79 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"