1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-21 13:47:39 +02:00

feat: dependent features in playground (#4930)

This commit is contained in:
Mateusz Kwasniewski 2023-10-05 13:05:20 +02:00 committed by GitHub
parent 5d11d5b0fd
commit 2c7587ba4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 183 additions and 22 deletions

View File

@ -60,6 +60,17 @@ export const FeatureDetails = ({
theme.palette.success.main, 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) if (!feature.isEnabledInCurrentEnvironment)
return [ return [
`This feature toggle is False in ${input?.environment} because `, `This feature toggle is False in ${input?.environment} because `,
@ -81,6 +92,14 @@ export const FeatureDetails = ({
theme.palette.warning.main, 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 [ return [
`This feature toggle is False in ${input?.environment} because `, `This feature toggle is False in ${input?.environment} because `,
'all strategies are either False or could not be fully evaluated', 'all strategies are either False or could not be fully evaluated',

View File

@ -27,7 +27,10 @@ export const hasCustomStrategies = (feature: PlaygroundFeatureSchema) => {
}; };
export const hasOnlyCustomStrategies = (feature: PlaygroundFeatureSchema) => { export const hasOnlyCustomStrategies = (feature: PlaygroundFeatureSchema) => {
return !feature.strategies?.data?.find((strategy) => return (
DEFAULT_STRATEGIES.includes(strategy.name), feature.strategies?.data?.length > 0 &&
!feature.strategies?.data?.find((strategy) =>
DEFAULT_STRATEGIES.includes(strategy.name),
)
); );
}; };

View File

@ -28,12 +28,13 @@ export const PlaygroundResultFeatureStrategyList = ({
/> />
<ConditionallyRender <ConditionallyRender
condition={ condition={
!feature.isEnabledInCurrentEnvironment && (feature.hasUnsatisfiedDependency ||
!feature.isEnabledInCurrentEnvironment) &&
Boolean(feature?.strategies?.data) Boolean(feature?.strategies?.data)
} }
show={ show={
<WrappedPlaygroundResultStrategyList <WrappedPlaygroundResultStrategyList
strategies={feature?.strategies} feature={feature}
input={input} input={input}
/> />
} }

View File

@ -3,7 +3,7 @@ import { Alert, Box, styled, Typography } from '@mui/material';
import { import {
PlaygroundStrategySchema, PlaygroundStrategySchema,
PlaygroundRequestSchema, PlaygroundRequestSchema,
PlaygroundFeatureSchemaStrategies, PlaygroundFeatureSchema,
} from 'openapi'; } from 'openapi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem'; import { FeatureStrategyItem } from './StrategyItem/FeatureStrategyItem';
@ -67,24 +67,40 @@ export const PlaygroundResultStrategyLists = ({
); );
interface IWrappedPlaygroundResultStrategyListProps { interface IWrappedPlaygroundResultStrategyListProps {
strategies: PlaygroundFeatureSchemaStrategies; feature: PlaygroundFeatureSchema;
input?: PlaygroundRequestSchema; 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 = ({ export const WrappedPlaygroundResultStrategyList = ({
strategies, feature,
input, input,
}: IWrappedPlaygroundResultStrategyListProps) => { }: IWrappedPlaygroundResultStrategyListProps) => {
return ( return (
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}> <StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
<StyledAlert severity={'info'} color={'warning'}> <StyledAlert severity={'info'} color={'warning'}>
If environment was enabled, then this feature toggle would be{' '} {resolveHintText(feature)}, then this feature toggle would be{' '}
{strategies?.result ? 'TRUE' : 'FALSE'} with strategies {feature.strategies?.result ? 'TRUE' : 'FALSE'} with strategies
evaluated like so:{' '} evaluated like so:{' '}
</StyledAlert> </StyledAlert>
<StyledListWrapper sx={{ p: 2.5 }}> <StyledListWrapper sx={{ p: 2.5 }}>
<PlaygroundResultStrategyLists <PlaygroundResultStrategyLists
strategies={strategies?.data || []} strategies={feature.strategies?.data || []}
input={input} input={input}
/> />
</StyledListWrapper> </StyledListWrapper>

View File

@ -19,6 +19,7 @@ export interface PlaygroundFeatureSchema {
strategies: PlaygroundFeatureSchemaStrategies; strategies: PlaygroundFeatureSchemaStrategies;
/** Whether the feature is active and would be evaluated in the provided environment in a normal SDK context. */ /** Whether the feature is active and would be evaluated in the provided environment in a normal SDK context. */
isEnabledInCurrentEnvironment: boolean; isEnabledInCurrentEnvironment: boolean;
hasUnsatisfiedDependency?: boolean;
/** Whether this feature is enabled or not in the current environment. /** 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`), 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. */ this will be `false` to align with how client SDKs treat unresolved feature states. */

View File

@ -3,5 +3,5 @@ import { FeatureDependency, FeatureDependencyId } from './dependent-features';
export interface IDependentFeaturesStore { export interface IDependentFeaturesStore {
upsert(featureDependency: FeatureDependency): Promise<void>; upsert(featureDependency: FeatureDependency): Promise<void>;
delete(dependency: FeatureDependencyId): Promise<void>; delete(dependency: FeatureDependencyId): Promise<void>;
deleteAll(child: string): Promise<void>; deleteAll(child?: string): Promise<void>;
} }

View File

@ -41,7 +41,13 @@ export class DependentFeaturesStore implements IDependentFeaturesStore {
.del(); .del();
} }
async deleteAll(feature: string): Promise<void> { async deleteAll(feature: string | undefined): Promise<void> {
await this.db('dependent_features').andWhere('child', feature).del(); if (feature) {
await this.db('dependent_features')
.andWhere('child', feature)
.del();
} else {
await this.db('dependent_features').del();
}
} }
} }

View File

@ -10,7 +10,9 @@ let app: IUnleashTest;
let db: ITestDb; let db: ITestDb;
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('advanced_playground', getLogger); db = await dbInit('advanced_playground', getLogger, {
experimental: { flags: { dependentFeatures: true } },
});
app = await setupAppWithCustomConfig( app = await setupAppWithCustomConfig(
db.stores, db.stores,
{ {
@ -20,6 +22,7 @@ beforeAll(async () => {
strictSchemaValidation: true, strictSchemaValidation: true,
strategyVariant: true, strategyVariant: true,
privateProjects: true, privateProjects: true,
dependentFeatures: true,
}, },
}, },
}, },
@ -67,6 +70,7 @@ afterAll(async () => {
}); });
afterEach(async () => { afterEach(async () => {
await db.stores.dependentFeaturesStore.deleteAll();
await db.stores.featureToggleStore.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 () => { test('advanced playground evaluation happy path', async () => {
await createFeatureToggleWithStrategy('test-playground-feature'); await createFeatureToggleWithStrategy('test-playground-feature');
await enableToggle('test-playground-feature'); await enableToggle('test-playground-feature');
@ -128,6 +162,7 @@ test('advanced playground evaluation happy path', async () => {
{ {
isEnabled: true, isEnabled: true,
isEnabledInCurrentEnvironment: true, isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: { strategies: {
result: true, result: true,
data: [ data: [
@ -161,6 +196,7 @@ test('advanced playground evaluation happy path', async () => {
{ {
isEnabled: true, isEnabled: true,
isEnabledInCurrentEnvironment: true, isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: { strategies: {
result: true, result: true,
data: [ data: [
@ -194,6 +230,7 @@ test('advanced playground evaluation happy path', async () => {
{ {
isEnabled: true, isEnabled: true,
isEnabledInCurrentEnvironment: true, isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: { strategies: {
result: true, result: true,
data: [ data: [
@ -227,6 +264,7 @@ test('advanced playground evaluation happy path', async () => {
{ {
isEnabled: true, isEnabled: true,
isEnabledInCurrentEnvironment: true, isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: { strategies: {
result: true, result: true,
data: [ data: [

View File

@ -27,6 +27,7 @@ export type FeatureStrategiesEvaluationResult = {
variant?: Variant; variant?: Variant;
variants?: VariantDefinition[]; variants?: VariantDefinition[];
strategies: EvaluatedPlaygroundStrategy[]; strategies: EvaluatedPlaygroundStrategy[];
hasUnsatisfiedDependency?: boolean;
}; };
export default class UnleashClient { 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( isEnabled(
name: string, name: string,
context: Context, context: Context,
fallback: Function, fallback: Function,
): FeatureStrategiesEvaluationResult { ): FeatureStrategiesEvaluationResult {
const feature = this.repository.getToggle(name); 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( isFeatureEnabled(
@ -234,7 +288,10 @@ export default class UnleashClient {
const fallback = fallbackVariant || getDefaultVariant(); const fallback = fallbackVariant || getDefaultVariant();
const feature = this.repository.getToggle(name); const feature = this.repository.getToggle(name);
if (typeof feature === 'undefined') { if (
typeof feature === 'undefined' ||
!this.isParentDependencySatisfied(feature, context)
) {
return fallback; return fallback;
} }

View File

@ -3,6 +3,12 @@ import { Segment } from './strategy/strategy';
// eslint-disable-next-line import/no-cycle // eslint-disable-next-line import/no-cycle
import { VariantDefinition } from './variant'; import { VariantDefinition } from './variant';
export interface Dependency {
feature: string;
variants?: string[];
enabled?: boolean;
}
export interface FeatureInterface { export interface FeatureInterface {
name: string; name: string;
type: string; type: string;
@ -12,6 +18,7 @@ export interface FeatureInterface {
impressionData: boolean; impressionData: boolean;
strategies: StrategyTransportInterface[]; strategies: StrategyTransportInterface[];
variants: VariantDefinition[]; variants: VariantDefinition[];
dependencies?: Dependency[];
} }
export interface ClientFeaturesResponse { export interface ClientFeaturesResponse {

View File

@ -42,6 +42,7 @@ export const mapFeaturesForClient = (
operator: constraint.operator as unknown as Operator, operator: constraint.operator as unknown as Operator,
})), })),
})), })),
dependencies: feature.dependencies,
})); }));
export const mapSegmentsForClient = (segments: ISegment[]): Segment[] => export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>

View File

@ -21,6 +21,7 @@ import { AdvancedPlaygroundEnvironmentFeatureSchema } from '../../openapi/spec/a
import { validateQueryComplexity } from './validateQueryComplexity'; import { validateQueryComplexity } from './validateQueryComplexity';
import { playgroundStrategyEvaluation } from 'lib/openapi'; import { playgroundStrategyEvaluation } from 'lib/openapi';
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType'; import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
import { getDefaultVariant } from './feature-evaluator/variant';
type EvaluationInput = { type EvaluationInput = {
features: FeatureConfigurationClient[]; features: FeatureConfigurationClient[];
@ -198,23 +199,29 @@ export class PlaygroundService {
const strategyEvaluationResult: FeatureStrategiesEvaluationResult = const strategyEvaluationResult: FeatureStrategiesEvaluationResult =
client.isEnabled(feature.name, clientContext); client.isEnabled(feature.name, clientContext);
const hasUnsatisfiedDependency =
strategyEvaluationResult.hasUnsatisfiedDependency;
const isEnabled = const isEnabled =
strategyEvaluationResult.result === true && strategyEvaluationResult.result === true &&
feature.enabled; feature.enabled &&
!hasUnsatisfiedDependency;
return { return {
isEnabled, isEnabled,
isEnabledInCurrentEnvironment: feature.enabled, isEnabledInCurrentEnvironment: feature.enabled,
hasUnsatisfiedDependency,
strategies: { strategies: {
result: strategyEvaluationResult.result, result: strategyEvaluationResult.result,
data: strategyEvaluationResult.strategies, data: strategyEvaluationResult.strategies,
}, },
projectId: featureProject[feature.name], projectId: featureProject[feature.name],
variant: client.forceGetVariant( variant: isEnabled
feature.name, ? client.forceGetVariant(
strategyEvaluationResult, feature.name,
clientContext, strategyEvaluationResult,
), clientContext,
)
: getDefaultVariant(),
name: feature.name, name: feature.name,
environment, environment,
context, context,

View File

@ -67,6 +67,11 @@ export const playgroundFeatureSchema = {
}, },
}, },
}, },
hasUnsatisfiedDependency: {
type: 'boolean',
description:
'Whether the feature has a parent dependency that is not satisfied',
},
isEnabledInCurrentEnvironment: { isEnabledInCurrentEnvironment: {
type: 'boolean', type: 'boolean',
description: description: