diff --git a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png index d4389462f0b58..248d1f34b318a 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--funnel-top-to-bottom-edit--dark.png differ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 81587232b3b07..29b8ab34a832d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -54,6 +54,7 @@ import { ExternalDataSourceSyncSchema, ExternalDataSourceType, FeatureFlagAssociatedRoleType, + FeatureFlagStatusResponse, FeatureFlagType, Group, GroupListParams, @@ -663,6 +664,13 @@ class ApiRequest { ) } + public featureFlagStatus(teamId: TeamType['id'], featureFlagId: FeatureFlagType['id']): ApiRequest { + return this.projectsDetail(teamId) + .addPathComponent('feature_flags') + .addPathComponent(String(featureFlagId)) + .addPathComponent('status') + } + public featureFlagCreateScheduledChange(teamId: TeamType['id']): ApiRequest { return this.projectsDetail(teamId).addPathComponent('scheduled_changes') } @@ -1042,6 +1050,12 @@ const api = { ): Promise<{ scheduled_change: ScheduledChangeType }> { return await new ApiRequest().featureFlagDeleteScheduledChange(teamId, scheduledChangeId).delete() }, + async getStatus( + teamId: TeamType['id'], + featureFlagId: FeatureFlagType['id'] + ): Promise { + return await new ApiRequest().featureFlagStatus(teamId, featureFlagId).get() + }, }, organizationFeatureFlags: { diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.tsx b/frontend/src/scenes/feature-flags/FeatureFlag.tsx index f673e27c4b401..51c01ee231fce 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlag.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlag.tsx @@ -70,6 +70,7 @@ import FeatureFlagProjects from './FeatureFlagProjects' import { FeatureFlagReleaseConditions } from './FeatureFlagReleaseConditions' import FeatureFlagSchedule from './FeatureFlagSchedule' import { featureFlagsLogic, FeatureFlagsTab } from './featureFlagsLogic' +import { FeatureFlagStatusIndicator } from './FeatureFlagStatusIndicator' import { RecentFeatureFlagInsights } from './RecentFeatureFlagInsightsCard' export const scene: SceneExport = { @@ -734,6 +735,7 @@ function FeatureFlagRollout({ readOnly }: { readOnly?: boolean }): JSX.Element { aggregationTargetName, featureFlag, recordingFilterForFlag, + flagStatus, } = useValues(featureFlagLogic) const { distributeVariantsEqually, @@ -788,38 +790,41 @@ function FeatureFlagRollout({ readOnly }: { readOnly?: boolean }): JSX.Element { Deleted ) : ( - { - LemonDialog.open({ - title: `${newValue === true ? 'Enable' : 'Disable'} this flag?`, - description: `This flag will be immediately ${ - newValue === true ? 'rolled out to' : 'rolled back from' - } the users matching the release conditions.`, - primaryButton: { - children: 'Confirm', - type: 'primary', - onClick: () => { - const updatedFlag = { ...featureFlag, active: newValue } - setFeatureFlag(updatedFlag) - saveFeatureFlag(updatedFlag) +
+ { + LemonDialog.open({ + title: `${newValue === true ? 'Enable' : 'Disable'} this flag?`, + description: `This flag will be immediately ${ + newValue === true ? 'rolled out to' : 'rolled back from' + } the users matching the release conditions.`, + primaryButton: { + children: 'Confirm', + type: 'primary', + onClick: () => { + const updatedFlag = { ...featureFlag, active: newValue } + setFeatureFlag(updatedFlag) + saveFeatureFlag(updatedFlag) + }, + size: 'small', }, - size: 'small', - }, - secondaryButton: { - children: 'Cancel', - type: 'tertiary', - size: 'small', - }, - }) - }} - label="Enabled" - disabledReason={ - !featureFlag.can_edit - ? "You only have view access to this feature flag. To make changes, contact the flag's creator." - : null - } - checked={featureFlag.active} - /> + secondaryButton: { + children: 'Cancel', + type: 'tertiary', + size: 'small', + }, + }) + }} + label="Enabled" + disabledReason={ + !featureFlag.can_edit + ? "You only have view access to this feature flag. To make changes, contact the flag's creator." + : null + } + checked={featureFlag.active} + /> + +
)}
diff --git a/frontend/src/scenes/feature-flags/FeatureFlagStatusIndicator.tsx b/frontend/src/scenes/feature-flags/FeatureFlagStatusIndicator.tsx new file mode 100644 index 0000000000000..ecaee93c121e3 --- /dev/null +++ b/frontend/src/scenes/feature-flags/FeatureFlagStatusIndicator.tsx @@ -0,0 +1,40 @@ +import { LemonTag } from 'lib/lemon-ui/LemonTag' +import { Tooltip } from 'lib/lemon-ui/Tooltip' + +import { FeatureFlagStatus, FeatureFlagStatusResponse } from '~/types' + +export function FeatureFlagStatusIndicator({ + flagStatus, +}: { + flagStatus: FeatureFlagStatusResponse | null +}): JSX.Element | null { + if ( + !flagStatus || + [FeatureFlagStatus.ACTIVE, FeatureFlagStatus.DELETED, FeatureFlagStatus.UNKNOWN].includes(flagStatus.status) + ) { + return null + } + + return ( + +
{flagStatus.reason}
+
+ {flagStatus.status === FeatureFlagStatus.STALE && + 'Make sure to remove any references to this flag in your code before deleting it.'} + {flagStatus.status === FeatureFlagStatus.INACTIVE && + 'It is probably not being used in your code, but be sure to remove any references to this flag before deleting it.'} +
+ + } + placement="right" + > + + + {flagStatus.status} + + +
+ ) +} diff --git a/frontend/src/scenes/feature-flags/FeatureFlags.stories.tsx b/frontend/src/scenes/feature-flags/FeatureFlags.stories.tsx index acf32b9788ed5..b929e2d203f19 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlags.stories.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlags.stories.tsx @@ -9,6 +9,7 @@ import { mswDecorator } from '~/mocks/browser' import featureFlags from './__mocks__/feature_flags.json' const meta: Meta = { + tags: ['ff'], title: 'Scenes-App/Feature Flags', parameters: { layout: 'fullscreen', @@ -33,6 +34,13 @@ const meta: Meta = { 200, featureFlags.results.find((r) => r.id === Number(req.params['flagId'])), ], + '/api/projects/:team_id/feature_flags/:flagId/status': () => [ + 200, + { + status: 'active', + reason: 'Feature flag is active', + }, + ], }, post: { '/api/environments/:team_id/query': {}, diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts index 3b4f69787fc40..978348e795149 100644 --- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts @@ -33,6 +33,7 @@ import { EarlyAccessFeatureType, FeatureFlagGroupType, FeatureFlagRollbackConditions, + FeatureFlagStatusResponse, FeatureFlagType, FilterLogicalOperator, FilterType, @@ -755,6 +756,18 @@ export const featureFlagLogic = kea([ } }, }, + flagStatus: [ + null as FeatureFlagStatusResponse | null, + { + loadFeatureFlagStatus: () => { + const { currentTeamId } = values + if (currentTeamId && props.id && props.id !== 'new' && props.id !== 'link') { + return api.featureFlags.getStatus(currentTeamId, props.id) + } + return null + }, + }, + ], })), listeners(({ actions, values, props }) => ({ submitNewDashboardSuccessWithResult: async ({ result }) => { @@ -1040,8 +1053,10 @@ export const featureFlagLogic = kea([ actions.setFeatureFlag(formatPayloadsWithFlag) actions.loadRelatedInsights() actions.loadAllInsightsForFlag() + actions.loadFeatureFlagStatus() } else if (props.id !== 'new') { actions.loadFeatureFlag() + actions.loadFeatureFlagStatus() } }), ]) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d98d05d8d61b5..587e2585daec4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2994,6 +2994,19 @@ export interface FeatureFlagRollbackConditions { operator?: string } +export enum FeatureFlagStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + STALE = 'stale', + DELETED = 'deleted', + UNKNOWN = 'unknown', +} + +export interface FeatureFlagStatusResponse { + status: FeatureFlagStatus + reason: string +} + export interface CombinedFeatureFlagAndValueType { feature_flag: FeatureFlagType value: boolean | string diff --git a/posthog/api/test/test_feature_flag.py b/posthog/api/test/test_feature_flag.py index 86280d76fd5fa..9fff24d2f0f61 100644 --- a/posthog/api/test/test_feature_flag.py +++ b/posthog/api/test/test_feature_flag.py @@ -6287,6 +6287,30 @@ def test_flag_status_reasons(self): FeatureFlagStatus.ACTIVE, ) + # Request status for multivariate flag with a variant set to 100% but no release condition set to 100% + multivariate_flag_rolled_out_release_condition_half_variant = FeatureFlag.objects.create( + name="Multivariate flag with release condition set to 100%, but variants still 50%", + key="multivariate-rolled-out-release-half-variant-flag", + team=self.team, + active=True, + filters={ + "multivariate": { + "variants": [ + {"key": "var1key", "name": "test", "rollout_percentage": 50}, + {"key": "var2key", "name": "control", "rollout_percentage": 50}, + ], + }, + "groups": [ + {"variant": None, "properties": [], "rollout_percentage": 100}, + ], + }, + ) + self.create_feature_flag_called_event(multivariate_flag_rolled_out_release_condition_half_variant.key) + self.assert_expected_response( + multivariate_flag_rolled_out_release_condition_half_variant.id, + FeatureFlagStatus.ACTIVE, + ) + # Request status for multivariate flag with variants set to 100% and a filtered release condition multivariate_flag_rolled_out_variant_rolled_out_filtered_release = FeatureFlag.objects.create( name="Multivariate flag with variant and release condition set to 100%", diff --git a/posthog/models/feature_flag/flag_status.py b/posthog/models/feature_flag/flag_status.py index ab236bd9fcee9..fa7ad52929304 100644 --- a/posthog/models/feature_flag/flag_status.py +++ b/posthog/models/feature_flag/flag_status.py @@ -85,7 +85,7 @@ def is_flag_fully_rolled_out(self, flag: FeatureFlag) -> tuple[bool, FeatureFlag ) if multivariate and is_multivariate_flag_fully_rolled_out: return True, f'This flag will always use the variant "{fully_rolled_out_variant_name}"' - elif self.is_boolean_flag_fully_rolled_out(flag): + elif not multivariate and self.is_boolean_flag_fully_rolled_out(flag): return True, 'This boolean flag will always evaluate to "true"' return False, ""