From c1b2a84e5a384b97b76600f4408b7f52e8f9d73d Mon Sep 17 00:00:00 2001 From: Talisson Costa Date: Fri, 21 Aug 2026 11:13:21 -0300 Subject: [PATCH] fix(features): flatten deep-linked feature value so the editor renders it Opening a flag via the ?feature= deep link when it is beyond the loaded page fetches it through features/featurestates/, which returns feature_state_value as a nested {type, string_value, ...} object rather than the flat value the feature-list path supplies. The value editor stringified that object to "[object Object]", and a save would have persisted it over the real value. Flatten the nested value in pickEnvironmentFlag, mirroring Utils.featureStateToValue (inlined to keep the Flux stores out of this hook). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/common/types/responses.ts | 2 +- .../hooks/__tests__/deepLinkedFeature.test.ts | 31 ++++++++++++-- .../pages/features/hooks/deepLinkedFeature.ts | 41 +++++++++++++++++-- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 068ab99c483c..ce81d9128f93 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -578,7 +578,7 @@ export type MultivariateFeatureStateValue = { export type FeatureStateValue = { boolean_value: boolean | null float_value?: number | null - integer_value?: boolean | null + integer_value?: number | null string_value: string type: 'int' | 'unicode' | 'bool' | 'float' } 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 c3ffb107f5c2..d0ebaf8606d7 100644 --- a/frontend/web/components/pages/features/hooks/__tests__/deepLinkedFeature.test.ts +++ b/frontend/web/components/pages/features/hooks/__tests__/deepLinkedFeature.test.ts @@ -59,21 +59,44 @@ describe('shouldDeepFetchFeature', () => { }) describe('pickEnvironmentFlag', () => { - const make = (id: number, feature: number) => - ({ feature, id } as FeatureState) + const make = (id: number, feature: number, feature_state_value?: unknown) => + ({ feature, feature_state_value, id } as unknown as FeatureState) it('returns the state matching the feature id', () => { const results = [make(10, 1), make(11, 99), make(12, 2)] - expect(pickEnvironmentFlag(results, 99)).toBe(results[1]) + expect(pickEnvironmentFlag(results, 99)).toMatchObject({ + feature: 99, + id: 11, + }) }) it('falls back to the first result when there is no exact match', () => { const results = [make(10, 1), make(12, 2)] - expect(pickEnvironmentFlag(results, 99)).toBe(results[0]) + expect(pickEnvironmentFlag(results, 99)).toMatchObject({ + feature: 1, + id: 10, + }) }) it('returns undefined when there are no results', () => { expect(pickEnvironmentFlag([], 99)).toBeUndefined() 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, + ) + }, + ) }) diff --git a/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts b/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts index 4c9acfae0ddf..99f70cfd7150 100644 --- a/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts +++ b/frontend/web/components/pages/features/hooks/deepLinkedFeature.ts @@ -1,4 +1,33 @@ -import type { FeatureState } from 'common/types/responses' +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 + } +} /** * Decides whether the `?feature=` deep link targets a feature that is NOT on the @@ -31,8 +60,14 @@ export function pickEnvironmentFlag( results: FeatureState[] | undefined, featureId: number, ): FeatureState | undefined { - return ( + const match = results?.find((featureState) => featureState.feature === featureId) ?? results?.[0] - ) + if (!match) { + return undefined + } + return { + ...match, + feature_state_value: flattenFeatureStateValue(match.feature_state_value), + } }