diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1923e228197e..a75f7e75fad7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -130,6 +130,7 @@
"@types/classnames": "^2.3.1",
"@types/color": "^3.0.3",
"@types/dompurify": "^3.0.2",
+ "@types/rc-switch": "^1.9.5",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^2.0.3",
@@ -4922,6 +4923,16 @@
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
+ "node_modules/@types/rc-switch": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/@types/rc-switch/-/rc-switch-1.9.5.tgz",
+ "integrity": "sha512-pah8pI9LwjppjzD2rAd2p9AdWxCoQzKYff0zCIHAiVpAxUI60U9vmNVbosunpEmOvbzaChhRnWgeWwTRweLAgA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "17.0.87",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.87.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 3a842b4dd290..7918e2e0db90 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -155,6 +155,7 @@
"@types/classnames": "^2.3.1",
"@types/color": "^3.0.3",
"@types/dompurify": "^3.0.2",
+ "@types/rc-switch": "^1.9.5",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"@types/react-select": "^2.0.3",
diff --git a/frontend/web/components/Feature.js b/frontend/web/components/Feature.js
deleted file mode 100644
index 73258aca6c1b..000000000000
--- a/frontend/web/components/Feature.js
+++ /dev/null
@@ -1,209 +0,0 @@
-// import propTypes from 'prop-types';
-import React, { PureComponent } from 'react'
-import ValueEditor from './ValueEditor'
-import Constants from 'common/constants'
-import { VariationOptions } from './mv/VariationOptions'
-import { AddVariationButton } from './mv/AddVariationButton'
-import ErrorMessage from './ErrorMessage'
-import Tooltip from './Tooltip'
-import Icon from './Icon'
-import InputGroup from './base/forms/InputGroup'
-import WarningMessage from './WarningMessage'
-
-function isNegativeNumberString(str) {
- if (typeof Utils.getTypedValue(str) !== 'number') {
- return false
- }
- if (typeof str !== 'string') {
- return false
- }
- const num = parseFloat(str)
- return !isNaN(num) && num < 0
-}
-
-export default class Feature extends PureComponent {
- static displayName = 'Feature'
-
- constructor(props) {
- super(props)
- this.state = {
- isNegativeNumberString: isNegativeNumberString(
- props.environmentFlag?.feature_state_value,
- ),
- }
- }
- removeVariation = (i) => {
- const idToRemove = this.props.multivariate_options[i].id
-
- if (idToRemove) {
- openConfirm({
- body: 'This will remove the variation on your feature for all environments, if you wish to turn it off just for this environment you can set the % value to 0.',
- destructive: true,
- onYes: () => {
- this.props.removeVariation(i)
- },
- title: 'Delete variation',
- yesText: 'Confirm',
- })
- } else {
- this.props.removeVariation(i)
- }
- }
-
- render() {
- const {
- checked,
- environmentFlag,
- environmentVariations,
- error,
- identity,
- isEdit,
- multivariate_options,
- onCheckedChange,
- onValueChange,
- projectFlag,
- readOnly,
- value,
- } = this.props
-
- const enabledString = isEdit ? 'Enabled' : 'Enabled by default'
- const controlPercentage = Utils.calculateControl(multivariate_options)
- const valueString = identity
- ? 'User override'
- : !!multivariate_options && multivariate_options.length
- ? `Control Value - ${controlPercentage}%`
- : `Value`
-
- const showValue = !(
- !!identity &&
- multivariate_options &&
- !!multivariate_options.length
- )
- return (
-
-
-
-
-
- {enabledString || 'Enabled'}
-
- {!isEdit && }
-
- }
- >
- {!isEdit &&
- 'This will determine the initial enabled state for all environments. You can edit the this individually for each environment once the feature is created.'}
-
-
-
- {showValue && (
-
-
- }
- tooltip={`${Constants.strings.REMOTE_CONFIG_DESCRIPTION}${
- !isEdit
- ? ' Setting this when creating a feature will set the value for all environments. You can edit this individually for each environment once the feature is created.'
- : ''
- }`}
- title={`${valueString}`}
- />
-
- )}
- {this.state.isNegativeNumberString && (
-
- This feature currently has the value of{' '}
- "{environmentFlag?.feature_state_value}" .
- Saving this feature will convert its value from a string to a
- number. If you wish to preserve this value as a string, please
- save it using the{' '}
-
- API
-
- .
-
- }
- />
- )}
-
- {!!error && (
-
-
-
- )}
- {!!identity && (
-
-
- {}}
- weightTitle='Override Weight %'
- projectFlag={projectFlag}
- multivariateOptions={projectFlag.multivariate_options}
- removeVariation={() => {}}
- />
-
-
- )}
- {!identity && (
-
-
- {(!!environmentVariations || !isEdit) && (
-
- )}
-
- {!this.props.hideAddVariation &&
- Utils.renderWithPermission(
- this.props.canCreateFeature,
- Constants.projectPermissions('Create Feature'),
-
,
- )}
-
- )}
-
- )
- }
-}
diff --git a/frontend/web/components/SegmentOverrides.js b/frontend/web/components/SegmentOverrides.js
index ff6e896bba21..64ea7f53fdfb 100644
--- a/frontend/web/components/SegmentOverrides.js
+++ b/frontend/web/components/SegmentOverrides.js
@@ -302,8 +302,7 @@ const SegmentOverrideInner = class Override extends React.Component {
{!!multivariateOptions?.length && (
- {
- onChange(!this.props.checked)
- }}
- >
- {checked ? offMarkup || 'On' : onMarkup || 'Off'}
-
-
- )
- }
- if (darkMode) {
- return (
- {
- onChange(!checked)
- }}
- >
-
-
-
- )
- }
- return
- }
-}
diff --git a/frontend/web/components/Switch.tsx b/frontend/web/components/Switch.tsx
new file mode 100644
index 000000000000..7c54595d0eff
--- /dev/null
+++ b/frontend/web/components/Switch.tsx
@@ -0,0 +1,68 @@
+import React, { FC } from 'react'
+import RCSwitch, { Props as RCSwitchProps } from 'rc-switch'
+import Icon from './Icon'
+
+export type SwitchProps = RCSwitchProps & {
+ checked?: boolean
+ darkMode?: boolean
+ offMarkup?: React.ReactNode
+ onMarkup?: React.ReactNode
+ onChange?: (checked: boolean) => void
+}
+
+const Switch: FC = ({
+ checked,
+ darkMode,
+ offMarkup,
+ onChange,
+ onMarkup,
+ ...rest
+}) => {
+ if (E2E) {
+ return (
+
+ {
+ onChange?.(!checked)
+ }}
+ >
+ {checked ? offMarkup || 'On' : onMarkup || 'Off'}
+
+
+ )
+ }
+
+ if (darkMode) {
+ return (
+ {
+ onChange?.(!checked)
+ }}
+ >
+
+
+
+ )
+ }
+
+ return
+}
+
+Switch.displayName = 'Switch'
+
+export default Switch
diff --git a/frontend/web/components/base/forms/Button.tsx b/frontend/web/components/base/forms/Button.tsx
index 67cb7983ffb9..fc88958e88cd 100644
--- a/frontend/web/components/base/forms/Button.tsx
+++ b/frontend/web/components/base/forms/Button.tsx
@@ -23,6 +23,7 @@ export const sizeClassNames = {
large: 'btn-lg',
small: 'btn-sm',
xSmall: 'btn-xsm',
+ xxSmall: 'btn-xxsm',
}
export type ButtonType = ButtonHTMLAttributes & {
diff --git a/frontend/web/components/base/grid/FormGroup.js b/frontend/web/components/base/grid/FormGroup.js
deleted file mode 100644
index 9f4bcdb77e89..000000000000
--- a/frontend/web/components/base/grid/FormGroup.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { PureComponent } from 'react'
-const FormGroup = class extends PureComponent {
- static displayName = 'FormGroup'
-
- render() {
- return (
-
- {this.props.children}
-
- )
- }
-}
-
-FormGroup.displayName = 'FormGroup'
-FormGroup.propTypes = {
- children: OptionalNode,
-}
-module.exports = FormGroup
diff --git a/frontend/web/components/base/grid/FormGroup.tsx b/frontend/web/components/base/grid/FormGroup.tsx
new file mode 100644
index 000000000000..d41269cd96a9
--- /dev/null
+++ b/frontend/web/components/base/grid/FormGroup.tsx
@@ -0,0 +1,14 @@
+import React, { FC, ReactNode } from 'react'
+
+export type FormGroupProps = {
+ children?: ReactNode
+ className?: string
+}
+
+const FormGroup: FC = ({ children, className = '' }) => {
+ return {children}
+}
+
+FormGroup.displayName = 'FormGroup'
+
+export default FormGroup
diff --git a/frontend/web/components/base/grid/Row.js b/frontend/web/components/base/grid/Row.js
deleted file mode 100644
index 7a686afcbcb0..000000000000
--- a/frontend/web/components/base/grid/Row.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/**
- * Created by kylejohnson on 24/07/2016.
- */
-import { PureComponent } from 'react'
-import cn from 'classnames'
-
-class Row extends PureComponent {
- static displayName = 'Row'
-
- static propTypes = {
- children: OptionalNode,
- className: OptionalString,
- space: OptionalBool,
- style: propTypes.any,
- }
-
- render() {
- const { noWrap, space, ...rest } = this.props
-
- return (
-
- {this.props.children}
-
- )
- }
-}
-
-module.exports = Row
diff --git a/frontend/web/components/base/grid/Row.tsx b/frontend/web/components/base/grid/Row.tsx
new file mode 100644
index 000000000000..58671d832389
--- /dev/null
+++ b/frontend/web/components/base/grid/Row.tsx
@@ -0,0 +1,31 @@
+import React, { FC, HTMLAttributes, ReactNode } from 'react'
+import cn from 'classnames'
+
+export type RowProps = HTMLAttributes & {
+ children?: ReactNode
+ className?: string
+ space?: boolean
+ noWrap?: boolean
+}
+
+const Row: FC = ({ children, className, noWrap, space, ...rest }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+Row.displayName = 'Row'
+
+export default Row
diff --git a/frontend/web/components/feature-override/FeatureOverrideRow.tsx b/frontend/web/components/feature-override/FeatureOverrideRow.tsx
index 900baa90be3f..b01120d45cc7 100644
--- a/frontend/web/components/feature-override/FeatureOverrideRow.tsx
+++ b/frontend/web/components/feature-override/FeatureOverrideRow.tsx
@@ -50,6 +50,7 @@ const FeatureOverrideRow: FC = ({
dataTest,
environmentFeatureState,
environmentId,
+ highlightSegmentId,
identifier,
identity,
identityName,
@@ -59,7 +60,6 @@ const FeatureOverrideRow: FC = ({
shouldPreselect,
toggleDataTest,
valueDataTest,
- highlightSegmentId,
}) => {
const hasUserOverride =
!!overrideFeatureState?.identity ||
diff --git a/frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx b/frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx
new file mode 100644
index 000000000000..8bf1886838d9
--- /dev/null
+++ b/frontend/web/components/modals/CreateFlag/FeatureLimitAlert.tsx
@@ -0,0 +1,34 @@
+import { FC, useEffect } from 'react'
+import Utils from 'common/utils/utils'
+import { useGetProjectQuery } from 'common/services/useProject'
+
+type FeatureLimitAlertType = {
+ projectId: string | number
+ onChange?: (limitAlert: { percentage: number; limit: number }) => void
+}
+
+const FeatureLimitAlert: FC = ({
+ onChange,
+ projectId,
+}) => {
+ const { data: project } = useGetProjectQuery({ id: `${projectId}` })
+
+ const featureLimitAlert = Utils.calculateRemainingLimitsPercentage(
+ project?.total_features,
+ project?.max_features_allowed,
+ )
+
+ useEffect(() => {
+ if (onChange && featureLimitAlert) {
+ onChange(featureLimitAlert)
+ }
+ }, [onChange, featureLimitAlert])
+
+ if (!featureLimitAlert.percentage) {
+ return null
+ }
+
+ return <>{Utils.displayLimitAlert('features', featureLimitAlert.percentage)}>
+}
+
+export default FeatureLimitAlert
diff --git a/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx b/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx
new file mode 100644
index 000000000000..2c5e38dccb0d
--- /dev/null
+++ b/frontend/web/components/modals/CreateFlag/FeatureNameInput.tsx
@@ -0,0 +1,79 @@
+import React, { FC } from 'react'
+import InputGroup from 'components/base/forms/InputGroup'
+import Tooltip from 'components/Tooltip'
+import Icon from 'components/Icon'
+import InfoMessage from 'components/InfoMessage'
+import Constants from 'common/constants'
+import Utils from 'common/utils/utils'
+import FormGroup from 'components/base/grid/FormGroup'
+import Row from 'components/base/grid/Row'
+
+type FeatureNameInputProps = {
+ value: string
+ onChange: (name: string) => void
+ caseSensitive: boolean
+ regex?: string
+ regexValid: boolean
+ autoFocus?: boolean
+}
+
+const FeatureNameInput: FC = ({
+ autoFocus,
+ caseSensitive,
+ onChange,
+ regex,
+ regexValid,
+ value,
+}) => {
+ const FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID
+
+ return (
+
+ {
+ const newName = Utils.safeParseEventValue(e).replace(/ /g, '_')
+ onChange(caseSensitive ? newName.toLowerCase() : newName)
+ }}
+ isValid={!!value && regexValid}
+ type='text'
+ title={
+ <>
+
+ ID / Name*
+
+
+ }
+ >
+ The ID that will be used by SDKs to retrieve the feature value and
+ enabled state. This cannot be edited once the feature has been
+ created.
+
+ {!!regex && (
+
+ {' '}
+
+ {' '}
+ This must conform to the regular expression {regex}
+
+
+ )}
+ >
+ }
+ placeholder='E.g. header_size'
+ />
+
+ )
+}
+
+export default FeatureNameInput
diff --git a/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx b/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx
new file mode 100644
index 000000000000..891f55e62456
--- /dev/null
+++ b/frontend/web/components/modals/CreateFlag/FeatureUpdateSummary.tsx
@@ -0,0 +1,59 @@
+import React, { FC } from 'react'
+import InfoMessage from 'components/InfoMessage'
+import Button from 'components/base/forms/Button'
+import ModalHR from 'components/modals/ModalHR'
+
+type FeatureUpdateSummaryProps = {
+ identity?: string
+ onCreateFeature: () => void
+ isSaving: boolean
+ name: string
+ invalid: boolean
+ regexValid: boolean
+ featureLimitPercentage: number
+ hasMetadataRequired: boolean
+}
+
+const FeatureUpdateSummary: FC = ({
+ featureLimitPercentage,
+ hasMetadataRequired,
+ identity,
+ invalid,
+ isSaving,
+ name,
+ onCreateFeature,
+ regexValid,
+}) => {
+ return (
+ <>
+
+ {!identity && (
+
+
+ This will create the feature for all environments ,
+ you can edit this feature per environment once the feature is
+ created.
+
+
+ = 100 ||
+ hasMetadataRequired
+ }
+ >
+ {isSaving ? 'Creating' : 'Create Feature'}
+
+
+ )}
+ >
+ )
+}
+
+export default FeatureUpdateSummary
diff --git a/frontend/web/components/modals/CreateFlag.js b/frontend/web/components/modals/CreateFlag/index.js
similarity index 60%
rename from frontend/web/components/modals/CreateFlag.js
rename to frontend/web/components/modals/CreateFlag/index.js
index 37f1c9dfebe2..a1a5fced56d7 100644
--- a/frontend/web/components/modals/CreateFlag.js
+++ b/frontend/web/components/modals/CreateFlag/index.js
@@ -10,120 +10,131 @@ import IdentityProvider from 'common/providers/IdentityProvider'
import Tabs from 'components/navigation/TabMenu/Tabs'
import TabItem from 'components/navigation/TabMenu/TabItem'
import SegmentOverrides from 'components/SegmentOverrides'
-import AddEditTags from 'components/tags/AddEditTags'
-import FlagOwners from 'components/FlagOwners'
-import ChangeRequestModal from './ChangeRequestModal'
-import Feature from 'components/Feature'
+import ChangeRequestModal from 'components/modals/ChangeRequestModal'
import classNames from 'classnames'
import InfoMessage from 'components/InfoMessage'
import JSONReference from 'components/JSONReference'
import ErrorMessage from 'components/ErrorMessage'
import Permission from 'common/providers/Permission'
import IdentitySelect from 'components/IdentitySelect'
-import { setInterceptClose, setModalTitle } from './base/ModalDefault'
+import {
+ setInterceptClose,
+ setModalTitle,
+} from 'components/modals/base/ModalDefault'
import Icon from 'components/Icon'
-import ModalHR from './ModalHR'
+import ModalHR from 'components/modals/ModalHR'
import FeatureValue from 'components/feature-summary/FeatureValue'
import { getStore } from 'common/store'
-import FlagOwnerGroups from 'components/FlagOwnerGroups'
-import ExistingChangeRequestAlert from 'components/ExistingChangeRequestAlert'
import Button from 'components/base/forms/Button'
-import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity'
import { getSupportedContentType } from 'common/services/useSupportedContentType'
import { getGithubIntegration } from 'common/services/useGithubIntegration'
import { removeUserOverride } from 'components/RemoveUserOverride'
import ExternalResourcesLinkTab from 'components/ExternalResourcesLinkTab'
import { saveFeatureWithValidation } from 'components/saveFeatureWithValidation'
-import PlanBasedBanner from 'components/PlanBasedAccess'
import FeatureHistory from 'components/FeatureHistory'
import WarningMessage from 'components/WarningMessage'
import FeatureAnalytics from 'components/feature-page/FeatureNavTab/FeatureAnalytics'
+import { FlagValueFooter } from 'components/modals/FlagValueFooter'
import { getPermission } from 'common/services/usePermission'
import { getChangeRequests } from 'common/services/useChangeRequest'
import FeatureHealthTabContent from 'components/feature-health/FeatureHealthTabContent'
import { IonIcon } from '@ionic/react'
import { warning } from 'ionicons/icons'
import FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineStatus'
-import { FlagValueFooter } from './FlagValueFooter'
import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard'
import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer'
import BetaFlag from 'components/BetaFlag'
import ProjectProvider from 'common/providers/ProjectProvider'
+import CreateFeature from './tabs/CreateFeature'
+import FeatureSettings from './tabs/FeatureSettings'
+import FeatureValueTab from './tabs/FeatureValue'
+import FeatureLimitAlert from './FeatureLimitAlert'
+import FeatureUpdateSummary from './FeatureUpdateSummary'
+import FeatureNameInput from './FeatureNameInput'
-const CreateFlag = class extends Component {
+const Index = class extends Component {
static displayName = 'CreateFlag'
constructor(props, context) {
super(props, context)
- const {
- description,
- enabled,
- feature_state_value,
- is_archived,
- is_server_key_only,
- multivariate_options,
- name,
- tags,
- } = this.props.projectFlag
- ? Utils.getFlagValue(
- this.props.projectFlag,
- this.props.environmentFlag,
- this.props.identityFlag,
- )
+ if (this.props.projectFlag) {
+ this.userOverridesPage(1, true)
+ }
+
+ const projectFlagData = this.props.projectFlag
+ ? _.cloneDeep(this.props.projectFlag)
: {
+ description: undefined,
+ is_archived: undefined,
+ is_server_key_only: undefined,
+ metadata: [],
multivariate_options: [],
+ name: undefined,
+ tags: [],
}
- const { allowEditDescription } = this.props
- const hideTags = this.props.hideTags || []
- if (this.props.projectFlag) {
- this.userOverridesPage(1, true)
- }
+ const sourceFlag = this.props.identityFlag || this.props.environmentFlag
+ const environmentFlagData = sourceFlag ? _.cloneDeep(sourceFlag) : {}
+
this.state = {
- allowEditDescription,
changeRequests: [],
- default_enabled: enabled,
- description,
enabledIndentity: false,
enabledSegment: false,
+ environmentFlag: environmentFlagData,
externalResource: {},
externalResources: [],
featureContentType: {},
+ featureLimitAlert: { percentage: 0 },
githubId: '',
hasIntegrationWithGithub: false,
hasMetadataRequired: false,
- identityVariations:
- this.props.identityFlag &&
- this.props.identityFlag.multivariate_feature_state_values
- ? _.cloneDeep(
- this.props.identityFlag.multivariate_feature_state_values,
- )
- : [],
- initial_value:
- typeof feature_state_value === 'undefined'
- ? undefined
- : Utils.getTypedValue(feature_state_value),
isEdit: !!this.props.projectFlag,
- is_archived,
- is_server_key_only,
- metadata: [],
- multivariate_options: _.cloneDeep(multivariate_options),
- name,
period: 30,
+ projectFlag: projectFlagData,
scheduledChangeRequests: [],
+ segmentsChanged: false,
selectedIdentity: null,
- tags: tags?.filter((tag) => !hideTags.includes(tag)) || [],
+ settingsChanged: false,
+ valueChanged: false,
}
}
- getState = () => {}
-
close() {
closeModal()
}
componentDidUpdate(prevProps) {
ES6Component(this)
+
+ const environmentFlagSource =
+ this.props.identityFlag || this.props.environmentFlag
+ const prevEnvironmentFlagSource =
+ prevProps.identityFlag || prevProps.environmentFlag
+
+ if (
+ environmentFlagSource &&
+ prevEnvironmentFlagSource &&
+ environmentFlagSource.updated_at &&
+ prevEnvironmentFlagSource.updated_at &&
+ environmentFlagSource.updated_at !== prevEnvironmentFlagSource.updated_at
+ ) {
+ this.setState({
+ environmentFlag: _.cloneDeep(environmentFlagSource),
+ })
+ }
+
+ if (
+ this.props.projectFlag &&
+ prevProps.projectFlag &&
+ this.props.projectFlag.updated_at &&
+ prevProps.projectFlag.updated_at &&
+ this.props.projectFlag.updated_at !== prevProps.projectFlag.updated_at
+ ) {
+ this.setState({
+ projectFlag: _.cloneDeep(this.props.projectFlag),
+ })
+ }
+
if (
!this.props.identity &&
this.props.environmentVariations !== prevProps.environmentVariations
@@ -133,22 +144,25 @@ const CreateFlag = class extends Component {
this.props.environmentVariations.length
) {
this.setState({
- multivariate_options:
- this.state.multivariate_options &&
- this.state.multivariate_options.map((v) => {
- const matchingVariation = (
- this.props.multivariate_options ||
- this.props.environmentVariations
- ).find((e) => e.multivariate_feature_option === v.id)
- return {
- ...v,
- default_percentage_allocation:
- (matchingVariation &&
- matchingVariation.percentage_allocation) ||
- v.default_percentage_allocation ||
- 0,
- }
- }),
+ projectFlag: {
+ ...this.state.projectFlag,
+ multivariate_options:
+ this.state.projectFlag.multivariate_options &&
+ this.state.projectFlag.multivariate_options.map((v) => {
+ const matchingVariation = (
+ this.props.multivariate_options ||
+ this.props.environmentVariations
+ ).find((e) => e.multivariate_feature_option === v.id)
+ return {
+ ...v,
+ default_percentage_allocation:
+ (matchingVariation &&
+ matchingVariation.percentage_allocation) ||
+ v.default_percentage_allocation ||
+ 0,
+ }
+ }),
+ },
})
}
}
@@ -157,10 +171,13 @@ const CreateFlag = class extends Component {
onClosing = () => {
if (this.state.isEdit) {
return new Promise((resolve) => {
+ const projectFlagChanged = this.state.settingsChanged
+ const environmentFlagChanged = this.state.valueChanged
+ const segmentOverridesChanged = this.state.segmentsChanged
if (
- this.state.valueChanged ||
- this.state.segmentsChanged ||
- this.state.settingsChanged
+ projectFlagChanged ||
+ environmentFlagChanged ||
+ segmentOverridesChanged
) {
openConfirm({
body: 'Closing this will discard your unsaved changes.',
@@ -180,12 +197,6 @@ const CreateFlag = class extends Component {
componentDidMount = () => {
setInterceptClose(this.onClosing)
- if (!this.state.isEdit && !E2E) {
- this.focusTimeout = setTimeout(() => {
- this.input.focus()
- this.focusTimeout = null
- }, 500)
- }
if (Utils.getPlansPermission('METADATA')) {
getSupportedContentType(getStore(), {
organisation_id: AccountStore.getOrganisation().id,
@@ -285,42 +296,32 @@ const CreateFlag = class extends Component {
save = (func, isSaving) => {
const {
- environmentFlag,
+ environmentFlag: propsEnvironmentFlag,
environmentId,
identity,
identityFlag,
projectFlag: _projectFlag,
segmentOverrides,
} = this.props
- const {
- default_enabled,
- description,
- initial_value,
- is_archived,
- is_server_key_only,
- name,
- } = this.state
- const projectFlag = {
- skipSaveProjectFeature: this.state.skipSaveProjectFeature,
- ..._projectFlag,
- }
+ const { environmentFlag: stateEnvironmentFlag, projectFlag } = this.state
const hasMultivariate =
- this.props.environmentFlag &&
- this.props.environmentFlag.multivariate_feature_state_values &&
- this.props.environmentFlag.multivariate_feature_state_values.length
+ propsEnvironmentFlag &&
+ propsEnvironmentFlag.multivariate_feature_state_values &&
+ propsEnvironmentFlag.multivariate_feature_state_values.length
if (identity) {
!isSaving &&
- name &&
+ projectFlag.name &&
func({
- environmentFlag,
+ environmentFlag: propsEnvironmentFlag,
environmentId,
identity,
identityFlag: Object.assign({}, identityFlag || {}, {
- enabled: default_enabled,
+ enabled: stateEnvironmentFlag.enabled,
feature_state_value: hasMultivariate
- ? this.props.environmentFlag.feature_state_value
- : this.cleanInputValue(initial_value),
- multivariate_options: this.state.identityVariations,
+ ? propsEnvironmentFlag.feature_state_value
+ : this.cleanInputValue(stateEnvironmentFlag.feature_state_value),
+ multivariate_options:
+ stateEnvironmentFlag.multivariate_feature_state_values,
}),
projectFlag,
})
@@ -328,28 +329,33 @@ const CreateFlag = class extends Component {
FeatureListStore.isSaving = true
FeatureListStore.trigger('change')
!isSaving &&
- name &&
+ projectFlag.name &&
func(
this.props.projectId,
this.props.environmentId,
{
- default_enabled,
- description,
- initial_value: this.cleanInputValue(initial_value),
- is_archived,
- is_server_key_only,
+ default_enabled: stateEnvironmentFlag.enabled,
+ description: projectFlag.description,
+ initial_value: this.cleanInputValue(
+ stateEnvironmentFlag.feature_state_value,
+ ),
+ is_archived: projectFlag.is_archived,
+ is_server_key_only: projectFlag.is_server_key_only,
metadata:
!this.props.projectFlag?.metadata ||
- (this.props.projectFlag.metadata !== this.state.metadata &&
- this.state.metadata.length)
- ? this.state.metadata
+ (this.props.projectFlag.metadata !== projectFlag.metadata &&
+ projectFlag.metadata.length)
+ ? projectFlag.metadata
: this.props.projectFlag.metadata,
- multivariate_options: this.state.multivariate_options,
- name,
- tags: this.state.tags,
+ multivariate_options: projectFlag.multivariate_options,
+ name: projectFlag.name,
+ tags: projectFlag.tags,
},
- projectFlag,
- environmentFlag,
+ {
+ skipSaveProjectFeature: this.state.skipSaveProjectFeature,
+ ..._projectFlag,
+ },
+ propsEnvironmentFlag,
segmentOverrides,
)
}
@@ -466,40 +472,6 @@ const CreateFlag = class extends Component {
}
}
- addVariation = () => {
- this.setState({
- multivariate_options: this.state.multivariate_options.concat([
- {
- ...Utils.valueToFeatureState(''),
- default_percentage_allocation: 0,
- },
- ]),
- valueChanged: true,
- })
- }
-
- removeVariation = (i) => {
- this.state.valueChanged = true
- if (this.state.multivariate_options[i].id) {
- const idToRemove = this.state.multivariate_options[i].id
- if (idToRemove) {
- this.props.removeMultivariateOption(idToRemove)
- }
- this.state.multivariate_options.splice(i, 1)
- this.forceUpdate()
- } else {
- this.state.multivariate_options.splice(i, 1)
- this.forceUpdate()
- }
- }
-
- updateVariation = (i, e, environmentVariations) => {
- this.props.onEnvironmentVariationsChange(environmentVariations)
- this.state.multivariate_options[i] = e
- this.state.valueChanged = true
- this.forceUpdate()
- }
-
fetchChangeRequests = (forceRefetch) => {
const { environmentId, projectFlag } = this.props
if (!projectFlag?.id) return
@@ -538,23 +510,17 @@ const CreateFlag = class extends Component {
render() {
const {
- default_enabled,
- description,
enabledIndentity,
enabledSegment,
+ environmentFlag,
featureContentType,
githubId,
hasIntegrationWithGithub,
- initial_value,
isEdit,
- multivariate_options,
- name,
+ projectFlag,
} = this.state
- const FEATURE_ID_MAXLENGTH = Constants.forms.maxLength.FEATURE_ID
-
- const { identity, identityName, projectFlag } = this.props
+ const { identity, identityName } = this.props
const Provider = identity ? IdentityProvider : FeatureListProvider
- const environmentVariations = this.props.environmentVariations
const environment = ProjectStore.getEnvironment(this.props.environmentId)
const isVersioned = !!environment?.use_v2_feature_versioning
const is4Eyes =
@@ -563,319 +529,30 @@ const CreateFlag = class extends Component {
const project = ProjectStore.model
const caseSensitive = project?.only_allow_lower_case_feature_names
const regex = project?.feature_name_regex
- const controlValue = Utils.calculateControl(multivariate_options)
+ const controlValue = Utils.calculateControl(
+ projectFlag.multivariate_options,
+ )
const invalid =
- !!multivariate_options && multivariate_options.length && controlValue < 0
+ !!projectFlag.multivariate_options &&
+ projectFlag.multivariate_options.length &&
+ controlValue < 0
const existingChangeRequest = this.props.changeRequest
const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab()
const noPermissions = this.props.noPermissions
let regexValid = true
- const metadataEnable = Utils.getPlansPermission('METADATA')
const isCodeReferencesEnabled = Utils.getFlagsmithHasFeature(
'git_code_references',
)
try {
- if (!isEdit && name && regex) {
- regexValid = name.match(new RegExp(regex))
+ if (!isEdit && projectFlag.name && regex) {
+ regexValid = projectFlag.name.match(new RegExp(regex))
}
} catch (e) {
regexValid = false
}
- const Settings = (projectAdmin, createFeature, featureContentType) =>
- !createFeature ? (
-
-
-
- ) : (
- <>
- {!identity && this.state.tags && (
-
-
- this.setState({ settingsChanged: true, tags })
- }
- />
- }
- />
-
- )}
- {metadataEnable && featureContentType?.id && (
- <>
- Custom Fields
- {
- this.setState({
- hasMetadataRequired: b,
- })
- }}
- onChange={(m) => {
- this.setState({
- metadata: m,
- })
- }}
- />
- >
- )}
- {!identity && projectFlag && (
-
- {({ permission }) =>
- permission && (
- <>
-
-
-
-
-
-
-
- >
- )
- }
-
- )}
-
-
- this.setState({
- description: Utils.safeParseEventValue(e),
- settingsChanged: true,
- })
- }
- ds
- type='text'
- title={identity ? 'Description' : 'Description (optional)'}
- placeholder="e.g. 'This determines what size the header is' "
- />
-
-
- {!identity && (
-
-
-
- this.setState({ is_server_key_only, settingsChanged: true })
- }
- className='ml-0'
- />
-
- Server-side only
-
- }
- >
- Prevent this feature from being accessed with client-side
- SDKs.
-
-
-
- )}
-
- {!identity && isEdit && (
-
-
- {
- this.setState({ is_archived, settingsChanged: true })
- }}
- className='ml-0'
- />
-
- Archived
-
- }
- >
- {`Archiving a flag allows you to filter out flags from the
- Flagsmith dashboard that are no longer relevant.
-
- An archived flag will still return as normal in all SDK
- endpoints.`}
-
-
-
- )}
- >
- )
- const Value = (error, projectAdmin, createFeature, hideValue) => {
- const { featureError, featureWarning } = this.parseError(error)
- const { changeRequests, scheduledChangeRequests } = this.state
- return (
- <>
- {!!isEdit && !identity && (
-
- )}
- {!isEdit && (
-
- (this.input = e)}
- data-test='featureID'
- inputProps={{
- className: 'full-width',
- maxLength: FEATURE_ID_MAXLENGTH,
- name: 'featureID',
- readOnly: isEdit,
- }}
- value={name}
- onChange={(e) => {
- const newName = Utils.safeParseEventValue(e).replace(
- / /g,
- '_',
- )
- this.setState({
- name: caseSensitive ? newName.toLowerCase() : newName,
- })
- }}
- isValid={!!name && regexValid}
- type='text'
- title={
- <>
-
-
- {isEdit ? 'ID / Name' : 'ID / Name*'}
-
-
-
- }
- >
- The ID that will be used by SDKs to retrieve the feature
- value and enabled state. This cannot be edited once the
- feature has been created.
-
- {!!regex && !isEdit && (
-
- {' '}
-
- {' '}
- This must conform to the regular expression{' '}
- {regex}
-
-
- )}
- >
- }
- placeholder='E.g. header_size'
- />
-
- )}
-
-
- {identity && description && (
-
-
- this.setState({ description: Utils.safeParseEventValue(e) })
- }
- type='text'
- title={identity ? 'Description' : 'Description (optional)'}
- placeholder='No description'
- />
-
- )}
- {!hideValue && (
-
- {
- this.setState({ identityVariations, valueChanged: true })
- }}
- environmentFlag={this.props.environmentFlag}
- projectFlag={projectFlag}
- onValueChange={(e) => {
- const initial_value = Utils.getTypedValue(
- Utils.safeParseEventValue(e),
- )
- this.setState({ initial_value, valueChanged: true })
- }}
- onCheckedChange={(default_enabled) =>
- this.setState({ default_enabled })
- }
- />
-
- )}
- {!isEdit &&
- !identity &&
- Settings(projectAdmin, createFeature, featureContentType)}
- >
- )
- }
return (
{({ project }) => (
@@ -934,20 +611,27 @@ const CreateFlag = class extends Component {
})
.concat([
Object.assign({}, this.props.environmentFlag, {
- enabled: default_enabled,
- feature_state_value:
- Utils.valueToFeatureState(initial_value),
+ enabled: environmentFlag.enabled,
+ feature_state_value: Utils.valueToFeatureState(
+ environmentFlag.feature_state_value,
+ ),
multivariate_feature_state_values:
- this.state.identityVariations,
+ environmentFlag.multivariate_feature_state_values,
}),
])
+ const getModalTitle = () => {
+ if (schedule) {
+ return 'New Scheduled Flag Update'
+ }
+ if (this.props.changeRequest) {
+ return 'Update Change Request'
+ }
+ return 'New Change Request'
+ }
+
openModal2(
- schedule
- ? 'New Scheduled Flag Update'
- : this.props.changeRequest
- ? 'Update Change Request'
- : 'New Change Request',
+ getModalTitle(),
{
const matching =
- this.state.multivariate_options.find(
+ projectFlag.multivariate_options.find(
(m) =>
m.id ===
v.multivariate_feature_option,
@@ -1009,7 +693,7 @@ const CreateFlag = class extends Component {
matching.default_percentage_allocation,
}
})
- : this.state.multivariate_options,
+ : projectFlag.multivariate_options,
title,
},
!is4Eyes,
@@ -1047,16 +731,8 @@ const CreateFlag = class extends Component {
})
const isLimitReached = false
- const featureLimitAlert =
- Utils.calculateRemainingLimitsPercentage(
- project.total_features,
- project.max_features_allowed,
- )
const { featureError, featureWarning } = this.parseError(error)
- const isReleasePipelineEnabled =
- Utils.getFlagsmithHasFeature('release_pipelines')
-
return (
{isEdit && !identity ? (
<>
- {isReleasePipelineEnabled && (
-
- )}
+
this.forceUpdate()}
urlParam='tab'
@@ -1104,93 +779,65 @@ const CreateFlag = class extends Component {
}
>
-
- {featureLimitAlert.percentage &&
- Utils.displayLimitAlert(
- 'features',
- featureLimitAlert.percentage,
- )}
-
- (
- <>
- Environment Value
-
- This feature is in{' '}
-
- {matchingReleasePipeline?.name}
- {' '}
- release pipeline and its value
- cannot be changed
-
- >
- )}
- >
-
- Environment Value{' '}
-
-
- }
- place='top'
- >
- {Constants.strings.ENVIRONMENT_OVERRIDE_DESCRIPTION(
- _.find(project.environments, {
- api_key: this.props.environmentId,
- }).name,
- )}
-
-
- {Value(
- error,
- projectAdmin,
- createFeature,
- )}
-
- {isEdit && (
- <>
-
-
- >
- )}
-
-
-
+ {
+ this.setState({
+ environmentFlag: {
+ ...this.state.environmentFlag,
+ ...changes,
+ },
+ valueChanged: true,
+ })
+ }}
+ onProjectFlagChange={(changes) => {
+ this.setState({
+ projectFlag: {
+ ...this.state.projectFlag,
+ ...changes,
+ },
+ })
+ }}
+ onRemoveMultivariateOption={
+ this.props.removeMultivariateOption
+ }
+ />
+
+
+
{!existingChangeRequest && (
}
>
- {!identity && isEdit && (
-
- (
- <>
-
- Segment Overrides{' '}
-
-
+ (
+ <>
+
+ Segment Overrides{' '}
+
+
+ This feature is in{' '}
+
+ {
+ matchingReleasePipeline?.name
+ }
+ {' '}
+ release pipeline and no segment
+ overrides can be created
+
+ >
+ )}
+ >
+
+
+
+
+ Segment Overrides{' '}
+
+
+ }
+ place='top'
>
- This feature is in{' '}
-
- {
- matchingReleasePipeline?.name
- }
- {' '}
- release pipeline and no
- segment overrides can be
- created
-
- >
- )}
- >
-
-
-
-
- Segment Overrides{' '}
-
-
+ {
+ Constants.strings
+ .SEGMENT_OVERRIDES_DESCRIPTION
+ }
+
+
+
+ {({
+ permission:
+ manageSegmentOverrides,
+ }) =>
+ !this.state
+ .showCreateSegment &&
+ !!manageSegmentOverrides &&
+ !this.props.disableCreate && (
+
+ {
+ this.setState({
+ showCreateSegment: true,
+ })
+ }}
+ theme='outline'
+ disabled={
+ !!isLimitReached
+ }
+ >
+ Create Feature-Specific
+ Segment
+
+
+ )
+ }
+
+ {!this.state.showCreateSegment &&
+ !noPermissions && (
+
+ this.changeSegment(
+ this.props
+ .segmentOverrides,
+ )
}
- place='top'
+ type='button'
+ theme='secondary'
+ size='small'
>
+ {enabledSegment
+ ? 'Enable All'
+ : 'Disable All'}
+
+ )}
+
+ {this.props.segmentOverrides ? (
+
+ {({
+ permission:
+ manageSegmentOverrides,
+ }) => {
+ const isReadOnly =
+ !manageSegmentOverrides
+ return (
+ <>
+
+
+
+ this.setState({
+ showCreateSegment,
+ })
+ }
+ readOnly={isReadOnly}
+ is4Eyes={is4Eyes}
+ showEditSegment
+ showCreateSegment={
+ this.state
+ .showCreateSegment
+ }
+ feature={projectFlag.id}
+ projectId={
+ this.props.projectId
+ }
+ multivariateOptions={
+ projectFlag.multivariate_options
+ }
+ environmentId={
+ this.props
+ .environmentId
+ }
+ value={
+ this.props
+ .segmentOverrides
+ }
+ controlValue={
+ environmentFlag.feature_state_value
+ }
+ onChange={(v) => {
+ this.setState({
+ segmentsChanged: true,
+ })
+ this.props.updateSegments(
+ v,
+ )
+ }}
+ highlightSegmentId={
+ this.props
+ .highlightSegmentId
+ }
+ />
+ >
+ )
+ }}
+
+ ) : (
+
+
+
+ )}
+ {!this.state.showCreateSegment && (
+
+ )}
+ {!this.state.showCreateSegment && (
+
+
+ {is4Eyes && isVersioned
+ ? 'This will create a change request with any value and segment override changes for the environment'
+ : 'This will update the segment overrides for the environment'}{' '}
+
{
- Constants.strings
- .SEGMENT_OVERRIDES_DESCRIPTION
+ _.find(
+ project.environments,
+ {
+ api_key:
+ this.props
+ .environmentId,
+ },
+ ).name
}
-
-
-
- {({
- permission:
- manageSegmentOverrides,
- }) =>
- !this.state
- .showCreateSegment &&
- !!manageSegmentOverrides &&
- !this.props
- .disableCreate && (
-
- {
- this.setState({
- showCreateSegment: true,
- })
- }}
- theme='outline'
- disabled={
- !!isLimitReached
- }
- >
- Create
- Feature-Specific
- Segment
-
-
- )
- }
-
- {!this.state
- .showCreateSegment &&
- !noPermissions && (
-
- this.changeSegment(
+
+
+
+
+ {({
+ permission:
+ savePermission,
+ }) => (
+
- {enabledSegment
- ? 'Enable All'
- : 'Disable All'}
-
- )}
-
- {this.props.segmentOverrides ? (
-
- {({
- permission:
- manageSegmentOverrides,
- }) => {
- const isReadOnly =
- !manageSegmentOverrides
- return (
- <>
-
-
-
- this.setState({
- showCreateSegment,
- })
- }
- readOnly={isReadOnly}
- is4Eyes={is4Eyes}
- showEditSegment
- showCreateSegment={
- this.state
- .showCreateSegment
- }
- feature={
- projectFlag.id
- }
- projectId={
- this.props.projectId
- }
- multivariateOptions={
- multivariate_options
- }
- environmentId={
- this.props
- .environmentId
- }
- value={
- this.props
- .segmentOverrides
- }
- controlValue={
- initial_value
- }
- onChange={(v) => {
- this.setState({
- segmentsChanged: true,
- })
- this.props.updateSegments(
- v,
- )
- }}
- highlightSegmentId={
- this.props
- .highlightSegmentId
- }
- />
- >
- )
- }}
-
- ) : (
-
-
-
- )}
- {!this.state
- .showCreateSegment && (
-
- )}
- {!this.state
- .showCreateSegment && (
-
-
- {is4Eyes && isVersioned
- ? `This will create a change request ${
- isVersioned
- ? 'with any value and segment override changes '
- : ''
- }for the environment`
- : 'This will update the segment overrides for the environment'}{' '}
-
- {
- _.find(
- project.environments,
- {
- api_key:
- this.props
- .environmentId,
- },
- ).name
- }
-
-
-
-
- {({
- permission:
- savePermission,
- }) => (
-
- {({
- permission:
- manageSegmentsOverrides,
- }) => {
- if (
- isVersioned &&
- is4Eyes
- ) {
- return Utils.renderWithPermission(
- savePermission,
- Utils.getManageFeaturePermissionDescription(
- is4Eyes,
- identity,
- ),
-
- saveFeatureSegments(
- false,
- )
- }
- type='button'
- data-test='update-feature-segments-btn'
- id='update-feature-segments-btn'
- disabled={
- isSaving ||
- !name ||
- invalid ||
- !savePermission
- }
- >
- {isSaving
- ? existingChangeRequest
+ .environmentId
+ }
+ >
+ {({
+ permission:
+ manageSegmentsOverrides,
+ }) => {
+ if (
+ isVersioned &&
+ is4Eyes
+ ) {
+ return Utils.renderWithPermission(
+ savePermission,
+ Utils.getManageFeaturePermissionDescription(
+ is4Eyes,
+ identity,
+ ),
+
+ saveFeatureSegments(
+ false,
+ )
+ }
+ type='button'
+ data-test='update-feature-segments-btn'
+ id='update-feature-segments-btn'
+ disabled={
+ isSaving ||
+ !projectFlag.name ||
+ invalid ||
+ !savePermission
+ }
+ >
+ {(() => {
+ if (
+ isSaving
+ ) {
+ return existingChangeRequest
? 'Updating Change Request'
: 'Creating Change Request'
- : existingChangeRequest
+ }
+ return existingChangeRequest
? 'Update Change Request'
- : 'Create Change Request'}
- ,
- )
- }
+ : 'Create Change Request'
+ })()}
+ ,
+ )
+ }
- return Utils.renderWithPermission(
- manageSegmentsOverrides,
- Constants.environmentPermissions(
- 'Manage segment overrides',
- ),
- <>
- {!is4Eyes &&
- isVersioned && (
- <>
-
- saveFeatureSegments(
- true,
- )
- }
- className='mr-2'
- type='button'
- data-test='create-change-request'
- id='create-change-request-btn'
- disabled={
- isSaving ||
- !name ||
- invalid ||
- !savePermission
- }
- >
- {isSaving
- ? existingChangeRequest
+ return Utils.renderWithPermission(
+ manageSegmentsOverrides,
+ Constants.environmentPermissions(
+ 'Manage segment overrides',
+ ),
+ <>
+ {!is4Eyes &&
+ isVersioned && (
+ <>
+
+ saveFeatureSegments(
+ true,
+ )
+ }
+ className='mr-2'
+ type='button'
+ data-test='create-change-request'
+ id='create-change-request-btn'
+ disabled={
+ isSaving ||
+ !projectFlag.name ||
+ invalid ||
+ !savePermission
+ }
+ >
+ {(() => {
+ if (
+ isSaving
+ ) {
+ return existingChangeRequest
? 'Updating Change Request'
: 'Scheduling Update'
- : existingChangeRequest
+ }
+ return existingChangeRequest
? 'Update Change Request'
- : 'Schedule Update'}
-
- >
- )}
-
- saveFeatureSegments(
- false,
- )
- }
- type='button'
- data-test='update-feature-segments-btn'
- id='update-feature-segments-btn'
- disabled={
- isSaving ||
- !name ||
- invalid ||
- !manageSegmentsOverrides
- }
- >
- {isSaving
- ? 'Updating'
- : 'Update Segment Overrides'}
-
- >,
- )
- }}
-
- )}
-
-
+ : 'Schedule Update'
+ })()}
+
+ >
+ )}
+
+ saveFeatureSegments(
+ false,
+ )
+ }
+ type='button'
+ data-test='update-feature-segments-btn'
+ id='update-feature-segments-btn'
+ disabled={
+ isSaving ||
+ !projectFlag.name ||
+ invalid ||
+ !manageSegmentsOverrides
+ }
+ >
+ {isSaving
+ ? 'Updating'
+ : 'Update Segment Overrides'}
+
+ >,
+ )
+ }}
+
+ )}
+
- )}
-
-
-
- )}
+
+ )}
+
+
+
)}
{({ permission: viewIdentities }) =>
- !identity &&
- isEdit &&
!existingChangeRequest &&
!hideIdentityOverridesTab && (
@@ -1961,11 +1602,38 @@ const CreateFlag = class extends Component {
}
>
- {Settings(
- projectAdmin,
- createFeature,
- featureContentType,
- )}
+
{
+ const updates = {}
+
+ // Update projectFlag with changes
+ updates.projectFlag = {
+ ...this.state.projectFlag,
+ ...changes,
+ }
+
+ // Set settingsChanged flag unless it's only metadata changing
+ if (changes.metadata === undefined) {
+ updates.settingsChanged = true
+ }
+
+ this.setState(updates)
+ }}
+ onHasMetadataRequiredChange={(
+ hasMetadataRequired,
+ ) =>
+ this.setState({
+ hasMetadataRequired,
+ })
+ }
+ />
- {featureLimitAlert.percentage &&
- Utils.displayLimitAlert(
- 'features',
- featureLimitAlert.percentage,
- )}
- {Value(
- error,
- projectAdmin,
- createFeature,
- project.prevent_flag_defaults && !identity,
- )}
-
+ this.setState({ featureLimitAlert })
+ }
+ />
+
+
+ this.setState({
+ projectFlag: {
+ ...this.state.projectFlag,
+ name,
+ },
+ })
+ }
+ caseSensitive={caseSensitive}
+ regex={regex}
+ regexValid={regexValid}
+ autoFocus
+ />
+
+ {
+ this.setState({
+ environmentFlag: {
+ ...this.state.environmentFlag,
+ ...changes,
+ },
+ valueChanged: true,
+ })
+ }}
+ onProjectFlagChange={(changes) => {
+ this.setState({
+ projectFlag: {
+ ...this.state.projectFlag,
+ ...changes,
+ },
+ })
+ }}
+ onRemoveMultivariateOption={
+ this.props.removeMultivariateOption
+ }
+ onHasMetadataRequiredChange={(
+ hasMetadataRequired,
+ ) =>
+ this.setState({
+ hasMetadataRequired,
+ })
+ }
+ featureError={
+ this.parseError(error).featureError
+ }
+ featureWarning={
+ this.parseError(error).featureWarning
+ }
+ />
+
- {!identity && (
-
- {project.prevent_flag_defaults ? (
-
- This will create the feature for{' '}
- all environments , you
- can edit this feature per environment
- once the feature's enabled state and
- environment once the feature is created.
-
- ) : (
-
- This will create the feature for{' '}
- all environments , you
- can edit this feature per environment
- once the feature is created.
-
- )}
-
- = 100 ||
- _hasMetadataRequired
- }
- >
- {isSaving ? 'Creating' : 'Create Feature'}
-
-
- )}
)}
{identity && (
@@ -2104,7 +1792,11 @@ const CreateFlag = class extends Component {
onClick={() => saveFeatureValue()}
data-test='update-feature-btn'
id='update-feature-btn'
- disabled={isSaving || !name || invalid}
+ disabled={
+ isSaving ||
+ !projectFlag.name ||
+ invalid
+ }
>
{isSaving
? 'Updating'
@@ -2130,7 +1822,7 @@ const CreateFlag = class extends Component {
}
}
-CreateFlag.propTypes = {}
+Index.propTypes = {}
//This will remount the modal when a feature is created
const FeatureProvider = (WrappedComponent) => {
@@ -2202,6 +1894,9 @@ const FeatureProvider = (WrappedComponent) => {
...(newEnvironmentFlag || {}),
},
projectFlag,
+ segmentsChanged: false,
+ settingsChanged: false,
+ valueChanged: false,
})
} else if (this.props.projectFlag) {
//update the environmentFlag and projectFlag to the new values
@@ -2216,6 +1911,9 @@ const FeatureProvider = (WrappedComponent) => {
...(newEnvironmentFlag || {}),
},
projectFlag: newProjectFlag,
+ segmentsChanged: false,
+ settingsChanged: false,
+ valueChanged: false,
})
}
},
@@ -2234,6 +1932,6 @@ const FeatureProvider = (WrappedComponent) => {
return HOC
}
-const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(CreateFlag))
+const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(Index))
export default FeatureProvider(WrappedCreateFlag)
diff --git a/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx
new file mode 100644
index 000000000000..5fa2c8c11661
--- /dev/null
+++ b/frontend/web/components/modals/CreateFlag/tabs/CreateFeature.tsx
@@ -0,0 +1,88 @@
+import React, { FC } from 'react'
+import { FeatureState, ProjectFlag } from 'common/types/responses'
+import FeatureValue from './FeatureValue'
+import FeatureSettings from './FeatureSettings'
+import ErrorMessage from 'components/ErrorMessage'
+import WarningMessage from 'components/WarningMessage'
+import { useHasPermission } from 'common/providers/Permission'
+
+type CreateFeatureTabProps = {
+ projectId: number
+ error: any
+ featureState: FeatureState
+ projectFlag: ProjectFlag | null
+ featureContentType: any
+ identity?: string
+ onEnvironmentFlagChange: (changes: FeatureState) => void
+ onProjectFlagChange: (changes: ProjectFlag) => void
+ onRemoveMultivariateOption?: (id: number) => void
+ onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void
+ featureError?: string
+ featureWarning?: string
+}
+
+const CreateFeature: FC = ({
+ error,
+ featureContentType,
+ featureError,
+ featureState,
+ featureWarning,
+ identity,
+ onEnvironmentFlagChange,
+ onHasMetadataRequiredChange,
+ onProjectFlagChange,
+ onRemoveMultivariateOption,
+ projectFlag,
+ projectId,
+}) => {
+ const { permission: createFeature } = useHasPermission({
+ id: projectId,
+ level: 'project',
+ permission: 'CREATE_FEATURE',
+ })
+
+ const { permission: projectAdmin } = useHasPermission({
+ id: projectId,
+ level: 'project',
+ permission: 'ADMIN',
+ })
+
+ const noPermissions = !createFeature && !projectAdmin
+
+ return (
+ <>
+
+
+ {!!projectFlag && (
+ <>
+
+
+ >
+ )}
+ >
+ )
+}
+
+export default CreateFeature
diff --git a/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx
new file mode 100644
index 000000000000..27589b8ade69
--- /dev/null
+++ b/frontend/web/components/modals/CreateFlag/tabs/FeatureSettings.tsx
@@ -0,0 +1,186 @@
+import React, { FC } from 'react'
+import { ProjectFlag } from 'common/types/responses'
+import Constants from 'common/constants'
+import InfoMessage from 'components/InfoMessage'
+import InputGroup from 'components/base/forms/InputGroup'
+import AddEditTags from 'components/tags/AddEditTags'
+import AddMetadataToEntity from 'components/metadata/AddMetadataToEntity'
+import Permission from 'common/providers/Permission'
+import FlagOwners from 'components/FlagOwners'
+import FlagOwnerGroups from 'components/FlagOwnerGroups'
+import PlanBasedBanner from 'components/PlanBasedAccess'
+import Switch from 'components/Switch'
+import Tooltip from 'components/Tooltip'
+import Icon from 'components/Icon'
+import Utils from 'common/utils/utils'
+import FormGroup from 'components/base/grid/FormGroup'
+import Row from 'components/base/grid/Row'
+import AccountStore from 'common/stores/account-store'
+type FeatureSettingsTabProps = {
+ projectAdmin: boolean
+ createFeature: boolean
+ featureContentType: any
+ identity?: string
+ isEdit: boolean
+ projectId: number | string
+ projectFlag: ProjectFlag | null
+ onChange: (projectFlag: ProjectFlag) => void
+ onHasMetadataRequiredChange: (hasMetadataRequired: boolean) => void
+}
+
+const FeatureSettings: FC = ({
+ createFeature,
+ featureContentType,
+ identity,
+ isEdit,
+ onChange,
+ onHasMetadataRequiredChange,
+ projectFlag,
+ projectId,
+}) => {
+ const metadataEnable = Utils.getPlansPermission('METADATA')
+
+ if (!createFeature) {
+ return (
+
+
+
+ )
+ }
+
+ if (!projectFlag) {
+ return null
+ }
+ return (
+
+ {!identity && projectFlag?.tags && (
+
+ onChange({ ...projectFlag, tags })}
+ />
+ }
+ />
+
+ )}
+ {metadataEnable && featureContentType?.id && !identity && (
+ <>
+
Custom Fields
+
onChange({ ...projectFlag, metadata })}
+ />
+ >
+ )}
+ {!identity && projectFlag?.id && (
+
+ {({ permission }) =>
+ permission && (
+ <>
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ )}
+
+
+ onChange({
+ ...projectFlag,
+ description: Utils.safeParseEventValue(e),
+ })
+ }
+ ds
+ type='text'
+ title={identity ? 'Description' : 'Description (optional)'}
+ placeholder="e.g. 'This determines what size the header is' "
+ />
+
+
+ {!identity && (
+
+
+
+ onChange({ ...projectFlag, is_server_key_only })
+ }
+ className='ml-0'
+ />
+
+ Server-side only
+
+ }
+ >
+ Prevent this feature from being accessed with client-side SDKs.
+
+
+
+ )}
+
+ {!identity && isEdit && (
+
+
+
+ onChange({ ...projectFlag, is_archived })
+ }
+ className='ml-0'
+ />
+
+ Archived
+
+ }
+ >
+ {`Archiving a flag allows you to filter out flags from the
+ Flagsmith dashboard that are no longer relevant.
+
+ An archived flag will still return as normal in all SDK
+ endpoints.`}
+
+
+
+ )}
+
+ )
+}
+
+export default FeatureSettings
diff --git a/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx b/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx
new file mode 100644
index 000000000000..b054faaab889
--- /dev/null
+++ b/frontend/web/components/modals/CreateFlag/tabs/FeatureValue.tsx
@@ -0,0 +1,294 @@
+import React, { FC, useEffect, useState } from 'react'
+import InputGroup from 'components/base/forms/InputGroup'
+import ValueEditor from 'components/ValueEditor'
+import Constants from 'common/constants'
+import { VariationOptions } from 'components/mv/VariationOptions'
+import { AddVariationButton } from 'components/mv/AddVariationButton'
+import ErrorMessage from 'components/ErrorMessage'
+import WarningMessage from 'components/WarningMessage'
+import Tooltip from 'components/Tooltip'
+import Icon from 'components/Icon'
+import Switch from 'components/Switch'
+import Utils from 'common/utils/utils'
+import { FeatureState, ProjectFlag } from 'common/types/responses'
+
+function isNegativeNumberString(str: any) {
+ if (typeof Utils.getTypedValue(str) !== 'number') {
+ return false
+ }
+ if (typeof str !== 'string') {
+ return false
+ }
+ const num = parseFloat(str)
+ return !isNaN(num) && num < 0
+}
+
+type EditFeatureValueProps = {
+ error: any
+ createFeature: boolean
+ hideValue: boolean
+ isEdit: boolean
+ identity?: string
+ identityName?: string
+ noPermissions: boolean
+ featureState: FeatureState
+ projectFlag: ProjectFlag
+ onEnvironmentFlagChange: (changes: FeatureState) => void
+ onProjectFlagChange: (changes: ProjectFlag) => void
+ onRemoveMultivariateOption?: (id: number) => void
+}
+
+/* eslint-disable sort-destructure-keys/sort-destructure-keys */
+const FeatureValue: FC = ({
+ createFeature,
+ error,
+ featureState,
+ hideValue,
+ identity,
+ isEdit,
+ noPermissions,
+ onEnvironmentFlagChange,
+ onProjectFlagChange,
+ onRemoveMultivariateOption,
+ projectFlag,
+}) => {
+ /* eslint-enable sort-destructure-keys/sort-destructure-keys */
+ const default_enabled = featureState.enabled ?? false
+ const initial_value = featureState.feature_state_value
+ const multivariate_options = projectFlag.multivariate_options || []
+ const environmentVariations =
+ featureState.multivariate_feature_state_values ?? []
+ const identityVariations =
+ featureState.multivariate_feature_state_values ?? []
+
+ const addVariation = () => {
+ const newVariation = {
+ ...Utils.valueToFeatureState(''),
+ default_percentage_allocation: 0,
+ }
+ onProjectFlagChange({
+ multivariate_options: [...multivariate_options, newVariation],
+ })
+ }
+
+ const [isNegativeNumber, setIsNegativeNumber] = useState(
+ isNegativeNumberString(featureState?.feature_state_value),
+ )
+
+ useEffect(() => {
+ setIsNegativeNumber(
+ isNegativeNumberString(featureState?.feature_state_value),
+ )
+ }, [featureState?.feature_state_value])
+
+ const handleRemoveVariation = (i: number) => {
+ const idToRemove = multivariate_options[i].id
+
+ const doRemove = () => {
+ if (idToRemove && onRemoveMultivariateOption) {
+ onRemoveMultivariateOption(idToRemove)
+ }
+ const newMultivariateOptions = multivariate_options.filter(
+ (_, index) => index !== i,
+ )
+ onProjectFlagChange({
+ multivariate_options: newMultivariateOptions,
+ })
+ }
+
+ if (idToRemove) {
+ openConfirm({
+ body: 'This will remove the variation on your feature for all environments, if you wish to turn it off just for this environment you can set the % value to 0.',
+ destructive: true,
+ onYes: doRemove,
+ title: 'Delete variation',
+ yesText: 'Confirm',
+ })
+ } else {
+ doRemove()
+ }
+ }
+
+ const handleUpdateVariation = (
+ i: number,
+ updatedVariation: any,
+ updatedEnvironmentVariations: any[],
+ ) => {
+ // Update the environment variations (weights)
+ onEnvironmentFlagChange({
+ multivariate_feature_state_values: updatedEnvironmentVariations,
+ })
+
+ // Update the multivariate option itself
+ const newMultivariateOptions = [...multivariate_options]
+ newMultivariateOptions[i] = updatedVariation
+ onProjectFlagChange({
+ multivariate_options: newMultivariateOptions,
+ })
+ }
+
+ const enabledString = isEdit ? 'Enabled' : 'Enabled by default'
+ const controlPercentage = Utils.calculateControl(multivariate_options)
+
+ const getValueString = () => {
+ if (multivariate_options && multivariate_options.length) {
+ return `Control Value - ${controlPercentage}%`
+ }
+ return 'Value'
+ }
+ const valueString = getValueString()
+
+ const showValue = !(
+ !!identity &&
+ multivariate_options &&
+ !!multivariate_options.length
+ )
+
+ return (
+ <>
+ {!hideValue && (
+
+
+
+ onEnvironmentFlagChange({ enabled })}
+ className='ml-0'
+ />
+
+ {enabledString || 'Enabled'}
+
+ {!isEdit && }
+
+ }
+ >
+ {!isEdit
+ ? 'This will determine the initial enabled state for all environments. You can edit the this individually for each environment once the feature is created.'
+ : ''}
+
+
+
+ {showValue && (
+
+ {
+ const feature_state_value = Utils.getTypedValue(
+ Utils.safeParseEventValue(e),
+ )
+ onEnvironmentFlagChange({ feature_state_value })
+ }}
+ disabled={noPermissions}
+ placeholder="e.g. 'big' "
+ />
+ }
+ tooltip={`${Constants.strings.REMOTE_CONFIG_DESCRIPTION}${
+ !isEdit
+ ? ' Setting this when creating a feature will set the value for all environments. You can edit this individually for each environment once the feature is created.'
+ : ''
+ }`}
+ title={`${valueString}`}
+ />
+
+ )}
+
+ {isNegativeNumber && (
+
+ This feature currently has the value of{' '}
+ "{featureState.feature_state_value}" . Saving
+ this feature will convert its value from a string to a number.
+ If you wish to preserve this value as a string, please save it
+ using the{' '}
+
+ API
+
+ .
+
+ }
+ />
+ )}
+
+ {!!error?.initial_value?.[0] && (
+
+
+
+ )}
+
+ {!!identity && (
+
+
+
+ onEnvironmentFlagChange({
+ multivariate_feature_state_values,
+ })
+ }
+ updateVariation={() => {}}
+ weightTitle='Override Weight %'
+ projectFlag={projectFlag}
+ multivariateOptions={projectFlag.multivariate_options}
+ removeVariation={() => {}}
+ />
+
+
+ )}
+
+ {!identity && (
+
+
+ {(!!environmentVariations || !isEdit) && (
+
+ )}
+
+ {Utils.renderWithPermission(
+ createFeature,
+ Constants.projectPermissions('Create Feature'),
+
,
+ )}
+
+ )}
+
+ )}
+ >
+ )
+}
+
+export default FeatureValue
diff --git a/frontend/web/components/modals/FlagValueFooter.tsx b/frontend/web/components/modals/FlagValueFooter.tsx
index 2a023b03552f..71d16c1e990a 100644
--- a/frontend/web/components/modals/FlagValueFooter.tsx
+++ b/frontend/web/components/modals/FlagValueFooter.tsx
@@ -1,11 +1,9 @@
-import _ from 'lodash'
import { useMemo } from 'react'
import Utils from 'common/utils/utils'
import ModalHR from './ModalHR'
import Constants from 'common/constants'
import Permission from 'common/providers/Permission'
-import { Identity } from 'common/types/responses'
import { useGetReleasePipelinesQuery } from 'common/services/useReleasePipelines'
import AddToReleasePipelineModal from 'components/release-pipelines/AddToReleasePipelineModal'
import RemoveFromReleasePipelineModal from 'components/release-pipelines/RemoveFromReleasePipelineModal'
@@ -24,7 +22,6 @@ export interface FlagValueFooterProps {
isInvalid: boolean
existingChangeRequest: boolean
onSaveFeatureValue: (schedule?: boolean) => void
- identity: Identity
}
const FlagValueFooter = ({
@@ -32,7 +29,6 @@ const FlagValueFooter = ({
environmentName,
existingChangeRequest,
featureName,
- identity,
is4Eyes,
isInvalid,
isSaving,
@@ -116,17 +112,14 @@ const FlagValueFooter = ({
{({ permission: savePermission }) =>
Utils.renderWithPermission(
savePermission,
Constants.environmentPermissions(
- Utils.getManageFeaturePermissionDescription(
- is4Eyes,
- identity,
- ),
+ Utils.getManageFeaturePermissionDescription(is4Eyes),
),
<>
{!is4Eyes && (
diff --git a/frontend/web/components/mv/VariationOptions.tsx b/frontend/web/components/mv/VariationOptions.tsx
index 72b4a703c8d5..acf980fc78ab 100644
--- a/frontend/web/components/mv/VariationOptions.tsx
+++ b/frontend/web/components/mv/VariationOptions.tsx
@@ -10,10 +10,9 @@ interface VariationOptionsProps {
controlValue: any
disabled: boolean
multivariateOptions: any[]
- preventRemove: boolean
- readOnlyValue: boolean
+ readOnly?: boolean
removeVariation: (i: number) => void
- select: boolean
+ select?: boolean
setValue: (value: any) => void
setVariations: (variations: any[]) => void
updateVariation: (
@@ -31,8 +30,7 @@ export const VariationOptions: React.FC = ({
controlValue,
disabled,
multivariateOptions,
- preventRemove,
- readOnlyValue,
+ readOnly,
removeVariation,
select,
setValue,
@@ -56,7 +54,7 @@ export const VariationOptions: React.FC = ({
error='Your variation percentage splits total to over 100%'
/>
)}
- {!preventRemove && (
+ {!readOnly && (
Changing a Variation Value will affect{' '}
@@ -152,7 +150,7 @@ export const VariationOptions: React.FC = ({
key={i}
index={i}
canCreateFeature={canCreateFeature}
- readOnlyValue={readOnlyValue}
+ readOnly={readOnly}
value={theValue}
onChange={(e) => {
updateVariation(i, e, variationOverrides)
@@ -160,7 +158,7 @@ export const VariationOptions: React.FC = ({
weightTitle={weightTitle}
disabled={disabled}
onRemove={
- preventRemove || disabled ? undefined : () => removeVariation(i)
+ readOnly || disabled ? undefined : () => removeVariation(i)
}
/>
)
diff --git a/frontend/web/components/mv/VariationValueInput.tsx b/frontend/web/components/mv/VariationValueInput.tsx
index a10cfe1f4d62..4565ea7835d8 100644
--- a/frontend/web/components/mv/VariationValueInput.tsx
+++ b/frontend/web/components/mv/VariationValueInput.tsx
@@ -12,7 +12,7 @@ interface VariationValueProps {
index: number
onChange: (value: any) => void
onRemove?: () => void
- readOnlyValue: boolean
+ readOnly: boolean
value: any
weightTitle: string
}
@@ -23,7 +23,7 @@ export const VariationValueInput: React.FC = ({
index,
onChange,
onRemove,
- readOnlyValue,
+ readOnly,
value,
weightTitle,
}) => {
@@ -44,7 +44,7 @@ export const VariationValueInput: React.FC = ({
name='featureValue'
className='full-width code-medium'
value={Utils.getTypedValue(Utils.featureStateToValue(value))}
- disabled={!canCreateFeature || disabled || readOnlyValue}
+ disabled={!canCreateFeature || disabled || readOnly}
onBlur={() => {
const newValue = {
...value,
@@ -98,7 +98,7 @@ export const VariationValueInput: React.FC = ({
title={weightTitle}
/>
- {!!onRemove && (
+ {!!onRemove && !readOnly && (
= ({ match }) => {
if (!changeRequest) return
openModal(
'Edit Change Request',
- {
openModal(
'New Feature',
- {
+ const isReleasePipelineEnabled =
+ Utils.getFlagsmithHasFeature('release_pipelines')
const { data: releasePipelines } = useGetReleasePipelinesQuery(
{
page_size: 100,
@@ -23,7 +26,7 @@ const FeaturePipelineStatus = ({
q: 'name',
},
{
- skip: !projectId,
+ skip: !projectId || !isReleasePipelineEnabled,
},
)
const matchingReleasePipeline = useMemo(
@@ -61,7 +64,7 @@ const FeaturePipelineStatus = ({
[stageHasFeature, featureId],
)
- if (!stages) return null
+ if (!stages || !isReleasePipelineEnabled) return null
return (
diff --git a/frontend/web/components/tags/AddEditTags.tsx b/frontend/web/components/tags/AddEditTags.tsx
index 67dceff07dbc..d2f167adbc6e 100644
--- a/frontend/web/components/tags/AddEditTags.tsx
+++ b/frontend/web/components/tags/AddEditTags.tsx
@@ -193,6 +193,7 @@ const AddEditTags: FC = ({
),
{
@@ -215,14 +216,13 @@ const AddEditTags: FC = ({
)}
-
+
{filteredTags &&
filteredTags.map((tag) => (
= ({
: 'opacity-0 pointer-events-none'
}
>
-
+
confirmDeleteTag(tag)}
className='ml-3 clickable'
>
-
+
>
)}
diff --git a/frontend/web/components/tags/TagValues.tsx b/frontend/web/components/tags/TagValues.tsx
index 6e8273a21c1f..74d697730b68 100644
--- a/frontend/web/components/tags/TagValues.tsx
+++ b/frontend/web/components/tags/TagValues.tsx
@@ -64,7 +64,7 @@ const TagValues: FC
= ({
),
onAdd?.()}
type='button'
theme='outline'
diff --git a/frontend/web/project/project-components.js b/frontend/web/project/project-components.js
index 29d1a696d8e2..838b436334dd 100644
--- a/frontend/web/project/project-components.js
+++ b/frontend/web/project/project-components.js
@@ -15,7 +15,8 @@ import OrganisationProvider from 'common/providers/OrganisationProvider'
import Panel from 'components/base/grid/Panel'
import { checkmarkCircle } from 'ionicons/icons'
import { IonIcon } from '@ionic/react'
-
+import FormGroup from 'components/base/grid/FormGroup'
+import Row from 'components/base/grid/Row'
window.AppActions = require('../../common/dispatcher/app-actions')
window.Actions = require('../../common/dispatcher/action-constants')
window.ES6Component = require('../../common/ES6Component')
@@ -29,15 +30,14 @@ window.ProjectProvider = ProjectProvider
window.Paging = Paging
// Useful components
-window.Row = require('../components/base/grid/Row')
+window.Row = Row
window.Flex = require('../components/base/grid/Flex')
window.Column = require('../components/base/grid/Column')
window.InputGroup = InputGroup
window.Input = Input
window.Button = Button
-window.FormGroup = require('../components/base/grid/FormGroup')
+window.FormGroup = FormGroup
window.Panel = Panel
-window.FormGroup = require('../components/base/grid/FormGroup')
window.PanelSearch = PanelSearch
window.CodeHelp = CodeHelp
diff --git a/frontend/web/styles/_variables.scss b/frontend/web/styles/_variables.scss
index aad1ba774fa6..b91e7b92c8ee 100644
--- a/frontend/web/styles/_variables.scss
+++ b/frontend/web/styles/_variables.scss
@@ -105,6 +105,7 @@ $border-radius-xlg: 10px;
//Buttons
$btn-line-height-xsm: 32px;
+$btn-line-height-xxsm: 24px;
$btn-line-height-sm: 40px;
$btn-line-height: 44px;
$btn-line-height-lg: 56px;
diff --git a/frontend/web/styles/project/_buttons.scss b/frontend/web/styles/project/_buttons.scss
index cf7bba27d733..5e0ae70f961a 100644
--- a/frontend/web/styles/project/_buttons.scss
+++ b/frontend/web/styles/project/_buttons.scss
@@ -341,6 +341,13 @@ button.btn {
font-size: $font-caption-sm;
border-radius: $border-radius-sm;
}
+ &-xxsm {
+ padding: 0 12px;
+ line-height: $btn-line-height-xxsm;
+ height: $btn-line-height-xxsm;
+ font-size: $font-caption-sm;
+ border-radius: $border-radius-sm;
+ }
}
.btn-link {