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
132 changes: 132 additions & 0 deletions frontend/common/services/useMultivariateOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { MultivariateOption, ProjectFlag, Res } from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'

export const multivariateOptionService = service.injectEndpoints({
endpoints: (builder) => ({
createMultivariateOption: builder.mutation<
Res['multivariateOption'],
Req['createMultivariateOption']
>({
query: (query) => ({
body: query.body,
method: 'POST',
url: `projects/${query.project_id}/features/${query.feature_id}/mv-options/`,
}),
}),
saveMultivariateOptions: builder.mutation<
Res['saveMultivariateOptions'],
Req['saveMultivariateOptions']
>({
// No invalidatesTags: every save chain already ends with a broad
// invalidateTags(['ProjectFlag', 'FeatureList']) once the downstream
// feature-state save completes — invalidating here too would refetch
// every subscribed query twice per save.
queryFn: async (args, _, _2, baseQuery) => {
const featureUrl = `projects/${args.project_id}/features/${args.feature_id}/`
// Diff against the server's current options rather than any client
// cache, so stale state can never turn an update into a duplicate
// create.
const flagRes = await baseQuery({ method: 'GET', url: featureUrl })
if (flagRes.error) {
return { error: flagRes.error }
}
const serverOptions =
(flagRes.data as ProjectFlag)?.multivariate_options || []
const errors: Record<number, any> = {}
// Results are written back by input index — downstream feature
// state saves map weights to option ids positionally. Requests run
// sequentially so newly created options get ascending ids in input
// order, which is the order the UI displays.
const ordered: MultivariateOption[] = []
for (let i = 0; i < args.multivariate_options.length; i++) {
const v = args.multivariate_options[i]
let original
if (v.id) {
original = serverOptions.find((m) => m.id === v.id)
Comment on lines +45 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To prevent potential type mismatch issues (e.g., if v.id is passed as a string from legacy form inputs or client state while m.id is a number from the server), it is safer to compare them by normalizing both to strings or numbers. Strict equality === will fail if one is a string and the other is a number, which would result in creating duplicate options instead of updating the existing ones.

Suggested change
if (v.id) {
original = serverOptions.find((m) => m.id === v.id)
if (v.id) {
original = serverOptions.find((m) => String(m.id) === String(v.id))

} else if (v.key) {
original = serverOptions.find((m) => !!m.key && m.key === v.key)
}
const body = {
...v,
default_percentage_allocation: 0,
feature: args.feature_id,
}
const res = await baseQuery(
original
? {
body,
method: 'PUT',
url: `${featureUrl}mv-options/${original.id}/`,
}
: {
body,
method: 'POST',
url: `${featureUrl}mv-options/`,
},
)
if (res.error) {
errors[i] = (res.error as { data?: any })?.data ?? null
} else {
ordered[i] = res.data as MultivariateOption
}
}
if (Object.keys(errors).length) {
return { data: { errors, multivariate_options: ordered } }
}
const deleted = serverOptions.filter(
(m) => !ordered.find((o) => o?.id === m.id),
)
const deleteResults = await Promise.all(
deleted.map((m) =>
baseQuery({
method: 'DELETE',
url: `${featureUrl}mv-options/${m.id}/`,
}),
),
)
const failedDelete = deleteResults.find((r) => r.error)
if (failedDelete) {
return { error: failedDelete.error }
}
return { data: { errors: null, multivariate_options: ordered } }
},
}),
// END OF ENDPOINTS
}),
})

export async function createMultivariateOption(
store: any,
data: Req['createMultivariateOption'],
options?: Parameters<
typeof multivariateOptionService.endpoints.createMultivariateOption.initiate
>[1],
) {
return store.dispatch(
multivariateOptionService.endpoints.createMultivariateOption.initiate(
data,
options,
),
)
}
export async function saveMultivariateOptions(
store: any,
data: Req['saveMultivariateOptions'],
options?: Parameters<
typeof multivariateOptionService.endpoints.saveMultivariateOptions.initiate
>[1],
) {
return store.dispatch(
multivariateOptionService.endpoints.saveMultivariateOptions.initiate(
data,
options,
),
)
}

export const {
useCreateMultivariateOptionMutation,
useSaveMultivariateOptionsMutation,
// END OF EXPORTS
} = multivariateOptionService
164 changes: 66 additions & 98 deletions frontend/common/stores/feature-list-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import { FEATURES_PAGE_SIZE } from 'common/services/useProjectFlag'
import Dispatcher from 'common/dispatcher/dispatcher'
import BaseStore from './base/_store'
import data from 'common/data/base/_data'
import {
createMultivariateOption,
saveMultivariateOptions,
} from 'common/services/useMultivariateOption'
import { createSegmentOverride } from 'common/services/useSegmentOverride'
import { getStore } from 'common/store'
let createdFirstFeature = false
Expand Down Expand Up @@ -114,26 +118,27 @@ 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 || []) {
const mvRes = await createMultivariateOption(getStore(), {
body: {
...v,
feature: res.data.id,
},
feature_id: res.data.id,
project_id: projectId,
})
if (mvRes.error) {
throw mvRes.error
}
}
return data.get(
`${Project.api}projects/${projectId}/features/${res.data.id}/`,
)
})
.then(() =>
Expand All @@ -151,7 +156,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 @@ -244,90 +249,45 @@ const controller = {
})
return
}
store.error = null
const originalFlag =
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) => {
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,
default_percentage_allocation: 0,
feature: flag.id,
}
return (
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,
}
})
.catch((e) => Promise.reject({ mvIndex: i, source: e }))
}),
)
.then(() => {
const deletedMv = (originalFlag?.multivariate_options || []).filter(
(v) => !flag.multivariate_options.find((x) => v.id === x.id),
)
return Promise.all(
deletedMv.map((v) =>
data.delete(
`${Project.api}projects/${projectId}/features/${flag.id}/mv-options/${v.id}/`,
),
),
)
})
.then(() => {
if (onComplete) {
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)
}
})
// Standard flags carry no multivariate data — skip the round-trip.
if (
!flag.multivariate_options?.length &&
!originalFlag?.multivariate_options?.length
) {
if (onComplete) {
onComplete(flag)
}
return
}
saveMultivariateOptions(getStore(), {
feature_id: flag.id,
multivariate_options: flag.multivariate_options || [],
project_id: projectId,
}).then((res) => {
if (res.error) {
API.ajaxHandler(store, res.error)
return
}
if (res.data.errors) {
store.error = { multivariate_options: res.data.errors } as any
store.goneABitWest()
return
}
// It's important to preserve the original order of multivariate_options, so that editing feature states can use the updated ID
res.data.multivariate_options.forEach(
(v: MultivariateOption, i: number) => {
flag.multivariate_options[i] = v
},
)
if (onComplete) {
onComplete(flag)
}
})
},
editFeatureState: async (
projectId,
Expand Down Expand Up @@ -1029,6 +989,14 @@ const controller = {
...fs,
segment: fs.segment.id,
})),
// The API returns multivariate options in unspecified order, which
// can change after any update — keep creation order stable for the
// UI and for index-based weight mapping.
multivariate_options:
flag.multivariate_options &&
[...flag.multivariate_options].sort(
(a: MultivariateOption, b: MultivariateOption) => a.id - b.id,
),
}
},
searchFeatures: throttle(
Expand Down
10 changes: 10 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,5 +1039,15 @@ export type Req = {
body: Req['createMetric']['body']
}
deleteMetric: { environmentId: string; metricId: number }
createMultivariateOption: {
project_id: string | number
feature_id: number
body: Partial<MultivariateOption> & { feature: number }
}
saveMultivariateOptions: {
project_id: string | number
feature_id: number
multivariate_options: Partial<MultivariateOption>[]
}
// END OF TYPES
}
7 changes: 7 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1413,5 +1413,12 @@ export type Res = {
experiment: Experiment
metric: Metric
metrics: PagedResponse<Metric>
multivariateOption: MultivariateOption
saveMultivariateOptions: {
multivariate_options: MultivariateOption[]
// Per-option API errors keyed by the input option's index; null when all
// requests succeeded.
errors: Record<number, any> | null
}
// END OF TYPES
}
Loading
Loading