Skip to content

Commit

Permalink
feat: dependent features in playground (#4930)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Oct 5, 2023
1 parent 5d11d5b commit 2c7587b
Show file tree
Hide file tree
Showing 13 changed files with 183 additions and 22 deletions.
Expand Up @@ -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 `,
Expand All @@ -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',
Expand Down
Expand Up @@ -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),
)
);
};
Expand Up @@ -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}
/>
}
Expand Down
Expand Up @@ -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';
Expand Down Expand Up @@ -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>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/openapi/models/playgroundFeatureSchema.ts
Expand Up @@ -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. */
Expand Down
Expand Up @@ -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>;
}
10 changes: 8 additions & 2 deletions src/lib/features/dependent-features/dependent-features-store.ts
Expand Up @@ -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();
}
}
}
40 changes: 39 additions & 1 deletion src/lib/features/playground/advanced-playground.test.ts
Expand Up @@ -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,
{
Expand All @@ -20,6 +22,7 @@ beforeAll(async () => {
strictSchemaValidation: true,
strategyVariant: true,
privateProjects: true,
dependentFeatures: true,
},
},
},
Expand Down Expand Up @@ -67,6 +70,7 @@ afterAll(async () => {
});

afterEach(async () => {
await db.stores.dependentFeaturesStore.deleteAll();
await db.stores.featureToggleStore.deleteAll();
});

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -128,6 +162,7 @@ test('advanced playground evaluation happy path', async () => {
{
isEnabled: true,
isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: {
result: true,
data: [
Expand Down Expand Up @@ -161,6 +196,7 @@ test('advanced playground evaluation happy path', async () => {
{
isEnabled: true,
isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: {
result: true,
data: [
Expand Down Expand Up @@ -194,6 +230,7 @@ test('advanced playground evaluation happy path', async () => {
{
isEnabled: true,
isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: {
result: true,
data: [
Expand Down Expand Up @@ -227,6 +264,7 @@ test('advanced playground evaluation happy path', async () => {
{
isEnabled: true,
isEnabledInCurrentEnvironment: true,
hasUnsatisfiedDependency: false,
strategies: {
result: true,
data: [
Expand Down
61 changes: 59 additions & 2 deletions src/lib/features/playground/feature-evaluator/client.ts
Expand Up @@ -27,6 +27,7 @@ export type FeatureStrategiesEvaluationResult = {
variant?: Variant;
variants?: VariantDefinition[];
strategies: EvaluatedPlaygroundStrategy[];
hasUnsatisfiedDependency?: boolean;
};

export default class UnleashClient {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}

Expand Down
7 changes: 7 additions & 0 deletions src/lib/features/playground/feature-evaluator/feature.ts
Expand Up @@ -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;
Expand All @@ -12,6 +18,7 @@ export interface FeatureInterface {
impressionData: boolean;
strategies: StrategyTransportInterface[];
variants: VariantDefinition[];
dependencies?: Dependency[];
}

export interface ClientFeaturesResponse {
Expand Down
1 change: 1 addition & 0 deletions src/lib/features/playground/offline-unleash-client.ts
Expand Up @@ -42,6 +42,7 @@ export const mapFeaturesForClient = (
operator: constraint.operator as unknown as Operator,
})),
})),
dependencies: feature.dependencies,
}));

export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>
Expand Down

0 comments on commit 2c7587b

Please sign in to comment.