Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ const Constants = {
'FEATURE_ID': 150,
'SEGMENT_ID': 150,
'TRAITS_ID': 150,
'VARIANT_KEY': 255,
},
},

Expand Down Expand Up @@ -651,6 +652,7 @@ const Constants = {
'Features can have values as well as being simply on or off, e.g. a font size for a banner or an environment variable for a server.',
REMOTE_CONFIG_DESCRIPTION_VARIATION:
'Features can have values as well as being simply on or off, e.g. a font size for a banner or an environment variable for a server.<br/>Variation values are set per project, the environment weight is per environment.',
RESERVED_VARIANT_KEY: 'control',
SEGMENT_OVERRIDES_DESCRIPTION:
'Set different values for your feature based on what segments users are in. Identity overrides will take priority over any segment override.',
TAGS_DESCRIPTION:
Expand Down
19 changes: 16 additions & 3 deletions frontend/common/providers/FeatureListProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,18 @@ const FeatureListProvider = class extends React.Component {
environmentFlag,
segmentOverrides,
) => {
AppActions.createFlag(projectId, environmentId, flag, segmentOverrides)
AppActions.createFlag(
projectId,
environmentId,
{
...flag,
multivariate_options: flag.multivariate_options?.map((v, i) => ({
...v,
key: v.key || Utils.getDefaultVariantKey(i),
})),
},
segmentOverrides,
)
}

editFeatureValue = (
Expand All @@ -94,7 +105,7 @@ const FeatureListProvider = class extends React.Component {
Object.assign({}, projectFlag, {
multivariate_options:
flag.multivariate_options &&
flag.multivariate_options.map((v) => {
flag.multivariate_options.map((v, i) => {
const matchingProjectVariate =
(projectFlag.multivariate_options &&
projectFlag.multivariate_options.find((p) => p.id === v.id)) ||
Expand All @@ -103,6 +114,7 @@ const FeatureListProvider = class extends React.Component {
...v,
default_percentage_allocation:
matchingProjectVariate.default_percentage_allocation,
key: v.key || Utils.getDefaultVariantKey(i),
}
}),
}),
Expand Down Expand Up @@ -192,7 +204,7 @@ const FeatureListProvider = class extends React.Component {
Object.assign({}, projectFlag, flag, {
multivariate_options:
flag.multivariate_options &&
flag.multivariate_options.map((v) => {
flag.multivariate_options.map((v, i) => {
const matchingProjectVariate =
(projectFlag.multivariate_options &&
projectFlag.multivariate_options.find((p) => p.id === v.id)) ||
Expand All @@ -201,6 +213,7 @@ const FeatureListProvider = class extends React.Component {
...v,
default_percentage_allocation:
matchingProjectVariate.default_percentage_allocation,
key: v.key || Utils.getDefaultVariantKey(i),
}
}),
}),
Expand Down
13 changes: 13 additions & 0 deletions frontend/common/services/useProjectFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PagedResponse, ProjectFlag, Res } from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import Utils from 'common/utils/utils'
import { sortMultivariateOptions } from 'common/utils/multivariate'

/**
* Number of features to display per page in the features list.
Expand Down Expand Up @@ -122,6 +123,12 @@ export const projectFlagService = service
pageSize: arg.page_size || FEATURES_PAGE_SIZE,
previous: response.previous,
},
results: response.results.map((feature) => ({
...feature,
multivariate_options: sortMultivariateOptions(
feature.multivariate_options,
),
})),
}),
}),

Expand All @@ -130,6 +137,12 @@ export const projectFlagService = service
query: (query: Req['getProjectFlag']) => ({
url: `projects/${query.project}/features/${query.id}/`,
}),
transformResponse: (res: Res['projectFlag']) => ({
...res,
multivariate_options: sortMultivariateOptions(
res.multivariate_options,
),
}),
}),

getProjectFlags: builder.query<
Expand Down
104 changes: 74 additions & 30 deletions frontend/common/stores/feature-list-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,13 @@ import {
ChangeRequest,
Environment,
FeatureState,
MultivariateOption,
PagedResponse,
ProjectFlag,
TypedFeatureState,
} from 'common/types/responses'
import Utils from 'common/utils/utils'
import { sortMultivariateOptions } from 'common/utils/multivariate'
import Actions from 'common/dispatcher/action-constants'
import Project from 'common/project'
import flagsmith from '@flagsmith/flagsmith'
Expand Down Expand Up @@ -113,26 +115,23 @@ const controller = {
}),
project_id: projectId,
})
.then((res) => {
.then(async (res) => {
if (res.error) {
throw res.error?.error || res.error
}
return Promise.all(
(flag.multivariate_options || []).map((v) =>
data
.post(
`${Project.api}projects/${projectId}/features/${res.data.id}/mv-options/`,
{
...v,
feature: res.data.id,
},
)
.then(() => res.data),
),
).then(() =>
data.get(
`${Project.api}projects/${projectId}/features/${res.data.id}/`,
),
// Sequential so options get ascending ids in input order, which is
// the order the UI displays.
for (const v of flag.multivariate_options || []) {
await data.post(
`${Project.api}projects/${projectId}/features/${res.data.id}/mv-options/`,
{
...v,
feature: res.data.id,
},
)
}
return data.get(
`${Project.api}projects/${projectId}/features/${res.data.id}/`,
)
})
.then(() =>
Expand All @@ -150,7 +149,7 @@ const controller = {
feature: v.id,
}))
store.model = {
features: features.results,
features: features.results.map(controller.parseFlag),
keyedEnvironmentFeatures:
environmentFeatures && keyBy(environmentFeatures, 'feature'),
}
Expand Down Expand Up @@ -247,12 +246,21 @@ const controller = {
store.model && store.model.features
? store.model.features.find((v) => v.id === flag.id)
: flag
store.error = null
Promise.all(
(flag.multivariate_options || []).map((v, i) => {
const originalMV =
v.id && originalFlag?.multivariate_options
? originalFlag.multivariate_options.find((m) => m.id === v.id)
: null
let originalMV = null
if (originalFlag?.multivariate_options) {
if (v.id) {
originalMV = originalFlag.multivariate_options.find(
(m: MultivariateOption) => m.id === v.id,
)
} else if (v.key) {
originalMV = originalFlag.multivariate_options.find(
(m: MultivariateOption) => !!m.key && m.key === v.key,
)
}
}
const url = `${Project.api}projects/${projectId}/features/${flag.id}/mv-options/`
const mvData = {
...v,
Expand All @@ -263,14 +271,16 @@ const controller = {
originalMV
? data.put(`${url}${originalMV.id}/`, mvData)
: data.post(url, mvData)
).then((res) => {
// It's important to preserve the original order of multivariate_options, so that editing feature states can use the updated ID
flag.multivariate_options[i] = res
return {
...v,
id: res.id,
}
})
)
.then((res) => {
// It's important to preserve the original order of multivariate_options, so that editing feature states can use the updated ID
flag.multivariate_options[i] = res
return {
...v,
id: res.id,
}
})
.catch((e) => Promise.reject({ mvIndex: i, source: e }))
}),
)
.then(() => {
Expand All @@ -290,6 +300,32 @@ const controller = {
onComplete(flag)
}
})
.catch((e) => {
if (typeof e?.mvIndex !== 'number') {
API.ajaxHandler(store, e)
return
}
// Attribute the failure to the option that caused it so the UI
// can surface it on the right variation.
const surface = (body: any) => {
store.error = { multivariate_options: { [e.mvIndex]: body } } as any
store.goneABitWest()
}
if (typeof e.source?.text === 'function') {
e.source
.text()
.then((text: string) => {
let body = text
try {
body = JSON.parse(text)
} catch {}
surface(body)
})
.catch(() => surface(null))
} else {
surface(e.source ?? null)
}
})
},
editFeatureState: async (
projectId,
Expand Down Expand Up @@ -852,6 +888,11 @@ const controller = {
projectId,
}).then((version) => {
if (version.error) {
// Multivariate options are saved separately at the project
// level, so an unchanged environment state is not a failure.
if (version.error.message === 'Feature contains no changes') {
return
}
throw version.error
}
const featureState = version.data.feature_states[0].data
Expand Down Expand Up @@ -986,6 +1027,9 @@ const controller = {
...fs,
segment: fs.segment.id,
})),
multivariate_options:
flag.multivariate_options &&
sortMultivariateOptions(flag.multivariate_options),
}
},
searchFeatures: throttle(
Expand Down
3 changes: 3 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,9 @@ export type MultivariateOption = {
string_value: string
boolean_value?: boolean
default_percentage_allocation: number
// A stable, human-readable identifier for the variant (the backend `key`).
// Surfaced in the UI as the variation "Label". Slug-constrained and nullable.
key?: string | null
}

export type FeatureType = 'STANDARD' | 'MULTIVARIATE'
Expand Down
54 changes: 54 additions & 0 deletions frontend/common/utils/__tests__/multivariate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
getDefaultVariantKey,
sortMultivariateOptions,
} from 'common/utils/multivariate'

describe('multivariate', () => {
describe('getDefaultVariantKey', () => {
it.each`
index | expected
${0} | ${'Variant_1'}
${1} | ${'Variant_2'}
${9} | ${'Variant_10'}
`(
'getDefaultVariantKey($index) returns $expected',
({ expected, index }) => {
expect(getDefaultVariantKey(index)).toBe(expected)
},
)
})

describe('sortMultivariateOptions', () => {
it('sorts options by id ascending', () => {
const options = [{ id: 3 }, { id: 1 }, { id: 2 }]

expect(sortMultivariateOptions(options)).toEqual([
{ id: 1 },
{ id: 2 },
{ id: 3 },
])
})

it('sorts unsaved options last, preserving their input order', () => {
const options = [
{ id: undefined, value: 'new_a' },
{ id: 2, value: 'saved' },
{ id: null, value: 'new_b' },
]

expect(sortMultivariateOptions(options)).toEqual([
{ id: 2, value: 'saved' },
{ id: undefined, value: 'new_a' },
{ id: null, value: 'new_b' },
])
})

it('does not mutate the input array', () => {
const options = [{ id: 2 }, { id: 1 }]

sortMultivariateOptions(options)

expect(options).toEqual([{ id: 2 }, { id: 1 }])
})
})
})
15 changes: 15 additions & 0 deletions frontend/common/utils/multivariate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// The label a variant displays (and is saved with) when the user never
// sets one — keep display, validation and save payloads consistent.
// Kept outside Utils so Storybook-rendered components can use it without
// pulling in Utils' store dependencies (Storybook stubs out Utils).
export const getDefaultVariantKey = (index: number): string =>
`Variant_${index + 1}`

// Options not yet saved have no id and sort last, in input order.
export const sortMultivariateOptions = <T extends { id?: number | null }>(
options: T[],
): T[] =>
[...options].sort(
(a, b) =>
(a.id ?? Number.MAX_SAFE_INTEGER) - (b.id ?? Number.MAX_SAFE_INTEGER),
)
5 changes: 4 additions & 1 deletion frontend/common/utils/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import find from 'lodash/find'
import ErrorMessage from 'components/ErrorMessage'
import WarningMessage from 'components/WarningMessage'
import Constants from 'common/constants'
import { getDefaultVariantKey } from './multivariate'
import { defaultFlags } from 'common/stores/default-flags'
import Color from 'color'
import { selectBuildVersion } from 'common/services/useBuildVersion'
Expand Down Expand Up @@ -92,7 +93,8 @@ const Utils = Object.assign({}, BaseUtils, {
} else if (typeof v.default_percentage_allocation === 'number') {
total += v.default_percentage_allocation
} else {
total += (v as any).percentage_allocation
// A cleared weight input leaves the allocation null — treat as 0.
total += (v as any).percentage_allocation || 0
}
return null
})
Expand Down Expand Up @@ -255,6 +257,7 @@ const Utils = Object.assign({}, BaseUtils, {
OrganisationPermission.CREATE_PROJECT
]
},
getDefaultVariantKey,
getExistingWaitForTime: (
waitFor: string | undefined,
):
Expand Down
Loading
Loading