diff --git a/frontend/common/utils/__tests__/featureStateToValue.test.ts b/frontend/common/utils/__tests__/featureStateToValue.test.ts new file mode 100644 index 000000000000..3708551480e5 --- /dev/null +++ b/frontend/common/utils/__tests__/featureStateToValue.test.ts @@ -0,0 +1,37 @@ +import { featureStateToValue } from 'common/utils/featureStateToValue' + +describe('featureStateToValue', () => { + it.each([ + ['unicode', { string_value: '{"a":1}', type: 'unicode' }, '{"a":1}'], + ['bool', { boolean_value: true, type: 'bool' }, true], + ['float', { float_value: 1.5, type: 'float' }, 1.5], + ['int', { integer_value: 7, type: 'int' }, 7], + ])('flattens a nested %s value', (_label, nested, expected) => { + expect(featureStateToValue(nested as never)).toBe(expected) + }) + + it('reads value_type when present (core traits)', () => { + expect( + featureStateToValue({ integer_value: 3, value_type: 'int' } as never), + ).toBe(3) + }) + + it('normalises a missing int/float to null', () => { + expect(featureStateToValue({ type: 'int' } as never)).toBeNull() + expect(featureStateToValue({ type: 'float' } as never)).toBeNull() + }) + + it('returns null for null or undefined', () => { + expect(featureStateToValue(null)).toBeNull() + expect(featureStateToValue(undefined)).toBeNull() + }) + + it.each([ + ['string', 'already-flat'], + ['number', 42], + ['boolean false', false], + ['empty string', ''], + ])('passes an already-flat %s through unchanged', (_label, flat) => { + expect(featureStateToValue(flat)).toBe(flat) + }) +}) diff --git a/frontend/common/utils/featureStateToValue.ts b/frontend/common/utils/featureStateToValue.ts new file mode 100644 index 000000000000..730b47e336e9 --- /dev/null +++ b/frontend/common/utils/featureStateToValue.ts @@ -0,0 +1,35 @@ +import type { FeatureStateValue, FlagsmithValue } from 'common/types/responses' + +/** + * Flattens a feature state (or core trait) value into its typed scalar. + * + * Accepts either the nested `{ type, string_value, ... }` shape returned by the + * featurestates endpoint or an already-flat value, and returns the flat value. + * + * Kept in its own module, free of `common/utils` imports, so consumers (and + * their unit tests) don't pull the Flux stores in through `utils.tsx`. + */ +export function featureStateToValue( + value: FlagsmithValue | FeatureStateValue | undefined, +): FlagsmithValue { + if (value === null || value === undefined) { + return null + } + if (typeof value !== 'object') { + return value + } + // `value_type` is the type key on core traits; `type` on feature states. + const type = + (value as { value_type?: FeatureStateValue['type'] }).value_type ?? + value.type + switch (type) { + case 'bool': + return value.boolean_value + case 'float': + return value.float_value ?? null + case 'int': + return value.integer_value ?? null + default: + return value.string_value + } +} diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index 9c9ab4e0d6cd..30df0c6165ae 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -4,7 +4,6 @@ import Project from 'common/project' import { ContentType, FeatureState, - FeatureStateValue, FlagsmithValue, MultivariateFeatureStateValue, MultivariateOption, @@ -22,6 +21,7 @@ import ErrorMessage from 'components/ErrorMessage' import WarningMessage from 'components/WarningMessage' import Constants from 'common/constants' import { getDefaultVariantKey } from './multivariate' +import { featureStateToValue } from './featureStateToValue' import { defaultFlags } from 'common/stores/default-flags' import Color from 'color' import { selectBuildVersion } from 'common/services/useBuildVersion' @@ -180,23 +180,9 @@ const Utils = Object.assign({}, BaseUtils, { } return null }, - featureStateToValue(featureState: FeatureStateValue) { - if (!featureState) { - return null - } - - //@ts-ignore value_type is the type key on core traits - switch (featureState.value_type || featureState.type) { - case 'bool': - return featureState.boolean_value - case 'float': - return featureState.float_value - case 'int': - return featureState.integer_value - default: - return featureState.string_value - } - }, + // Delegates to the standalone, Flux-free module so callers that can't import + // this file (e.g. unit-tested hooks) can use the same logic directly. + featureStateToValue, findOperator( operator: SegmentCondition['operator'], value: string, diff --git a/frontend/web/components/pages/features/hooks/__tests__/deepLinkedFeature.test.ts b/frontend/web/components/pages/features/hooks/__tests__/deepLinkedFeature.test.ts index d0ebaf8606d7..3994a8e8693c 100644 --- a/frontend/web/components/pages/features/hooks/__tests__/deepLinkedFeature.test.ts +++ b/frontend/web/components/pages/features/hooks/__tests__/deepLinkedFeature.test.ts @@ -83,20 +83,12 @@ describe('pickEnvironmentFlag', () => { expect(pickEnvironmentFlag(undefined, 99)).toBeUndefined() }) - it.each([ - ['unicode', { string_value: '{"a":1}', type: 'unicode' }, '{"a":1}'], - ['bool', { boolean_value: true, type: 'bool' }, true], - ['float', { float_value: 1.5, type: 'float' }, 1.5], - ['int', { integer_value: 7, type: 'int' }, 7], - ['int missing', { type: 'int' }, null], - ['no value', undefined, null], - ])( - 'flattens a nested feature_state_value (%s) to the typed value', - (_label, nested, expected) => { - const results = [make(11, 99, nested)] - expect(pickEnvironmentFlag(results, 99)?.feature_state_value).toBe( - expected, - ) - }, - ) + it('flattens the nested feature_state_value via featureStateToValue', () => { + // Branch coverage lives in featureStateToValue.test.ts; this just proves the + // nested value is flattened rather than passed through as an object. + const results = [make(11, 99, { string_value: '{"a":1}', type: 'unicode' })] + expect(pickEnvironmentFlag(results, 99)?.feature_state_value).toBe( + '{"a":1}', + ) + }) }) diff --git a/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts b/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts index 99f70cfd7150..4448c808e1a2 100644 --- a/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts +++ b/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts @@ -1,33 +1,5 @@ -import type { - FeatureState, - FeatureStateValue, - FlagsmithValue, -} from 'common/types/responses' - -type FlattenableFeatureStateValue = FlagsmithValue | FeatureStateValue - -// The featurestates endpoint returns a nested value; the list path is flat. -// Mirrors Utils.featureStateToValue, inlined to keep the Flux stores out. -function flattenFeatureStateValue( - value: FlattenableFeatureStateValue | undefined, -): FlagsmithValue { - if (value === null || value === undefined) { - return null - } - if (typeof value !== 'object') { - return value - } - switch (value.type) { - case 'bool': - return value.boolean_value - case 'float': - return value.float_value ?? null - case 'int': - return value.integer_value ?? null - default: - return value.string_value - } -} +import { featureStateToValue } from 'common/utils/featureStateToValue' +import type { FeatureState } from 'common/types/responses' /** * Decides whether the `?feature=` deep link targets a feature that is NOT on the @@ -68,6 +40,6 @@ export function pickEnvironmentFlag( } return { ...match, - feature_state_value: flattenFeatureStateValue(match.feature_state_value), + feature_state_value: featureStateToValue(match.feature_state_value), } }