diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/FeatureDetails.tsx b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/FeatureDetails.tsx index d34fc6ee37..1f37b37eed 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/FeatureDetails.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/FeatureDetails.tsx @@ -60,6 +60,17 @@ export const FeatureDetails = ({ theme.palette.success.main, ]; + if ( + feature.hasUnsatisfiedDependency && + !feature.isEnabledInCurrentEnvironment + ) { + return [ + `This feature toggle is False in ${input?.environment} because `, + 'parent dependency is not satisfied and the environment is disabled', + theme.palette.error.main, + ]; + } + if (!feature.isEnabledInCurrentEnvironment) return [ `This feature toggle is False in ${input?.environment} because `, @@ -81,6 +92,14 @@ export const FeatureDetails = ({ theme.palette.warning.main, ]; + if (feature.hasUnsatisfiedDependency) { + return [ + `This feature toggle is False in ${input?.environment} because `, + 'parent dependency is not satisfied', + theme.palette.error.main, + ]; + } + return [ `This feature toggle is False in ${input?.environment} because `, 'all strategies are either False or could not be fully evaluated', diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/helpers.ts b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/helpers.ts index 2c35d56f0a..c191887cae 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/helpers.ts +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureDetails/helpers.ts @@ -27,7 +27,10 @@ export const hasCustomStrategies = (feature: PlaygroundFeatureSchema) => { }; export const hasOnlyCustomStrategies = (feature: PlaygroundFeatureSchema) => { - return !feature.strategies?.data?.find((strategy) => - DEFAULT_STRATEGIES.includes(strategy.name), + return ( + feature.strategies?.data?.length > 0 && + !feature.strategies?.data?.find((strategy) => + DEFAULT_STRATEGIES.includes(strategy.name), + ) ); }; diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/PlaygroundResultFeatureStrategyList.tsx b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/PlaygroundResultFeatureStrategyList.tsx index 682a4ce605..c15efe19a9 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/PlaygroundResultFeatureStrategyList.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/PlaygroundResultFeatureStrategyList.tsx @@ -28,12 +28,13 @@ export const PlaygroundResultFeatureStrategyList = ({ /> } diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/StrategyList/playgroundResultStrategyLists.tsx b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/StrategyList/playgroundResultStrategyLists.tsx index a63708b25d..308a7d8beb 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/StrategyList/playgroundResultStrategyLists.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureStrategyList/StrategyList/playgroundResultStrategyLists.tsx @@ -3,7 +3,7 @@ import { Alert, Box, styled, Typography } from '@mui/material'; import { PlaygroundStrategySchema, PlaygroundRequestSchema, - PlaygroundFeatureSchemaStrategies, + PlaygroundFeatureSchema, } from 'openapi'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem'; @@ -67,24 +67,40 @@ export const PlaygroundResultStrategyLists = ({ ); interface IWrappedPlaygroundResultStrategyListProps { - strategies: PlaygroundFeatureSchemaStrategies; + feature: PlaygroundFeatureSchema; input?: PlaygroundRequestSchema; } +const resolveHintText = (feature: PlaygroundFeatureSchema) => { + if ( + feature.hasUnsatisfiedDependency && + !feature.isEnabledInCurrentEnvironment + ) { + return 'If environment was enabled and parent dependencies were satisfied'; + } + if (feature.hasUnsatisfiedDependency) { + return 'If parent dependencies were satisfied'; + } + if (!feature.isEnabledInCurrentEnvironment) { + return 'If environment was enabled'; + } + return ''; +}; + export const WrappedPlaygroundResultStrategyList = ({ - strategies, + feature, input, }: IWrappedPlaygroundResultStrategyListProps) => { return ( - If environment was enabled, then this feature toggle would be{' '} - {strategies?.result ? 'TRUE' : 'FALSE'} with strategies + {resolveHintText(feature)}, then this feature toggle would be{' '} + {feature.strategies?.result ? 'TRUE' : 'FALSE'} with strategies evaluated like so:{' '} diff --git a/frontend/src/openapi/models/playgroundFeatureSchema.ts b/frontend/src/openapi/models/playgroundFeatureSchema.ts index 86cb3b084b..2e85992953 100644 --- a/frontend/src/openapi/models/playgroundFeatureSchema.ts +++ b/frontend/src/openapi/models/playgroundFeatureSchema.ts @@ -19,6 +19,7 @@ export interface PlaygroundFeatureSchema { strategies: PlaygroundFeatureSchemaStrategies; /** Whether the feature is active and would be evaluated in the provided environment in a normal SDK context. */ isEnabledInCurrentEnvironment: boolean; + hasUnsatisfiedDependency?: boolean; /** Whether this feature is enabled or not in the current environment. If a feature can't be fully evaluated (that is, `strategies.result` is `unknown`), this will be `false` to align with how client SDKs treat unresolved feature states. */ diff --git a/src/lib/features/dependent-features/dependent-features-store-type.ts b/src/lib/features/dependent-features/dependent-features-store-type.ts index 7ea3a5a0f1..007bfd5ce2 100644 --- a/src/lib/features/dependent-features/dependent-features-store-type.ts +++ b/src/lib/features/dependent-features/dependent-features-store-type.ts @@ -3,5 +3,5 @@ import { FeatureDependency, FeatureDependencyId } from './dependent-features'; export interface IDependentFeaturesStore { upsert(featureDependency: FeatureDependency): Promise; delete(dependency: FeatureDependencyId): Promise; - deleteAll(child: string): Promise; + deleteAll(child?: string): Promise; } diff --git a/src/lib/features/dependent-features/dependent-features-store.ts b/src/lib/features/dependent-features/dependent-features-store.ts index a0ccd3c085..32ddb8ffc2 100644 --- a/src/lib/features/dependent-features/dependent-features-store.ts +++ b/src/lib/features/dependent-features/dependent-features-store.ts @@ -41,7 +41,13 @@ export class DependentFeaturesStore implements IDependentFeaturesStore { .del(); } - async deleteAll(feature: string): Promise { - await this.db('dependent_features').andWhere('child', feature).del(); + async deleteAll(feature: string | undefined): Promise { + if (feature) { + await this.db('dependent_features') + .andWhere('child', feature) + .del(); + } else { + await this.db('dependent_features').del(); + } } } diff --git a/src/lib/features/playground/advanced-playground.test.ts b/src/lib/features/playground/advanced-playground.test.ts index c2e6c8c52f..0eb9b99752 100644 --- a/src/lib/features/playground/advanced-playground.test.ts +++ b/src/lib/features/playground/advanced-playground.test.ts @@ -10,7 +10,9 @@ let app: IUnleashTest; let db: ITestDb; beforeAll(async () => { - db = await dbInit('advanced_playground', getLogger); + db = await dbInit('advanced_playground', getLogger, { + experimental: { flags: { dependentFeatures: true } }, + }); app = await setupAppWithCustomConfig( db.stores, { @@ -20,6 +22,7 @@ beforeAll(async () => { strictSchemaValidation: true, strategyVariant: true, privateProjects: true, + dependentFeatures: true, }, }, }, @@ -67,6 +70,7 @@ afterAll(async () => { }); afterEach(async () => { + await db.stores.dependentFeaturesStore.deleteAll(); await db.stores.featureToggleStore.deleteAll(); }); @@ -95,6 +99,36 @@ test('advanced playground evaluation with no toggles', async () => { }); }); +test('advanced playground evaluation with parent dependency', async () => { + await createFeatureToggle('test-parent'); + await createFeatureToggle('test-child'); + await enableToggle('test-child'); + await app.addDependency('test-child', 'test-parent'); + + const { body: result } = await app.request + .post('/api/admin/playground/advanced') + .send({ + environments: ['default'], + projects: ['default'], + context: { appName: 'test' }, + }) + .set('Content-Type', 'application/json') + .expect(200); + + const child = result.features[0].environments.default[0]; + const parent = result.features[1].environments.default[0]; + // child is disabled because of the parent + expect(child.hasUnsatisfiedDependency).toBe(true); + expect(child.isEnabled).toBe(false); + expect(child.isEnabledInCurrentEnvironment).toBe(true); + expect(child.variant).toEqual({ + name: 'disabled', + enabled: false, + }); + expect(parent.hasUnsatisfiedDependency).toBe(false); + expect(parent.isEnabled).toBe(false); +}); + test('advanced playground evaluation happy path', async () => { await createFeatureToggleWithStrategy('test-playground-feature'); await enableToggle('test-playground-feature'); @@ -128,6 +162,7 @@ test('advanced playground evaluation happy path', async () => { { isEnabled: true, isEnabledInCurrentEnvironment: true, + hasUnsatisfiedDependency: false, strategies: { result: true, data: [ @@ -161,6 +196,7 @@ test('advanced playground evaluation happy path', async () => { { isEnabled: true, isEnabledInCurrentEnvironment: true, + hasUnsatisfiedDependency: false, strategies: { result: true, data: [ @@ -194,6 +230,7 @@ test('advanced playground evaluation happy path', async () => { { isEnabled: true, isEnabledInCurrentEnvironment: true, + hasUnsatisfiedDependency: false, strategies: { result: true, data: [ @@ -227,6 +264,7 @@ test('advanced playground evaluation happy path', async () => { { isEnabled: true, isEnabledInCurrentEnvironment: true, + hasUnsatisfiedDependency: false, strategies: { result: true, data: [ diff --git a/src/lib/features/playground/feature-evaluator/client.ts b/src/lib/features/playground/feature-evaluator/client.ts index 0a621f3614..6735095e5c 100644 --- a/src/lib/features/playground/feature-evaluator/client.ts +++ b/src/lib/features/playground/feature-evaluator/client.ts @@ -27,6 +27,7 @@ export type FeatureStrategiesEvaluationResult = { variant?: Variant; variants?: VariantDefinition[]; strategies: EvaluatedPlaygroundStrategy[]; + hasUnsatisfiedDependency?: boolean; }; export default class UnleashClient { @@ -57,13 +58,66 @@ export default class UnleashClient { ); } + isParentDependencySatisfied( + feature: FeatureInterface | undefined, + context: Context, + ) { + if (!feature?.dependencies?.length) { + return true; + } + + return feature.dependencies.every((parent) => { + const parentToggle = this.repository.getToggle(parent.feature); + + if (!parentToggle) { + return false; + } + if (parentToggle.dependencies?.length) { + return false; + } + + if (parent.enabled !== false) { + if (!parentToggle.enabled) { + return false; + } + if (parent.variants?.length) { + return parent.variants.includes( + this.getVariant(parent.feature, context).name, + ); + } + return ( + this.isEnabled(parent.feature, context, () => false) + .result === true + ); + } + + return ( + !parentToggle.enabled && + !( + this.isEnabled(parent.feature, context, () => false) + .result === true + ) + ); + }); + } + isEnabled( name: string, context: Context, fallback: Function, ): FeatureStrategiesEvaluationResult { const feature = this.repository.getToggle(name); - return this.isFeatureEnabled(feature, context, fallback); + + const parentDependencySatisfied = this.isParentDependencySatisfied( + feature, + context, + ); + const result = this.isFeatureEnabled(feature, context, fallback); + + return { + ...result, + hasUnsatisfiedDependency: !parentDependencySatisfied, + }; } isFeatureEnabled( @@ -234,7 +288,10 @@ export default class UnleashClient { const fallback = fallbackVariant || getDefaultVariant(); const feature = this.repository.getToggle(name); - if (typeof feature === 'undefined') { + if ( + typeof feature === 'undefined' || + !this.isParentDependencySatisfied(feature, context) + ) { return fallback; } diff --git a/src/lib/features/playground/feature-evaluator/feature.ts b/src/lib/features/playground/feature-evaluator/feature.ts index 90e0861be4..50fdd81500 100644 --- a/src/lib/features/playground/feature-evaluator/feature.ts +++ b/src/lib/features/playground/feature-evaluator/feature.ts @@ -3,6 +3,12 @@ import { Segment } from './strategy/strategy'; // eslint-disable-next-line import/no-cycle import { VariantDefinition } from './variant'; +export interface Dependency { + feature: string; + variants?: string[]; + enabled?: boolean; +} + export interface FeatureInterface { name: string; type: string; @@ -12,6 +18,7 @@ export interface FeatureInterface { impressionData: boolean; strategies: StrategyTransportInterface[]; variants: VariantDefinition[]; + dependencies?: Dependency[]; } export interface ClientFeaturesResponse { diff --git a/src/lib/features/playground/offline-unleash-client.ts b/src/lib/features/playground/offline-unleash-client.ts index 316e173410..63115fae12 100644 --- a/src/lib/features/playground/offline-unleash-client.ts +++ b/src/lib/features/playground/offline-unleash-client.ts @@ -42,6 +42,7 @@ export const mapFeaturesForClient = ( operator: constraint.operator as unknown as Operator, })), })), + dependencies: feature.dependencies, })); export const mapSegmentsForClient = (segments: ISegment[]): Segment[] => diff --git a/src/lib/features/playground/playground-service.ts b/src/lib/features/playground/playground-service.ts index 4dbb64f42c..8679d2d76c 100644 --- a/src/lib/features/playground/playground-service.ts +++ b/src/lib/features/playground/playground-service.ts @@ -21,6 +21,7 @@ import { AdvancedPlaygroundEnvironmentFeatureSchema } from '../../openapi/spec/a import { validateQueryComplexity } from './validateQueryComplexity'; import { playgroundStrategyEvaluation } from 'lib/openapi'; import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType'; +import { getDefaultVariant } from './feature-evaluator/variant'; type EvaluationInput = { features: FeatureConfigurationClient[]; @@ -198,23 +199,29 @@ export class PlaygroundService { const strategyEvaluationResult: FeatureStrategiesEvaluationResult = client.isEnabled(feature.name, clientContext); + const hasUnsatisfiedDependency = + strategyEvaluationResult.hasUnsatisfiedDependency; const isEnabled = strategyEvaluationResult.result === true && - feature.enabled; + feature.enabled && + !hasUnsatisfiedDependency; return { isEnabled, isEnabledInCurrentEnvironment: feature.enabled, + hasUnsatisfiedDependency, strategies: { result: strategyEvaluationResult.result, data: strategyEvaluationResult.strategies, }, projectId: featureProject[feature.name], - variant: client.forceGetVariant( - feature.name, - strategyEvaluationResult, - clientContext, - ), + variant: isEnabled + ? client.forceGetVariant( + feature.name, + strategyEvaluationResult, + clientContext, + ) + : getDefaultVariant(), name: feature.name, environment, context, diff --git a/src/lib/openapi/spec/playground-feature-schema.ts b/src/lib/openapi/spec/playground-feature-schema.ts index 67579d4fdd..d4bf1eed93 100644 --- a/src/lib/openapi/spec/playground-feature-schema.ts +++ b/src/lib/openapi/spec/playground-feature-schema.ts @@ -67,6 +67,11 @@ export const playgroundFeatureSchema = { }, }, }, + hasUnsatisfiedDependency: { + type: 'boolean', + description: + 'Whether the feature has a parent dependency that is not satisfied', + }, isEnabledInCurrentEnvironment: { type: 'boolean', description: