Skip to content

Commit

Permalink
feat: versioned change request change sets (#4301)
Browse files Browse the repository at this point in the history
Co-authored-by: Matthew Elwell <[email protected]>
  • Loading branch information
kyle-ssg and matthewelwell authored Jul 25, 2024
1 parent 24831da commit 6f1f212
Show file tree
Hide file tree
Showing 23 changed files with 749 additions and 373 deletions.
93 changes: 92 additions & 1 deletion frontend/common/services/useChangeRequest.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { Res } from 'common/types/responses'
import {
ChangeRequest,
ChangeSet,
FeatureState,
Res,
} from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import Utils from 'common/utils/utils'
import sortBy from 'lodash/sortBy'
import moment from 'moment'

export const changeRequestService = service
.enhanceEndpoints({ addTagTypes: ['ChangeRequest'] })
Expand Down Expand Up @@ -45,3 +52,87 @@ const { data, isLoading } = useGetChangeRequestsQuery({ id: 2 }, {}) //get hook
const [createChangeRequests, { isLoading, data, isSuccess }] = useCreateChangeRequestsMutation() //create hook
changeRequestService.endpoints.getChangeRequests.select({id: 2})(store.getState()) //access data from any function
*/
export function parseChangeSet(changeSet: ChangeSet) {
const parsedChangeSet: {
feature_states_to_update: FeatureState[]
feature_states_to_create: FeatureState[]
segment_ids_to_delete_overrides: number[]
} = {
feature_states_to_create: changeSet.feature_states_to_create,
feature_states_to_update: changeSet.feature_states_to_update,
segment_ids_to_delete_overrides: changeSet.segment_ids_to_delete_overrides,
}

return {
...parsedChangeSet,
feature_states_to_create: Array.isArray(
parsedChangeSet.feature_states_to_create,
)
? parsedChangeSet.feature_states_to_create
: [],
feature_states_to_update: Array.isArray(
parsedChangeSet.feature_states_to_update,
)
? parsedChangeSet.feature_states_to_update
: [],
segment_ids_to_delete_overrides: Array.isArray(
parsedChangeSet.segment_ids_to_delete_overrides,
)
? parsedChangeSet.segment_ids_to_delete_overrides
: [],
}
}

export function mergeChangeSets(
changeSets: ChangeSet[] | undefined,
featureStates: FeatureState[] | undefined,
conflicts: ChangeRequest['conflicts'] | undefined,
) {
let mergedFeatureStates = (featureStates || []).concat([])

const safeChangeSets = changeSets || []
safeChangeSets.forEach((changeSet) => {
const parsedChangeSet = parseChangeSet(changeSet)
const toUpsert = (parsedChangeSet.feature_states_to_create || []).concat(
parsedChangeSet.feature_states_to_update,
)

toUpsert.forEach((v) => {
// Remove the existing feature state if it exists
mergedFeatureStates = mergedFeatureStates.filter((mergedFeatureState) => {
return (
mergedFeatureState.feature_segment?.segment !==
v.feature_segment?.segment
)
})

// Add the new or updated feature state
mergedFeatureStates = mergedFeatureStates.concat([v])
})

// Remove any to delete segment overrides
mergedFeatureStates = mergedFeatureStates.filter(
(v) =>
!v.feature_segment?.segment ||
!parsedChangeSet.segment_ids_to_delete_overrides?.includes(
v.feature_segment?.segment,
),
)
})

return mergedFeatureStates.map((featureState) => {
const conflict = sortBy(
conflicts,
//prioritise newly published conflicts as we show those when diffing change requests
(conflict) => -moment(conflict.published_at).valueOf(),
)?.find(
(conflict) =>
(conflict.segment_id || null) ===
(featureState.feature_segment?.segment || null),
)
return {
...featureState,
conflict: conflict,
}
})
}
3 changes: 1 addition & 2 deletions frontend/common/services/useFeatureState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import { service } from 'common/service'
import Utils from 'common/utils/utils'
import { getFeatureSegment } from './useFeatureSegment'
import { getStore } from 'common/store'
const _data = require('../data/base/_data')
export const addFeatureSegmentsToFeatureStates = async (v) => {
if (!v.feature_segment) {
if (typeof v.feature_segment !== 'number') {
return v
}
const featureSegmentData = await getFeatureSegment(getStore(), {
Expand Down
207 changes: 89 additions & 118 deletions frontend/common/services/useFeatureVersion.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,82 @@
import { FeatureState, FeatureVersion, Res } from 'common/types/responses'
import {
FeatureState,
FeatureVersion,
Res,
Segment,
} from 'common/types/responses'
import { Req } from 'common/types/requests'
import { service } from 'common/service'
import { getStore } from 'common/store'
import {
createVersionFeatureState,
getVersionFeatureState,
updateVersionFeatureState,
} from './useVersionFeatureState'
import { deleteFeatureSegment } from './useFeatureSegment'
import { getVersionFeatureState } from './useVersionFeatureState'
import transformCorePaging from 'common/transformCorePaging'
import Utils from 'common/utils/utils'
import { updateSegmentPriorities } from './useSegmentPriority'
import { getFeatureStateDiff, getSegmentDiff } from 'components/diff/diff-utils'

const transformFeatureStates = (featureStates: FeatureState[]) =>
featureStates?.map((v) => ({
...v,
feature_state_value: Utils.valueToFeatureState(v.feature_state_value),
id: undefined,
multivariate_feature_state_values: v.multivariate_feature_state_values?.map(
(v) => ({
...v,
id: undefined,
}),
),
}))

export const getFeatureStateCrud = (
featureStates: FeatureState[],
oldFeatureStates?: FeatureState[],
segments?: Segment[] | null | undefined,
) => {
const excludeNotChanged = (featureStates: FeatureState[]) => {
if (!oldFeatureStates) {
return featureStates
}
if (segments?.length) {
// filter out feature states that have no changes
const segmentDiffs = getSegmentDiff(
featureStates,
oldFeatureStates,
segments,
)
return featureStates.filter((v) => {
const diff = segmentDiffs?.diffs?.find(
(diff) => v.feature_segment?.segment === diff.segment.id,
)
return !!diff?.totalChanges
})
} else {
// return nothing if feature state isn't different
const valueDiff = getFeatureStateDiff(
featureStates[0],
oldFeatureStates[0],
)
if (!valueDiff.totalChanges) {
return []
}
return featureStates
}
}
const featureStatesToCreate: Req['createFeatureVersion']['feature_states_to_create'] =
featureStates.filter((v) => !v.id && !v.toRemove)
const featureStatesToUpdate: Req['createFeatureVersion']['feature_states_to_update'] =
excludeNotChanged(featureStates.filter((v) => !!v.id && !v.toRemove))
const segment_ids_to_delete_overrides: Req['createFeatureVersion']['segment_ids_to_delete_overrides'] =
featureStates
.filter((v) => !!v.id && !!v.toRemove && !!v.feature_segment)
.map((v) => v.feature_segment!.segment)

// Step 1: Create a new feature version
const feature_states_to_create = transformFeatureStates(featureStatesToCreate)
const feature_states_to_update = transformFeatureStates(featureStatesToUpdate)
return {
feature_states_to_create,
feature_states_to_update,
segment_ids_to_delete_overrides,
}
}
export const featureVersionService = service
.enhanceEndpoints({ addTagTypes: ['FeatureVersion'] })
.injectEndpoints({
Expand All @@ -22,137 +87,43 @@ export const featureVersionService = service
>({
invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }],
queryFn: async (query: Req['createAndSetFeatureVersion']) => {
// Step 1: Create a new feature version
const {
feature_states_to_create,
feature_states_to_update,
segment_ids_to_delete_overrides,
} = getFeatureStateCrud(query.featureStates)
const versionRes: { data: FeatureVersion } =
await createFeatureVersion(getStore(), {
environmentId: query.environmentId,
featureId: query.featureId,
liveFrom: query.liveFrom,
feature_states_to_create,
feature_states_to_update,
live_from: query.liveFrom,
publish_immediately: !query.skipPublish,
segment_ids_to_delete_overrides,
})

// Step 2: Get the feature states for the live version
const currentFeatureStates: { data: FeatureState[] } =
await getVersionFeatureState(getStore(), {
environmentId: query.environmentId,
featureId: query.featureId,
sha: versionRes.data.uuid,
})

// Step 3: update, create or delete feature states from the new version
const res: { data: FeatureState }[] = (
await Promise.all(
query.featureStates.map((featureState) => {
const matchingVersionState = currentFeatureStates.data.find(
(feature) => {
return (
feature.feature_segment?.segment ===
featureState.feature_segment?.segment
)
},
)
// Matching feature state exists, meaning we need to either modify or delete it
if (matchingVersionState) {
//Feature state is marked as to remove, delete it from the current version
if (
featureState.toRemove &&
matchingVersionState.feature_segment
) {
return deleteFeatureSegment(getStore(), {
id: matchingVersionState.feature_segment.id,
})
}
//Feature state is not marked as remove, so we update it
const multivariate_feature_state_values =
featureState.multivariate_feature_state_values
? featureState.multivariate_feature_state_values?.map(
(featureStateValue) => {
const newId =
matchingVersionState?.multivariate_feature_state_values?.find(
(v) => {
return (
v.multivariate_feature_option ===
featureStateValue.multivariate_feature_option
)
},
)

return {
...featureStateValue,
id: newId!.id,
}
},
)
: []

return updateVersionFeatureState(getStore(), {
environmentId: query.environmentId,
featureId: matchingVersionState.feature,
featureState: {
...featureState,
feature_segment: matchingVersionState?.feature_segment
? {
...(matchingVersionState.feature_segment as any),
priority: featureState.feature_segment!.priority,
}
: undefined,
id: matchingVersionState.id,
multivariate_feature_state_values,
uuid: matchingVersionState.uuid,
},
id: matchingVersionState.id,
sha: versionRes.data.uuid,
uuid: matchingVersionState.uuid,
})
}
// Matching feature state does not exist, meaning we need to create it
else {
return createVersionFeatureState(getStore(), {
environmentId: query.environmentId,
featureId: query.featureId,
featureState,
sha: versionRes.data.uuid,
})
}
}),
)
).filter((v) => !!v?.data)

//Step 4: Update feature segment priorities before saving feature states
const prioritiesToUpdate = query.featureStates
.filter((v) => !v.toRemove && !!v.feature_segment)
.map((v) => {
const matchingFeatureSegment = res?.find(
(currentFeatureState) =>
v.feature_segment?.segment ===
currentFeatureState.data.feature_segment?.segment,
)
return {
id: matchingFeatureSegment!.data.feature_segment!.id!,
priority: v.feature_segment!.priority,
}
})
if (prioritiesToUpdate.length) {
await updateSegmentPriorities(getStore(), prioritiesToUpdate)
}
const res = currentFeatureStates.data

const ret = {
error: res.find((v) => !!v.error)?.error,
feature_states: res.map((item) => ({
...item,
data: item,
version_sha: versionRes.data.uuid,
})),
feature_states_to_create,
feature_states_to_update,
segment_ids_to_delete_overrides,
version_sha: versionRes.data.uuid,
}

// Step 5: Publish the feature version
if (!query.skipPublish) {
await publishFeatureVersion(getStore(), {
environmentId: query.environmentId,
featureId: query.featureId,
sha: versionRes.data.uuid,
})
}

return { data: ret } as any
},
}),
Expand All @@ -162,7 +133,7 @@ export const featureVersionService = service
>({
invalidatesTags: [{ id: 'LIST', type: 'FeatureVersion' }],
query: (query: Req['createFeatureVersion']) => ({
body: { live_from: query.liveFrom },
body: query,
method: 'POST',
url: `environments/${query.environmentId}/features/${query.featureId}/versions/`,
}),
Expand Down
Loading

0 comments on commit 6f1f212

Please sign in to comment.