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: