Skip to content

Commit e6614c0

Browse files
committed
feat(react): add FeatureFlag component
Introduces the FeatureFlag component for React that allow using feature flags in a declarative manner Signed-off-by: Weyert de Boer <[email protected]>
1 parent 60401b6 commit e6614c0

File tree

4 files changed

+202
-0
lines changed

4 files changed

+202
-0
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import React from 'react';
2+
import { useFlag } from '../evaluation';
3+
import type { FlagQuery } from '../query';
4+
5+
/**
6+
* Props for the Feature component that conditionally renders content based on feature flag state.
7+
* @interface FeatureProps
8+
*/
9+
interface FeatureProps {
10+
/**
11+
* The key of the feature flag to evaluate.
12+
*/
13+
featureKey: string;
14+
15+
/**
16+
* Optional value to match against the feature flag value.
17+
* If provided, the component will only render children when the flag value matches this value.
18+
* If a boolean, it will check if the flag is enabled (true) or disabled (false).
19+
* If a string, it will check if the flag variant equals this string.
20+
*/
21+
match?: string | boolean;
22+
23+
/**
24+
* Default value to use when the feature flag is not found.
25+
*/
26+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27+
defaultValue: any;
28+
29+
/**
30+
* Content to render when the feature flag condition is met.
31+
* Can be a React node or a function that receives flag query details and returns a React node.
32+
*/
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
children: React.ReactNode | ((details: FlagQuery<any>) => React.ReactNode);
35+
36+
/**
37+
* Optional content to render when the feature flag condition is not met.
38+
*/
39+
fallback?: React.ReactNode;
40+
41+
/**
42+
* If true, inverts the condition logic (renders children when condition is NOT met).
43+
*/
44+
negate?: boolean;
45+
}
46+
47+
/**
48+
* FeatureFlag component that conditionally renders its children based on the evaluation of a feature flag.
49+
*
50+
* @param {FeatureProps} props The properties for the FeatureFlag component.
51+
* @returns {React.ReactElement | null} The rendered component or null if the feature is not enabled.
52+
*/
53+
export function FeatureFlag({
54+
featureKey,
55+
match,
56+
negate = false,
57+
defaultValue = true,
58+
children,
59+
fallback = null,
60+
}: FeatureProps): React.ReactElement | null {
61+
const details = useFlag(featureKey, defaultValue, {
62+
updateOnContextChanged: true,
63+
});
64+
65+
// If the flag evaluation failed, we render the fallback
66+
if (details.reason === 'ERROR') {
67+
return <>{fallback}</>;
68+
}
69+
70+
let isMatch = false;
71+
if (typeof match === 'string') {
72+
isMatch = details.variant === match;
73+
} else if (typeof match !== 'undefined') {
74+
isMatch = details.value === match;
75+
}
76+
77+
// If match is undefined, we assume the flag is enabled
78+
if (match === void 0) {
79+
isMatch = true;
80+
}
81+
82+
const shouldRender = negate ? !isMatch : isMatch;
83+
84+
if (shouldRender) {
85+
console.log('chop chop');
86+
const childNode: React.ReactNode = typeof children === 'function' ? children(details) : children;
87+
return <>{childNode}</>;
88+
}
89+
90+
return <>{fallback}</>;
91+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './FeatureFlag';

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './declarative';
12
export * from './evaluation';
23
export * from './query';
34
export * from './provider';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import React from 'react';
2+
import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup
3+
import { render, screen } from '@testing-library/react';
4+
import { FeatureFlag } from '../src/declarative/FeatureFlag'; // Assuming Feature.tsx is in the same directory or adjust path
5+
import { InMemoryProvider, OpenFeature, OpenFeatureProvider } from '../src';
6+
7+
describe('Feature Component', () => {
8+
const EVALUATION = 'evaluation';
9+
const MISSING_FLAG_KEY = 'missing-flag';
10+
const BOOL_FLAG_KEY = 'boolean-flag';
11+
const BOOL_FLAG_NEGATE_KEY = 'boolean-flag-negate';
12+
const BOOL_FLAG_VARIANT = 'on';
13+
const BOOL_FLAG_VALUE = true;
14+
const STRING_FLAG_KEY = 'string-flag';
15+
const STRING_FLAG_VARIANT = 'greeting';
16+
const STRING_FLAG_VALUE = 'hi';
17+
18+
const FLAG_CONFIG: ConstructorParameters<typeof InMemoryProvider>[0] = {
19+
[BOOL_FLAG_KEY]: {
20+
disabled: false,
21+
variants: {
22+
[BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE,
23+
off: false,
24+
},
25+
defaultVariant: BOOL_FLAG_VARIANT,
26+
},
27+
[BOOL_FLAG_NEGATE_KEY]: {
28+
disabled: false,
29+
variants: {
30+
[BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE,
31+
off: false,
32+
},
33+
defaultVariant: 'off',
34+
},
35+
[STRING_FLAG_KEY]: {
36+
disabled: false,
37+
variants: {
38+
[STRING_FLAG_VARIANT]: STRING_FLAG_VALUE,
39+
parting: 'bye',
40+
},
41+
defaultVariant: STRING_FLAG_VARIANT,
42+
}
43+
};
44+
45+
const makeProvider = () => {
46+
return new InMemoryProvider(FLAG_CONFIG);
47+
};
48+
49+
OpenFeature.setProvider(EVALUATION, makeProvider());
50+
51+
const childText = 'Feature is active';
52+
const ChildComponent = () => <div>{childText}</div>;
53+
54+
beforeEach(() => {
55+
jest.clearAllMocks();
56+
});
57+
58+
describe('<FeatureFlag />', () => {
59+
it('should not show the feature component if the flag is not enabled', () => {
60+
render(
61+
<OpenFeatureProvider domain={EVALUATION}>
62+
<FeatureFlag featureKey={BOOL_FLAG_KEY} defaultValue={false}>
63+
<ChildComponent />
64+
</FeatureFlag>
65+
</OpenFeatureProvider>,
66+
);
67+
68+
expect(screen.queryByText(childText)).toBeInTheDocument();
69+
});
70+
71+
it('should fallback when provided', () => {
72+
render(
73+
<OpenFeatureProvider domain={EVALUATION}>
74+
<FeatureFlag featureKey={MISSING_FLAG_KEY} defaultValue={false} fallback={<div>Fallback</div>}>
75+
<ChildComponent />
76+
</FeatureFlag>
77+
</OpenFeatureProvider>,
78+
);
79+
80+
expect(screen.queryByText('Fallback')).toBeInTheDocument();
81+
82+
screen.debug();
83+
});
84+
85+
it('should handle showing multivariate flags with bool match', () => {
86+
render(
87+
<OpenFeatureProvider domain={EVALUATION}>
88+
<FeatureFlag featureKey={STRING_FLAG_KEY} match={'greeting'} defaultValue={'default'}>
89+
<ChildComponent />
90+
</FeatureFlag>
91+
</OpenFeatureProvider>,
92+
);
93+
94+
expect(screen.queryByText(childText)).toBeInTheDocument();
95+
});
96+
97+
it('should show the feature component if the flag is not enabled but negate is true', () => {
98+
render(
99+
<OpenFeatureProvider domain={EVALUATION}>
100+
<FeatureFlag featureKey={BOOL_FLAG_KEY} defaultValue={false}>
101+
<ChildComponent />
102+
</FeatureFlag>
103+
</OpenFeatureProvider>,
104+
);
105+
106+
expect(screen.queryByText(childText)).toBeInTheDocument();
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)