diff --git a/packages/react/src/declarative/FeatureFlag.tsx b/packages/react/src/declarative/FeatureFlag.tsx new file mode 100644 index 000000000..1777dbcca --- /dev/null +++ b/packages/react/src/declarative/FeatureFlag.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { useFlag } from '../evaluation'; +import type { FlagQuery } from '../query'; + +/** + * Props for the Feature component that conditionally renders content based on feature flag state. + * @interface FeatureProps + */ +interface FeatureProps { + /** + * The key of the feature flag to evaluate. + */ + featureKey: string; + + /** + * Optional value to match against the feature flag value. + * If provided, the component will only render children when the flag value matches this value. + * If a boolean, it will check if the flag is enabled (true) or disabled (false). + * If a string, it will check if the flag variant equals this string. + */ + match?: string | boolean; + + /** + * Default value to use when the feature flag is not found. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + defaultValue: any; + + /** + * Content to render when the feature flag condition is met. + * Can be a React node or a function that receives flag query details and returns a React node. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: React.ReactNode | ((details: FlagQuery) => React.ReactNode); + + /** + * Optional content to render when the feature flag condition is not met. + */ + fallback?: React.ReactNode; + + /** + * If true, inverts the condition logic (renders children when condition is NOT met). + */ + negate?: boolean; +} + +/** + * FeatureFlag component that conditionally renders its children based on the evaluation of a feature flag. + * + * @param {FeatureProps} props The properties for the FeatureFlag component. + * @returns {React.ReactElement | null} The rendered component or null if the feature is not enabled. + */ +export function FeatureFlag({ + featureKey, + match, + negate = false, + defaultValue = true, + children, + fallback = null, +}: FeatureProps): React.ReactElement | null { + const details = useFlag(featureKey, defaultValue, { + updateOnContextChanged: true, + }); + + // If the flag evaluation failed, we render the fallback + if (details.reason === 'ERROR') { + return <>{fallback}; + } + + let isMatch = false; + if (typeof match === 'string') { + isMatch = details.variant === match; + } else if (typeof match !== 'undefined') { + isMatch = details.value === match; + } + + // If match is undefined, we assume the flag is enabled + if (match === void 0) { + isMatch = true; + } + + const shouldRender = negate ? !isMatch : isMatch; + + if (shouldRender) { + console.log('chop chop'); + const childNode: React.ReactNode = typeof children === 'function' ? children(details) : children; + return <>{childNode}; + } + + return <>{fallback}; +} diff --git a/packages/react/src/declarative/index.ts b/packages/react/src/declarative/index.ts new file mode 100644 index 000000000..5baeee7ca --- /dev/null +++ b/packages/react/src/declarative/index.ts @@ -0,0 +1 @@ +export * from './FeatureFlag'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e08a7ae63..9859e26d4 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,4 @@ +export * from './declarative'; export * from './evaluation'; export * from './query'; export * from './provider'; diff --git a/packages/react/test/declarative.spec.tsx b/packages/react/test/declarative.spec.tsx new file mode 100644 index 000000000..02d36b649 --- /dev/null +++ b/packages/react/test/declarative.spec.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup +import { render, screen } from '@testing-library/react'; +import { FeatureFlag } from '../src/declarative/FeatureFlag'; // Assuming Feature.tsx is in the same directory or adjust path +import { InMemoryProvider, OpenFeature, OpenFeatureProvider } from '../src'; + +describe('Feature Component', () => { + const EVALUATION = 'evaluation'; + const MISSING_FLAG_KEY = 'missing-flag'; + const BOOL_FLAG_KEY = 'boolean-flag'; + const BOOL_FLAG_NEGATE_KEY = 'boolean-flag-negate'; + const BOOL_FLAG_VARIANT = 'on'; + const BOOL_FLAG_VALUE = true; + const STRING_FLAG_KEY = 'string-flag'; + const STRING_FLAG_VARIANT = 'greeting'; + const STRING_FLAG_VALUE = 'hi'; + + const FLAG_CONFIG: ConstructorParameters[0] = { + [BOOL_FLAG_KEY]: { + disabled: false, + variants: { + [BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE, + off: false, + }, + defaultVariant: BOOL_FLAG_VARIANT, + }, + [BOOL_FLAG_NEGATE_KEY]: { + disabled: false, + variants: { + [BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE, + off: false, + }, + defaultVariant: 'off', + }, + [STRING_FLAG_KEY]: { + disabled: false, + variants: { + [STRING_FLAG_VARIANT]: STRING_FLAG_VALUE, + parting: 'bye', + }, + defaultVariant: STRING_FLAG_VARIANT, + } + }; + + const makeProvider = () => { + return new InMemoryProvider(FLAG_CONFIG); + }; + + OpenFeature.setProvider(EVALUATION, makeProvider()); + + const childText = 'Feature is active'; + const ChildComponent = () =>
{childText}
; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('', () => { + it('should not show the feature component if the flag is not enabled', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + + it('should fallback when provided', () => { + render( + + Fallback}> + + + , + ); + + expect(screen.queryByText('Fallback')).toBeInTheDocument(); + + screen.debug(); + }); + + it('should handle showing multivariate flags with bool match', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + + it('should show the feature component if the flag is not enabled but negate is true', () => { + render( + + + + + , + ); + + expect(screen.queryByText(childText)).toBeInTheDocument(); + }); + }); +});