From e81c108a842b9f3b7a7048f079c9e8a971248e9a Mon Sep 17 00:00:00 2001 From: Lautaro Petaccio <1120791+LautaroPetaccio@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:18:24 -0300 Subject: [PATCH] feat: Add feature variant selector (#662) --- src/modules/features/selectors.spec.ts | 122 +++++++++++++++++++++++++ src/modules/features/selectors.ts | 42 ++++++++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/modules/features/selectors.spec.ts b/src/modules/features/selectors.spec.ts index a6f3207f..8443855c 100644 --- a/src/modules/features/selectors.spec.ts +++ b/src/modules/features/selectors.spec.ts @@ -3,6 +3,7 @@ import { getMockApplicationFeaturesRecord } from './actions.spec' import { getData, getError, + getFeatureVariant, getIsFeatureEnabled, getLoading, hasLoadedInitialFlags, @@ -191,6 +192,127 @@ describe('when getting if a feature is enabled', () => { }) }) +describe('when getting a feature variant', () => { + let data: ReturnType + let state: StateWithFeatures + + beforeEach(() => { + data = getMockApplicationFeaturesRecord() + state = { + features: { + data, + error: null, + hasLoadedInitialFlags: false, + loading: [] + } + } + }) + + describe('when the variant is defined in the environment', () => { + beforeEach(() => { + process.env.REACT_APP_FF_VARIANT_DAPPS_TEST_FEATURE = 'test-variant' + }) + + afterEach(() => { + delete process.env.REACT_APP_FF_VARIANT_DAPPS_TEST_FEATURE + }) + + it('should return a local variant with the environment value', () => { + expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toEqual({ + name: 'Local variant', + enabled: true, + payload: { + type: 'string', + value: 'test-variant' + } + }) + }) + }) + + describe('when the variant is not defined in the environment', () => { + describe('and the application features are not loaded', () => { + beforeEach(() => { + state = { + features: { + data: {}, + error: null, + hasLoadedInitialFlags: false, + loading: [] + } + } + }) + + it('should return null', () => { + expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toBeNull() + }) + }) + + describe('and the application features are loaded', () => { + describe('and the variant exists', () => { + beforeEach(() => { + state = { + features: { + data: { + [ApplicationName.DAPPS]: { + name: ApplicationName.DAPPS, + flags: {}, + variants: { + 'dapps-test-feature': { + name: 'Remote variant', + enabled: true, + payload: { + type: 'string', + value: 'remote-variant' + } + } + } + } + }, + error: null, + hasLoadedInitialFlags: true, + loading: [] + } + } + }) + + it('should return the variant from the store', () => { + expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toEqual({ + name: 'Remote variant', + enabled: true, + payload: { + type: 'string', + value: 'remote-variant' + } + }) + }) + }) + + describe('and the variant does not exist', () => { + beforeEach(() => { + state = { + features: { + data: { + [ApplicationName.DAPPS]: { + name: ApplicationName.DAPPS, + flags: {}, + variants: {} + } + }, + error: null, + hasLoadedInitialFlags: true, + loading: [] + } + } + }) + + it('should return null', () => { + expect(getFeatureVariant(state, ApplicationName.DAPPS, 'test-feature')).toBeNull() + }) + }) + }) + }) +}) + describe('when getting if the feature flags were loaded at least once', () => { let state: StateWithFeatures diff --git a/src/modules/features/selectors.ts b/src/modules/features/selectors.ts index 2d1642db..7aeaa9cb 100644 --- a/src/modules/features/selectors.ts +++ b/src/modules/features/selectors.ts @@ -5,7 +5,8 @@ import { FeaturesState } from './reducer' import { ApplicationName, ApplicationFeatures, - StateWithFeatures + StateWithFeatures, + Variant } from './types' export const getState = (state: StateWithFeatures): FeaturesState => { @@ -73,6 +74,34 @@ export const isLoadingFeatureFlags = (state: StateWithFeatures) => { return isLoadingType(getLoading(state), FETCH_APPLICATION_FEATURES_REQUEST) } +export const getFeatureVariant = (state: StateWithFeatures, app: ApplicationName, feature: string): Variant | null => { + const variant = getVariantFromEnv(app, feature) + + // Return the flag variant if it has been defined in the env. + // If flag variants are only defined in the env, there is no need to implement the features reducer. + if (variant !== null) { + // Build the variant object + return { + name: 'Local variant', + enabled: true, + payload: { + type: 'string', + value: variant + } + } + } + + const appFeatures = getData(state)[app] + + // The app might not be defined in the store because the flag variants might not have been fetched yet. + // We suggest using isLoadingFeatureFlags and hasLoadedInitialFlags to handle this first. + if (!appFeatures) { + return null + } + + return appFeatures.variants[`${app}-${feature}`] || null +} + export const hasLoadedInitialFlags = (state: StateWithFeatures) => { return getState(state).hasLoadedInitialFlags } @@ -87,3 +116,14 @@ const getFromEnv = ( return !value || value === '' ? null : value === '1' ? true : false } + +const getVariantFromEnv = ( + application: ApplicationName, + flag: string +): string | null => { + const envify = (word: string) => word.toUpperCase().replace(/-/g, '_') + const key = `REACT_APP_FF_VARIANT_${envify(application)}_${envify(flag)}` + const value = process.env[key] + + return !value || value === '' ? null : value +}