1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-23 00:16:25 +01: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,
];
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',

View File

@ -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),
)
);
};

View File

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

View File

@ -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 (
<StyledAlertWrapper sx={{ pb: 1, mt: 2 }}>
<StyledAlert severity={'info'} color={'warning'}>
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:{' '}
</StyledAlert>
<StyledListWrapper sx={{ p: 2.5 }}>
<PlaygroundResultStrategyLists
strategies={strategies?.data || []}
strategies={feature.strategies?.data || []}
input={input}
/>
</StyledListWrapper>

View File

@ -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. */

View File

@ -3,5 +3,5 @@ import { FeatureDependency, FeatureDependencyId } from './dependent-features';
export interface IDependentFeaturesStore {
upsert(featureDependency: FeatureDependency): 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();
}
async deleteAll(feature: string): Promise<void> {
await this.db('dependent_features').andWhere('child', feature).del();
async deleteAll(feature: string | undefined): Promise<void> {
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;
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: [

View File

@ -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;
}

View File

@ -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 {

View File

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

View File

@ -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,

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: {
type: 'boolean',
description: