diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b953045d740a..c69dfe57f993 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,7 +23,7 @@ "@babel/register": "^7.12.1", "@datadog/ui-extensions-react": "0.32.0", "@datadog/ui-extensions-sdk": "0.32.0", - "@flagsmith/flagsmith": "^11.0.0-internal.5", + "@flagsmith/flagsmith": "^11.0.0-internal.6", "@ionic/react": "^7.5.3", "@material-ui/core": "4.12.4", "@react-oauth/google": "^0.2.8", @@ -2780,9 +2780,9 @@ } }, "node_modules/@flagsmith/flagsmith": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@flagsmith/flagsmith/-/flagsmith-11.0.0.tgz", - "integrity": "sha512-jJB+1O/ctU7TCoIBsV2lgYAUOpShjcSqHkH4nlyqUGeQCGC0ZHto8IvGf+nZwpFAF5Czaphn/anm9MDoZIS3rw==", + "version": "11.0.0-internal.6", + "resolved": "https://registry.npmjs.org/@flagsmith/flagsmith/-/flagsmith-11.0.0-internal.6.tgz", + "integrity": "sha512-Vz719LOLC6h6KDHqlULOJTCth5RuX2iT4GPxcTZTGxOrxM9q4XwE8AxxLqoExgucXrd2neKUMU0PDmB1Q3LE0Q==", "license": "BSD-3-Clause" }, "node_modules/@floating-ui/core": { diff --git a/frontend/package.json b/frontend/package.json index 7a1e53625582..e22927246a51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "@babel/register": "^7.12.1", "@datadog/ui-extensions-react": "0.32.0", "@datadog/ui-extensions-sdk": "0.32.0", - "@flagsmith/flagsmith": "^11.0.0-internal.5", + "@flagsmith/flagsmith": "^11.0.0-internal.6", "@ionic/react": "^7.5.3", "@material-ui/core": "4.12.4", "@react-oauth/google": "^0.2.8", diff --git a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer.tsx b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer.tsx index 42227eb39165..4ede1c2b5bfc 100644 --- a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer.tsx +++ b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer.tsx @@ -1,4 +1,5 @@ -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' +import flagsmith from '@flagsmith/flagsmith' import { useGetFeatureCodeReferencesQuery } from 'common/services/useCodeReferences' import RepoCodeReferencesSection from './components/RepoCodeReferencesSection' import { FeatureCodeReferences } from 'common/types/responses' @@ -34,6 +35,23 @@ const FeatureCodeReferencesContainer: React.FC< [data], ) + const hasTrackedView = useRef(false) + useEffect(() => { + if (data.length > 0 && !hasTrackedView.current) { + hasTrackedView.current = true + const totalRefs = data.reduce( + (sum, repo) => sum + repo.code_references.length, + 0, + ) + flagsmith.trackEvent('code_references_view', { + feature_id: featureId, + project_id: projectId, + repos_count: data.length, + total_refs_count: totalRefs, + }) + } + }, [data, featureId, projectId]) + if (isLoading) { return (
@@ -59,6 +77,7 @@ const FeatureCodeReferencesContainer: React.FC< key={codeReferencesByRepo[repo].repository_url} repositoryName={codeReferencesByRepo[repo].repository_url} repositoryScan={codeReferencesByRepo[repo]} + featureId={featureId} /> ))}
diff --git a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/CodeReferenceItem.tsx b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/CodeReferenceItem.tsx index edeb324c30c0..334a16903db8 100644 --- a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/CodeReferenceItem.tsx +++ b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/CodeReferenceItem.tsx @@ -1,12 +1,17 @@ +import flagsmith from '@flagsmith/flagsmith' import Icon from 'components/Icon' -import { CodeReference } from 'common/types/responses' +import { CodeReference, VCSProvider } from 'common/types/responses' interface CodeReferenceItemProps { codeReference: CodeReference + featureId: number + vcsProvider: VCSProvider } const CodeReferenceItem: React.FC = ({ codeReference, + featureId, + vcsProvider, }) => { return ( @@ -25,6 +30,12 @@ const CodeReferenceItem: React.FC = ({ href={codeReference.permalink} target='_blank' rel='noreferrer' + onClick={() => { + flagsmith.trackEvent('code_references_click_permalink', { + feature_id: featureId, + vcs_provider: vcsProvider, + }) + }} > {codeReference.file_path}:{codeReference.line_number} diff --git a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoCodeReferencesSection.tsx b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoCodeReferencesSection.tsx index 48adc5f7c5f8..cc8018dea91a 100644 --- a/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoCodeReferencesSection.tsx +++ b/frontend/web/components/feature-page/FeatureNavTab/CodeReferences/components/RepoCodeReferencesSection.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react' +import flagsmith from '@flagsmith/flagsmith' import { FeatureCodeReferences } from 'common/types/responses' import moment from 'moment' import CodeReferenceItem from './CodeReferenceItem' @@ -9,9 +10,11 @@ import CodeReferenceScanIndicator from './CodeReferenceScanIndicator' interface RepoCodeReferencesSectionProps { repositoryScan: FeatureCodeReferences repositoryName: string + featureId: number } const RepoCodeReferencesSection: React.FC = ({ + featureId, repositoryName, repositoryScan, }) => { @@ -42,7 +45,16 @@ const RepoCodeReferencesSection: React.FC = ({ > setIsOpen(!isOpen)} + onClick={() => { + if (!isOpen) { + flagsmith.trackEvent('code_references_expand_repo', { + feature_id: featureId, + refs_count: repositoryScan?.code_references?.length, + vcs_provider: repositoryScan?.vcs_provider, + }) + } + setIsOpen(!isOpen) + }} > = ({ {repositoryScan?.code_references?.map((codeReference) => ( ))} diff --git a/frontend/web/components/modals/create-feature/index.js b/frontend/web/components/modals/create-feature/index.js new file mode 100644 index 000000000000..08372eea3de7 --- /dev/null +++ b/frontend/web/components/modals/create-feature/index.js @@ -0,0 +1,2069 @@ +import React, { Component } from 'react' +import withSegmentOverrides from 'common/providers/withSegmentOverrides' +import moment from 'moment' +import Constants from 'common/constants' +import data from 'common/data/base/_data' +import ProjectStore from 'common/stores/project-store' +import ConfigProvider from 'common/providers/ConfigProvider' +import FeatureListStore from 'common/stores/feature-list-store' +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 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 'components/modals/base/ModalDefault' +import Icon from 'components/Icon' +import ModalHR from 'components/modals/ModalHR' +import FeatureValue from 'components/feature-summary/FeatureValue' +import { getStore } from 'common/store' +import Button from 'components/base/forms/Button' +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 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 FeaturePipelineStatus from 'components/release-pipelines/FeaturePipelineStatus' +import FeatureInPipelineGuard from 'components/release-pipelines/FeatureInPipelineGuard' +import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' +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' +import { + EnvironmentPermission, + ProjectPermission, +} from 'common/types/permissions.types' + +const Index = class extends Component { + static displayName = 'create-feature' + + constructor(props, context) { + super(props, context) + 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 sourceFlag = this.props.identityFlag || this.props.environmentFlag + const environmentFlagData = sourceFlag ? _.cloneDeep(sourceFlag) : {} + + this.state = { + changeRequests: [], + enabledIndentity: false, + enabledSegment: false, + environmentFlag: environmentFlagData, + externalResource: {}, + externalResources: [], + featureContentType: {}, + featureLimitAlert: { percentage: 0 }, + githubId: '', + hasIntegrationWithGithub: false, + hasMetadataRequired: false, + isEdit: !!this.props.projectFlag, + period: 30, + projectFlag: projectFlagData, + scheduledChangeRequests: [], + segmentsChanged: false, + selectedIdentity: null, + settingsChanged: false, + userOverridesError: false, + userOverridesNoPermission: false, + valueChanged: false, + } + } + + 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 + ) { + if ( + this.props.environmentVariations && + this.props.environmentVariations.length + ) { + this.setState({ + 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, + } + }), + }, + }) + } + } + } + + 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 ( + projectFlagChanged || + environmentFlagChanged || + segmentOverridesChanged + ) { + openConfirm({ + body: 'Closing this will discard your unsaved changes.', + noText: 'Cancel', + onNo: () => resolve(false), + onYes: () => resolve(true), + title: 'Discard changes', + yesText: 'Ok', + }) + } else { + resolve(true) + } + }) + } + return Promise.resolve(true) + } + + componentDidMount = () => { + setInterceptClose(this.onClosing) + if (Utils.getPlansPermission('METADATA')) { + getSupportedContentType(getStore(), { + organisation_id: AccountStore.getOrganisation().id, + }).then((res) => { + const featureContentType = Utils.getContentType( + res.data, + 'model', + 'feature', + ) + this.setState({ featureContentType: featureContentType }) + }) + } + + this.fetchChangeRequests() + this.fetchScheduledChangeRequests() + + getGithubIntegration(getStore(), { + organisation_id: AccountStore.getOrganisation().id, + }).then((res) => { + this.setState({ + githubId: res?.data?.results[0]?.id, + hasIntegrationWithGithub: !!res?.data?.results?.length, + }) + }) + } + + componentWillUnmount() { + if (this.focusTimeout) { + clearTimeout(this.focusTimeout) + } + } + + setUserOverridesError = () => { + this.setState({ + userOverrides: [], + userOverridesError: true, + userOverridesNoPermission: false, + userOverridesPaging: { count: 0, currentPage: 1, next: null }, + }) + } + + setUserOverridesNoPermission = () => { + this.setState({ + userOverrides: [], + userOverridesError: false, + userOverridesNoPermission: true, + userOverridesPaging: { count: 0, currentPage: 1, next: null }, + }) + } + + userOverridesPage = (page, forceRefetch) => { + if (Utils.getIsEdge()) { + // Early return if tab should be hidden + if (Utils.getShouldHideIdentityOverridesTab(ProjectStore.model)) { + this.setState({ + userOverrides: [], + userOverridesPaging: { + count: 0, + currentPage: 1, + next: null, + }, + }) + return + } + + getPermission( + getStore(), + { + id: this.props.environmentId, + level: 'environment', + permissions: EnvironmentPermission.VIEW_IDENTITIES, + }, + { forceRefetch }, + ) + .then((permissions) => { + const hasViewIdentitiesPermission = + permissions[EnvironmentPermission.VIEW_IDENTITIES] || + permissions.ADMIN + // Early return if user doesn't have permission + if (!hasViewIdentitiesPermission) { + this.setUserOverridesNoPermission() + return + } + + data + .get( + `${Project.api}environments/${this.props.environmentId}/edge-identity-overrides?feature=${this.props.projectFlag.id}&page=${page}`, + ) + .then((userOverrides) => { + this.setState({ + userOverrides: userOverrides.results.map((v) => ({ + ...v.feature_state, + identity: { + id: v.identity_uuid, + identifier: v.identifier, + }, + })), + userOverridesError: false, + userOverridesNoPermission: false, + userOverridesPaging: { + count: userOverrides.count, + currentPage: page, + next: userOverrides.next, + }, + }) + }) + .catch((response) => { + if (response?.status === 403) { + this.setUserOverridesNoPermission() + } else { + this.setUserOverridesError() + } + }) + }) + .catch(() => { + this.setUserOverridesError() + }) + + return + } + + data + .get( + `${Project.api}environments/${ + this.props.environmentId + }/${Utils.getFeatureStatesEndpoint()}/?anyIdentity=1&feature=${ + this.props.projectFlag.id + }&page=${page}`, + ) + .then((userOverrides) => { + this.setState({ + userOverrides: userOverrides.results, + userOverridesError: false, + userOverridesNoPermission: false, + userOverridesPaging: { + count: userOverrides.count, + currentPage: page, + next: userOverrides.next, + }, + }) + }) + .catch((response) => { + if (response?.status === 403) { + this.setUserOverridesNoPermission() + } else { + this.setUserOverridesError() + } + }) + } + + renderUserOverridesNoResults = () => { + if (this.state.userOverridesError) { + return ( +
+ Failed to load identity overrides. +
+ ) + } + if (this.state.userOverridesNoPermission) { + return ( +
+ You do not have permission to view identity overrides. +
+ ) + } + return ( + +
+ No identities are overriding this feature. +
+
+ ) + } + + save = (func, isSaving) => { + const { + environmentFlag, + environmentId, + identity, + identityFlag, + projectFlag: _projectFlag, + segmentOverrides, + } = this.props + const { environmentFlag: stateEnvironmentFlag, projectFlag } = this.state + const hasMultivariate = + environmentFlag && + environmentFlag.multivariate_feature_state_values && + environmentFlag.multivariate_feature_state_values.length + if (identity) { + !isSaving && + projectFlag.name && + func({ + environmentFlag, + environmentId, + identity, + identityFlag: Object.assign({}, identityFlag || {}, { + enabled: stateEnvironmentFlag.enabled, + feature_state_value: hasMultivariate + ? environmentFlag.feature_state_value + : this.cleanInputValue(stateEnvironmentFlag.feature_state_value), + multivariate_options: + stateEnvironmentFlag.multivariate_feature_state_values, + }), + projectFlag, + }) + } else { + FeatureListStore.isSaving = true + FeatureListStore.trigger('change') + !isSaving && + projectFlag.name && + func( + this.props.projectId, + this.props.environmentId, + { + 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 !== projectFlag.metadata && + projectFlag.metadata.length) + ? projectFlag.metadata + : this.props.projectFlag.metadata, + multivariate_options: projectFlag.multivariate_options, + name: projectFlag.name, + tags: projectFlag.tags, + }, + { + skipSaveProjectFeature: this.state.skipSaveProjectFeature, + ..._projectFlag, + }, + { + ...environmentFlag, + multivariate_feature_state_values: + this.props.environmentVariations || + environmentFlag?.multivariate_feature_state_values, + }, + segmentOverrides, + ) + } + } + + changeSegment = (items) => { + const { enabledSegment } = this.state + items.forEach((item) => { + item.enabled = enabledSegment + }) + this.props.updateSegments(items) + this.setState({ enabledSegment: !enabledSegment }) + } + + changeIdentity = (items) => { + const { environmentId } = this.props + const { enabledIndentity } = this.state + + Promise.all( + items.map( + (item) => + new Promise((resolve) => { + AppActions.changeUserFlag({ + environmentId, + identity: item.identity.id, + identityFlag: item.id, + onSuccess: resolve, + payload: { + enabled: enabledIndentity, + id: item.identity.id, + value: item.identity.identifier, + }, + }) + }), + ), + ).then(() => { + this.userOverridesPage(1) + }) + + this.setState({ enabledIndentity: !enabledIndentity }) + } + + toggleUserFlag = ({ enabled, id, identity }) => { + const { environmentId } = this.props + + AppActions.changeUserFlag({ + environmentId, + identity: identity.id, + identityFlag: id, + onSuccess: () => { + this.userOverridesPage(1) + }, + payload: { + enabled: !enabled, + id: identity.id, + value: identity.identifier, + }, + }) + } + parseError = (error) => { + const { projectFlag } = this.props + let featureError = + error?.metadata?.flatMap((m) => m.non_field_errors ?? []).join('\n') || + error?.message || + error?.name?.[0] || + error + let featureWarning = '' + //Treat multivariate no changes as warnings + if ( + featureError?.includes?.('no changes') && + projectFlag?.multivariate_options?.length + ) { + featureWarning = `Your feature contains no changes to its value, enabled state or environment weights. If you have adjusted any variation values this will have been saved for all environments.` + featureError = '' + } + return { featureError, featureWarning } + } + cleanInputValue = (value) => { + if (value && typeof value === 'string') { + return value.trim() + } + return value + } + + addItem = () => { + const { environmentFlag, environmentId, identity, projectFlag } = this.props + this.setState({ isLoading: true }) + const selectedIdentity = this.state.selectedIdentity.value + const identities = identity ? identity.identifier : [] + + if (!_.find(identities, (v) => v.identifier === selectedIdentity)) { + data + .post( + `${ + Project.api + }environments/${environmentId}/${Utils.getIdentitiesEndpoint()}/${selectedIdentity}/${Utils.getFeatureStatesEndpoint()}/`, + { + enabled: !environmentFlag.enabled, + feature: projectFlag.id, + feature_state_value: environmentFlag.value || null, + }, + ) + .then(() => { + this.setState({ + isLoading: false, + selectedIdentity: null, + }) + this.userOverridesPage(1) + }) + .catch((e) => { + this.setState({ error: e, isLoading: false }) + }) + } else { + this.setState({ + isLoading: false, + selectedIdentity: null, + }) + } + } + + fetchChangeRequests = (forceRefetch) => { + const { environmentId, projectFlag } = this.props + if (!projectFlag?.id) return + + getChangeRequests( + getStore(), + { + committed: false, + environmentId, + feature_id: projectFlag?.id, + }, + { forceRefetch }, + ).then((res) => { + this.setState({ changeRequests: res.data?.results }) + }) + } + + fetchScheduledChangeRequests = (forceRefetch) => { + const { environmentId, projectFlag } = this.props + if (!projectFlag?.id) return + + const date = moment().toISOString() + + getChangeRequests( + getStore(), + { + environmentId, + feature_id: projectFlag.id, + live_from_after: date, + }, + { forceRefetch }, + ).then((res) => { + this.setState({ scheduledChangeRequests: res.data?.results }) + }) + } + + render() { + const { + enabledIndentity, + enabledSegment, + environmentFlag, + featureContentType, + githubId, + hasIntegrationWithGithub, + isEdit, + projectFlag, + } = this.state + const { identity, identityName } = this.props + const Provider = identity ? IdentityProvider : FeatureListProvider + const environment = ProjectStore.getEnvironment(this.props.environmentId) + const isVersioned = !!environment?.use_v2_feature_versioning + const is4Eyes = + !!environment && + Utils.changeRequestsEnabled(environment.minimum_change_request_approvals) + const project = ProjectStore.model + const caseSensitive = project?.only_allow_lower_case_feature_names + const regex = project?.feature_name_regex + const controlValue = Utils.calculateControl( + projectFlag.multivariate_options, + ) + const invalid = + !!projectFlag.multivariate_options && + projectFlag.multivariate_options.length && + controlValue < 0 + const existingChangeRequest = this.props.changeRequest + const isVersionedChangeRequest = existingChangeRequest && isVersioned + const hideIdentityOverridesTab = Utils.getShouldHideIdentityOverridesTab() + const noPermissions = this.props.noPermissions + let regexValid = true + + const hasCodeReferences = projectFlag?.code_references_counts?.length > 0 + + try { + if (!isEdit && projectFlag.name && regex) { + regexValid = projectFlag.name.match(new RegExp(regex)) + } + } catch (e) { + regexValid = false + } + + return ( + + {({ project }) => ( + { + if (identity) { + this.close() + } + AppActions.refreshFeatures( + this.props.projectId, + this.props.environmentId, + ) + + if (is4Eyes && !identity) { + this.fetchChangeRequests(true) + this.fetchScheduledChangeRequests(true) + } + + if (this.props.changeRequest) { + this.close() + } + }} + > + {( + { error, isSaving }, + { + createChangeRequest, + createFlag, + editFeatureSegments, + editFeatureSettings, + editFeatureValue, + }, + ) => { + const saveFeatureValue = saveFeatureWithValidation((schedule) => { + if ((is4Eyes || schedule) && !identity) { + this.setState({ segmentsChanged: false, valueChanged: false }) + // Until this page and feature-list-store are refactored, this is the best way of parsing feature states + const featureStates = (this.props.segmentOverrides || []) + .filter((override) => !override.toRemove) + .map((override) => { + return { + enabled: override.enabled, + feature: override.feature, + feature_segment: { + environment: override.environment, + id: override.id, + is_feature_specific: override.is_feature_specific, + priority: override.priority, + segment: override.segment, + segment_name: override.segment_name, + uuid: override.uuid, + }, + feature_state_value: Utils.valueToFeatureState( + override.value, + ), + id: override.id, + multivariate_feature_state_values: + override.multivariate_options, + } + }) + .concat([ + Object.assign({}, this.props.environmentFlag, { + enabled: environmentFlag.enabled, + feature_state_value: Utils.valueToFeatureState( + environmentFlag.feature_state_value, + ), + multivariate_feature_state_values: + 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' + } + + let modalTitle = 'New Change Request' + if (schedule) { + modalTitle = 'New Scheduled Flag Update' + } else if (this.props.changeRequest) { + modalTitle = 'Update Change Request' + } + + openModal2( + getModalTitle(), + { + closeModal2() + this.save( + ( + projectId, + environmentId, + flag, + projectFlag, + environmentFlag, + segmentOverrides, + ) => { + createChangeRequest( + projectId, + environmentId, + flag, + projectFlag, + environmentFlag, + segmentOverrides, + { + approvals, + description, + featureStateId: + this.props.changeRequest && + this.props.changeRequest.feature_states?.[0] + ?.id, + id: + this.props.changeRequest && + this.props.changeRequest.id, + ignore_conflicts, + live_from, + multivariate_options: flag.multivariate_options, + title, + }, + !is4Eyes, + ) + }, + ) + }} + />, + ) + } else { + this.setState({ valueChanged: false }) + this.save(editFeatureValue, isSaving) + } + }) + + const saveSettings = () => { + this.setState({ settingsChanged: false }) + this.save(editFeatureSettings, isSaving) + } + + const saveFeatureSegments = saveFeatureWithValidation( + (schedule) => { + this.setState({ segmentsChanged: false }) + + if ((is4Eyes || schedule) && isVersioned && !identity) { + return saveFeatureValue(schedule) + } else { + this.save(editFeatureSegments, isSaving) + } + }, + ) + + const onCreateFeature = saveFeatureWithValidation(() => { + this.save(createFlag, isSaving) + }) + const isLimitReached = false + + const { featureError, featureWarning } = this.parseError(error) + + return ( + + {({ permission: createFeature }) => ( + + {({ permission: projectAdmin }) => { + this.state.skipSaveProjectFeature = !createFeature + + return ( +
+ {isEdit && !identity ? ( + <> + + this.forceUpdate()} + urlParam='tab' + history={this.props.history} + overflowX + > + + Value{' '} + {this.state.valueChanged && ( +
+ {'*'} +
+ )} + + } + > + { + this.setState({ + environmentFlag: { + ...this.state.environmentFlag, + ...changes, + }, + valueChanged: true, + }) + }} + onProjectFlagChange={(changes) => { + this.setState({ + projectFlag: { + ...this.state.projectFlag, + ...changes, + }, + }) + }} + onRemoveMultivariateOption={ + this.props.removeMultivariateOption + } + /> + + + +
+ {(!existingChangeRequest || + isVersionedChangeRequest) && ( + + Segment Overrides{' '} + {this.state.segmentsChanged && ( +
+ * +
+ )} + + } + > + + ( + <> +
+ Segment Overrides{' '} +
+ + This feature is in{' '} + + { + matchingReleasePipeline?.name + } + {' '} + release pipeline and no segment + overrides can be created + + + )} + > +
+ +
+ + Segment Overrides{' '} + + + } + place='top' + > + { + 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'}{' '} + + { + _.find( + project.environments, + { + api_key: + this.props + .environmentId, + }, + ).name + } + +

+
+ + {({ + permission: + savePermission, + }) => ( + + {({ + permission: + manageSegmentsOverrides, + }) => { + const getButtonText = + () => { + if (isSaving) { + return existingChangeRequest + ? 'Updating Change Request' + : 'Creating Change Request' + } + return existingChangeRequest + ? 'Update Change Request' + : 'Create Change Request' + } + + const getScheduleButtonText = + () => { + if (isSaving) { + return existingChangeRequest + ? 'Updating Change Request' + : 'Scheduling Update' + } + return existingChangeRequest + ? 'Update Change Request' + : 'Schedule Update' + } + + if ( + isVersioned && + is4Eyes + ) { + return Utils.renderWithPermission( + savePermission, + Utils.getManageFeaturePermissionDescription( + is4Eyes, + identity, + ), + , + ) + } + + return Utils.renderWithPermission( + manageSegmentsOverrides, + Constants.environmentPermissions( + EnvironmentPermission.MANAGE_SEGMENT_OVERRIDES, + ), + <> + {!is4Eyes && + isVersioned && ( + <> + + + )} + + , + ) + }} + + )} + +
+
+ )} +
+
+
+
+ )} + + {({ permission: viewIdentities }) => + !existingChangeRequest && + !hideIdentityOverridesTab && ( + + {viewIdentities ? ( + <> + + + + Identity Overrides{' '} + + + } + place='top' + > + { + Constants.strings + .IDENTITY_OVERRIDES_DESCRIPTION + } + +
+ + Identity overrides + override feature + values for individual + identities. The + overrides take + priority over an + segment overrides and + environment defaults. + Identity overrides + will only apply when + you identify via the + SDK.{' '} + + Check the Docs for + more details + + . + +
+ + } + action={ + !Utils.getIsEdge() && ( + + ) + } + items={ + this.state.userOverrides + } + paging={ + this.state + .userOverridesPaging + } + renderSearchWithNoResults + nextPage={() => + this.userOverridesPage( + this.state + .userOverridesPaging + .currentPage + 1, + ) + } + prevPage={() => + this.userOverridesPage( + this.state + .userOverridesPaging + .currentPage - 1, + ) + } + goToPage={(page) => + this.userOverridesPage(page) + } + searchPanel={ + !Utils.getIsEdge() && ( +
+ + + v.identity?.id, + )} + environmentId={ + this.props + .environmentId + } + data-test='select-identity' + placeholder='Create an Identity Override...' + value={ + this.state + .selectedIdentity + } + onChange={( + selectedIdentity, + ) => + this.setState( + { + selectedIdentity, + }, + this.addItem, + ) + } + /> + +
+ ) + } + renderRow={(identityFlag) => { + const { + enabled, + feature_state_value, + id, + identity, + } = identityFlag + return ( + + +
+ + this.toggleUserFlag( + { + enabled, + id, + identity, + }, + ) + } + disabled={Utils.getIsEdge()} + /> +
+
+ { + identity.identifier + } +
+
+ +
+ {feature_state_value !== + null && ( + + )} +
+
+ + +
+
+
+ ) + }} + renderNoResults={this.renderUserOverridesNoResults()} + isLoading={ + !this.state.userOverrides + } + /> +
+ + ) : ( + +
+ + )} + + ) + } + + {(!Project.disableAnalytics || + hasCodeReferences) && ( + + Usage + + } + > + {!Project.disableAnalytics && ( +
+ +
+ )} + {hasCodeReferences && ( + +
+
+ Code references +
+ + New + +
+
+ Code references allow you to track + where feature flags are being used + within your code.{' '} + { + flagsmith.trackEvent( + 'code_references_click_docs', + { + feature_id: projectFlag.id, + }, + ) + }} + > + Learn more + +
+ +
+ )} +
+ )} + { + + + + } + {hasIntegrationWithGithub && + projectFlag?.id && ( + + Links + + } + > + + + )} + {!existingChangeRequest && + this.props.flagId && + isVersioned && ( + + + + )} + {!existingChangeRequest && ( + + Settings{' '} + {this.state.settingsChanged && ( +
+ {'*'} +
+ )} + + } + > + { + 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, + }) + } + /> + + + {isEdit && ( +
+ {!!createFeature && ( + <> +

+ This will save the above + settings{' '} + + all environments + + . +

+ + + )} +
+ )} +
+ )} + + + ) : ( +
+ + 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 && ( +
+ {identity ? ( +
+

+ This will update the feature value for the + user {identityName} in + + {' '} + { + _.find(project.environments, { + api_key: this.props.environmentId, + }).name + } + . + + { + ' Any segment overrides for this feature will now be ignored.' + } +

+
+ ) : ( + '' + )} + +
+ {identity && ( + + {({ permission: savePermission }) => + Utils.renderWithPermission( + savePermission, + EnvironmentPermission.UPDATE_FEATURE_STATE, +
+ +
, + ) + } +
+ )} +
+
+ )} +
+ ) + }} +
+ )} + + ) + }} + + )} + + ) + } +} + +Index.propTypes = {} + +//This will remount the modal when a feature is created +const FeatureProvider = (WrappedComponent) => { + class HOC extends Component { + constructor(props) { + super(props) + this.state = { + ...props, + } + ES6Component(this) + } + + componentDidMount() { + // toast update feature + ES6Component(this) + this.listenTo( + FeatureListStore, + 'saved', + ({ + changeRequest, + createdFlag, + error, + isCreate, + updatedChangeRequest, + } = {}) => { + if (error?.data?.metadata) { + error.data.metadata?.forEach((m) => { + if (Object.keys(m).length > 0) { + toast(m.non_field_errors[0], 'danger') + } + }) + } else if (error?.data) { + toast('Error updating the Flag', 'danger') + return + } else { + const isEditingChangeRequest = + this.props.changeRequest && changeRequest + const operation = createdFlag || isCreate ? 'Created' : 'Updated' + const type = changeRequest ? 'Change Request' : 'Feature' + + const toastText = isEditingChangeRequest + ? `Updated ${type}` + : `${operation} ${type}` + const toastAction = changeRequest + ? { + buttonText: 'Open', + onClick: () => { + closeModal() + this.props.history.push( + `/project/${this.props.projectId}/environment/${this.props.environmentId}/change-requests/${updatedChangeRequest?.id}`, + ) + }, + } + : undefined + + toast(toastText, 'success', undefined, toastAction) + } + const envFlags = FeatureListStore.getEnvironmentFlags() + + if (createdFlag) { + const projectFlag = FeatureListStore.getProjectFlags()?.find?.( + (flag) => flag.name === createdFlag, + ) + window.history.replaceState( + {}, + `${document.location.pathname}?feature=${projectFlag.id}`, + ) + const newEnvironmentFlag = envFlags?.[projectFlag.id] || {} + setModalTitle(`Edit Feature ${projectFlag.name}`) + this.setState({ + environmentFlag: { + ...this.state.environmentFlag, + ...(newEnvironmentFlag || {}), + }, + projectFlag, + segmentsChanged: false, + settingsChanged: false, + valueChanged: false, + }) + } else if (this.props.projectFlag) { + //update the environmentFlag and projectFlag to the new values + const newEnvironmentFlag = + envFlags?.[this.props.projectFlag.id] || {} + const newProjectFlag = FeatureListStore.getProjectFlags()?.find?.( + (flag) => flag.id === this.props.projectFlag.id, + ) + this.setState({ + environmentFlag: { + ...this.state.environmentFlag, + ...(newEnvironmentFlag || {}), + }, + projectFlag: newProjectFlag, + segmentsChanged: false, + settingsChanged: false, + valueChanged: false, + }) + } + }, + ) + } + + render() { + return ( + + ) + } + } + return HOC +} + +const WrappedCreateFlag = ConfigProvider(withSegmentOverrides(Index)) + +export default FeatureProvider(WrappedCreateFlag) diff --git a/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx b/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx index c59793c75307..f35df112c9b3 100644 --- a/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/UsageTab.tsx @@ -1,4 +1,5 @@ import React, { FC } from 'react' +import flagsmith from '@flagsmith/flagsmith' import Project from 'common/project' import FeatureAnalytics from 'components/feature-page/FeatureNavTab/FeatureAnalytics' import FeatureCodeReferencesContainer from 'components/feature-page/FeatureNavTab/CodeReferences/FeatureCodeReferencesContainer' @@ -48,6 +49,11 @@ const UsageTab: FC = ({ target='_blank' href='https://docs.flagsmith.com/managing-flags/code-references' rel='noreferrer' + onClick={() => { + flagsmith.trackEvent('code_references_click_docs', { + feature_id: featureId, + }) + }} > Learn more diff --git a/frontend/web/components/navigation/navbars/ProjectNavbar.tsx b/frontend/web/components/navigation/navbars/ProjectNavbar.tsx index 19a96788b2c1..e2666ff3355a 100644 --- a/frontend/web/components/navigation/navbars/ProjectNavbar.tsx +++ b/frontend/web/components/navigation/navbars/ProjectNavbar.tsx @@ -51,18 +51,18 @@ const ProjectNavbar: FC = ({ environmentId, projectId }) => { > Segments - {Utils.getFlagsmithHasFeature('feature_lifecycle') && ( - } - id='lifecycle-link' - to={`/project/${projectId}/lifecycle`} - isActive={(_, location) => - location.pathname.startsWith(`/project/${projectId}/lifecycle`) - } - > - Feature Lifecycle - - )} + {Utils.getFlagsmithHasFeature('feature_lifecycle') && ( + } + id='lifecycle-link' + to={`/project/${projectId}/lifecycle`} + isActive={(_, location) => + location.pathname.startsWith(`/project/${projectId}/lifecycle`) + } + > + Feature Lifecycle + + )}