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 && (
- -
- ) - } - if (darkMode) { - return ( - - ) - } - 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 ( +
+ +
+ ) + } + + if (darkMode) { + return ( + + ) + } + + 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. + + + +
+ )} + + ) +} + +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 && ( - <> - - { - 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.state.showCreateSegment && + !noPermissions && ( + + )} +
+ {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.state - .showCreateSegment && - !noPermissions && ( -
- )} - - {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, - ), - , - ) - } + : 'Create Change Request' + })()} + , + ) + } - return Utils.renderWithPermission( - manageSegmentsOverrides, - Constants.environmentPermissions( - 'Manage segment overrides', - ), - <> - {!is4Eyes && - isVersioned && ( - <> - - - )} - - , - ) - }} - - )} - -
+ : 'Schedule Update' + })()} + + + )} + + , + ) + }} + + )} +
- )} -
- - - )} +
+ )} +
+
+
)} {({ 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. - - )} - - -
- )}
)} {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 && ( + <> + + 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 && (

)} -
+
{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 = ({ ),