Skip to content

Commit

Permalink
Merge pull request #99 from splitio/flag_sets
Browse files Browse the repository at this point in the history
[Flag sets] Implementation
  • Loading branch information
EmilianoSanchez authored Dec 18, 2023
2 parents d1a01a6 + 89d908d commit f241264
Show file tree
Hide file tree
Showing 9 changed files with 97 additions and 44 deletions.
8 changes: 6 additions & 2 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
1.10.0 (December 19, 2023)
1.10.0 (December 18, 2023)
- Added support for Flag Sets on the SDK, which enables grouping feature flags and interacting with the group rather than individually (more details in our documentation):
- Added a new optional `flagSets` property to the param object of the `getTreatments` action creator, to support evaluating flags in given flag set/s. Either `splitNames` or `flagSets` must be provided to the function. If both are provided, `splitNames` will be used.
- Added a new optional Split Filter configuration option. This allows the SDK and Split services to only synchronize the flags in the specified flag sets, avoiding unused or unwanted flags from being synced on the SDK instance, bringing all the benefits from a reduced payload.
- Added `sets` property to the `SplitView` object returned by the `getSplit` and `getSplits` helper functions to expose flag sets on flag views.
- Added `defaultTreatment` property to the `SplitView` object returned by the `getSplit` and `getSplits` helper functions (Related to issue https://github.com/splitio/javascript-commons/issues/225).
- Updated `getTreatments` action creator to validate the provided params object, in order to log a descriptive error when an invalid object is provided rather than throwing a cryptic error.
- Updated @splitsoftware/splitio package to version 10.24.1 that includes flag sets support, vulnerability fixes and other improvements.
Expand Down Expand Up @@ -73,7 +77,7 @@
- Updated Split's SDK dependency to fix vulnerabilities.

1.3.0 (December 9, 2020)
- Added a new parameter to `getTreatments` actions creator: `evalOnReadyFromCache` to evaluate feature flags when the SDK_READY_FROM_CACHE event is emitted. Learn more in our Redux SDK documentation.
- Added a new parameter to `getTreatments` action creator: `evalOnReadyFromCache` to evaluate feature flags when the SDK_READY_FROM_CACHE event is emitted. Learn more in our Redux SDK documentation.
- Updated how feature flag evaluations are handled on SDK_READY, SDK_READY_FROM_CACHE and SDK_UPDATE events, to dispatch a single action with evaluations that results in all treatments updates in the state at once, instead of having multiple actions that might lead to multiple store notifications.
- Updated some NPM dependencies for vulnerability fixes.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@splitsoftware/splitio-redux",
"version": "1.9.1-rc.0",
"version": "1.10.0",
"description": "A library to easily use Split JS SDK with Redux and React Redux",
"main": "lib/index.js",
"module": "es/index.js",
Expand Down
58 changes: 38 additions & 20 deletions src/__tests__/actions.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,13 +187,20 @@ describe('getTreatments', () => {

actionResult.then(() => {
store.dispatch<any>(getTreatments({ splitNames: 'split1' }));
store.dispatch<any>(getTreatments({ flagSets: 'set1' }));

const action = store.getActions()[1];
expect(action.type).toBe(ADD_TREATMENTS);
expect(action.payload.key).toBe(sdkBrowserConfig.core.key);
const actions = [store.getActions()[1], store.getActions()[2]];
actions.forEach(action => {
expect(action.type).toBe(ADD_TREATMENTS);
expect(action.payload.key).toBe(sdkBrowserConfig.core.key);
});

// getting the evaluation result and validating it matches the results from SDK
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split1'], undefined);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(actions[0].payload.treatments);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveBeenLastCalledWith(['set1'], undefined);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveLastReturnedWith(actions[1].payload.treatments);

expect(getClient(splitSdk).evalOnUpdate).toEqual({});
expect(getClient(splitSdk).evalOnReady.length).toEqual(0);

Expand Down Expand Up @@ -256,10 +263,16 @@ describe('getTreatments', () => {

// The first ADD_TREATMENTS actions is dispatched again, but this time is registered for 'evalOnUpdate'
store.dispatch<any>(getTreatments({ splitNames: 'split1', evalOnUpdate: true }));
// Dispatch another ADD_TREATMENTS action with flag sets
store.dispatch<any>(getTreatments({ flagSets: 'set1', evalOnUpdate: true }));

// Validate action and registered callback
expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledTimes(5);
expect(Object.values(getClient(splitSdk).evalOnUpdate).length).toBe(1);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(1);
expect(getClient(splitSdk).evalOnUpdate).toEqual({
'flag::split1': { evalOnUpdate: true, flagSets: undefined, splitNames: ['split1'] },
'set::set1': { evalOnUpdate: true, flagSets: ['set1'], splitNames: undefined }
});

done();
});
Expand All @@ -271,40 +284,45 @@ describe('getTreatments', () => {
const store = mockStore(STATE_INITIAL);
const actionResult = store.dispatch<any>(initSplitSdk({ config: sdkBrowserConfig, onReadyFromCache: onReadyFromCacheCb }));
store.dispatch<any>(getTreatments({ splitNames: 'split2' })); // `evalOnUpdate` and `evalOnReadyFromCache` params are false by default
store.dispatch<any>(getTreatments({ flagSets: 'set2' }));

// If SDK is not operational, an ADD_TREATMENTS action is dispatched with control treatments
// without calling SDK client, but the item is added to 'evalOnReady' list.
expect(store.getActions().length).toBe(1);
expect(getClient(splitSdk).evalOnReady.length).toEqual(1);
expect(getClient(splitSdk).evalOnUpdate).toEqual({});
let action = store.getActions()[0];
expect(action.type).toBe(ADD_TREATMENTS);
expect(action.payload.key).toBe(sdkBrowserConfig.core.key);
expect(action.payload.treatments).toEqual(getControlTreatmentsWithConfig(['split2']));
// If SDK is not operational, ADD_TREATMENTS actions are dispatched, with control treatments for provided feature flag names, and no treatments for provided flag sets.

expect(store.getActions()).toEqual([
{ type: ADD_TREATMENTS, payload: { key: sdkBrowserConfig.core.key, treatments: getControlTreatmentsWithConfig(['split2']) } },
{ type: ADD_TREATMENTS, payload: { key: sdkBrowserConfig.core.key, treatments: {} } },
]);
// SDK client is not called, but items are added to 'evalOnReady' list.
expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledTimes(0);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0);
expect(getClient(splitSdk).evalOnReady.length).toEqual(2);
expect(getClient(splitSdk).evalOnUpdate).toEqual({});

// When the SDK is ready from cache, the SPLIT_READY_FROM_CACHE_WITH_EVALUATIONS action is not dispatched if the `getTreatments` action was dispatched with `evalOnReadyFromCache` false
(splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE);
function onReadyFromCacheCb() {
expect(store.getActions().length).toBe(2);
action = store.getActions()[1];
expect(store.getActions().length).toBe(3);
const action = store.getActions()[2];
expect(action.type).toBe(SPLIT_READY_FROM_CACHE);
}

(splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY);

actionResult.then(() => {
// The SPLIT_READY_WITH_EVALUATIONS action is dispatched if the SDK is ready and there are pending evaluations.
action = store.getActions()[2];
const action = store.getActions()[3];
expect(action.type).toBe(SPLIT_READY_WITH_EVALUATIONS);
expect(action.payload.key).toBe(sdkBrowserConfig.core.key);

// getting the evaluation result and validating it matches the results from SDK
const treatments = action.payload.treatments;
expect(splitSdk.factory.client().getTreatmentsWithConfig).lastCalledWith(['split2'], undefined);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(treatments);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledWith(['split2'], undefined);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toBeCalledWith(['set2'], undefined);
expect(treatments).toEqual({
...(splitSdk.factory.client().getTreatmentsWithConfig as jest.Mock).mock.results[0].value,
...(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets as jest.Mock).mock.results[0].value,
})

expect(splitSdk.factory.client().getTreatmentsWithConfig).toBeCalledTimes(1); // control assertion - getTreatmentsWithConfig calls
expect(getClient(splitSdk).evalOnUpdate).toEqual({}); // control assertion - cbs scheduled for update

// The ADD_TREATMENTS actions is dispatched again, but this time is registered for 'evalOnUpdate'
Expand Down
21 changes: 13 additions & 8 deletions src/__tests__/actions.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,22 +167,27 @@ describe('getTreatments', () => {

// Invoke with a feature flag name string and no attributes
store.dispatch<any>(getTreatments({ key: splitKey, splitNames: 'split1' }));
store.dispatch<any>(getTreatments({ key: splitKey, flagSets: ['set1'] }));

let action = store.getActions()[1]; // action 0 is SPLIT_READY
expect(action.type).toBe(ADD_TREATMENTS);
expect(action.payload.key).toBe(splitKey);
const actions = [store.getActions()[1], store.getActions()[2]]; // action 0 is SPLIT_READY
actions.forEach(action => {
expect(action.type).toBe(ADD_TREATMENTS);
expect(action.payload.key).toBe(splitKey);
});
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith(splitKey, ['split1'], undefined);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(actions[0].payload.treatments);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveBeenLastCalledWith(splitKey, ['set1'], undefined);
expect(splitSdk.factory.client().getTreatmentsWithConfigByFlagSets).toHaveLastReturnedWith(actions[1].payload.treatments);

// Invoke with a list of feature flag names and a attributes object
const featureFlagNames = ['split1', 'split2'];
const attributes = { att1: 'att1' };
store.dispatch<any>(getTreatments({ key: splitKey, splitNames: featureFlagNames, attributes }));
store.dispatch<any>(getTreatments({ key: 'other_user', splitNames: featureFlagNames, attributes }));

action = store.getActions()[2];
const action = store.getActions()[3];
expect(action.type).toBe(ADD_TREATMENTS);
expect(action.payload.key).toBe(splitKey);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith(splitKey, featureFlagNames, attributes);
expect(action.payload.key).toBe('other_user');
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveBeenLastCalledWith('other_user', featureFlagNames, attributes);
expect(splitSdk.factory.client().getTreatmentsWithConfig).toHaveLastReturnedWith(action.payload.treatments);
}

Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/utils/mockBrowserSplitSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export function mockSdk() {
return acc;
}, {});
});
const getTreatmentsWithConfigByFlagSets: jest.Mock = jest.fn((flagSets) => {
return flagSets.reduce((acc: SplitIO.TreatmentsWithConfig, flagSet: string) => {
acc[flagSet + '_feature_flag'] = { treatment: 'fakeTreatment', config: null };
return acc;
}, {});
});
const setAttributes: jest.Mock = jest.fn(() => {
return true;
});
Expand Down Expand Up @@ -89,6 +95,7 @@ export function mockSdk() {

return Object.assign(Object.create(__emitter__), {
getTreatmentsWithConfig,
getTreatmentsWithConfigByFlagSets,
track,
ready,
destroy,
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/utils/mockNodeSplitSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ function mockClient() {
return acc;
}, {});
});
const getTreatmentsWithConfigByFlagSets: jest.Mock = jest.fn((key, flagSets) => {
return flagSets.reduce((acc: SplitIO.TreatmentsWithConfig, flagSet: string) => {
acc[flagSet + '_feature_flag'] = { treatment: 'fakeTreatment', config: null };
return acc;
}, {});
});
const ready: jest.Mock = jest.fn(() => {
return promiseWrapper(new Promise<void>((res, rej) => {
__isReady__ ? res() : __emitter__.on(Event.SDK_READY, res);
Expand All @@ -47,6 +53,7 @@ function mockClient() {

return Object.assign(Object.create(__emitter__), {
getTreatmentsWithConfig,
getTreatmentsWithConfigByFlagSets,
track,
ready,
destroy,
Expand Down
30 changes: 21 additions & 9 deletions src/asyncActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export function initSplitSdk(params: IInitSplitSdkParams): (dispatch: Dispatch<A
*/
function __getTreatments(client: IClientNotDetached, evalParams: IGetTreatmentsParams[]): SplitIO.TreatmentsWithConfig {
return evalParams.reduce((acc, params) => {
return { ...acc, ...client.getTreatmentsWithConfig((params.splitNames as string[]), params.attributes) };
return params.splitNames ?
{ ...acc, ...client.getTreatmentsWithConfig(params.splitNames as string[], params.attributes) } :
{ ...acc, ...client.getTreatmentsWithConfigByFlagSets(params.flagSets as string[], params.attributes) };
}, {});
}

Expand All @@ -112,19 +114,26 @@ export function getTreatments(params: IGetTreatmentsParams): Action | (() => voi
if (!params) return () => { };

const splitNames = params.splitNames as string[];
const flagSets = params.flagSets as string[];

if (!splitSdk.isDetached) { // Split SDK running in Browser

const client = getClient(splitSdk, params.key);

// Register or unregister the current `getTreatments` action from being re-executed on SDK_UPDATE.
if (params.evalOnUpdate) {
splitNames.forEach((featureFlagName) => {
client.evalOnUpdate[featureFlagName] = { ...params, splitNames: [featureFlagName] } as IGetTreatmentsParams;
splitNames && splitNames.forEach((featureFlagName) => {
client.evalOnUpdate[`flag::${featureFlagName}`] = { ...params, splitNames: [featureFlagName] } as IGetTreatmentsParams;
});
flagSets && flagSets.forEach((flagSetName) => {
client.evalOnUpdate[`set::${flagSetName}`] = { ...params, flagSets: [flagSetName] } as IGetTreatmentsParams;
});
} else {
splitNames.forEach((featureFlagName) => {
delete client.evalOnUpdate[featureFlagName];
splitNames && splitNames.forEach((featureFlagName) => {
delete client.evalOnUpdate[`flag::${featureFlagName}`];
});
flagSets && flagSets.forEach((flagSetName) => {
delete client.evalOnUpdate[`set::${flagSetName}`];
});
}

Expand All @@ -146,15 +155,18 @@ export function getTreatments(params: IGetTreatmentsParams): Action | (() => voi
return addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, treatments);
} else {
// Otherwise, it adds control treatments to the store, without calling the SDK (no impressions sent)
// With flag sets, an empty object is passed since we don't know their feature flag names
// @TODO remove eventually to minimize state changes
return addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, getControlTreatmentsWithConfig(splitNames));
return addTreatments(params.key || (splitSdk.config as SplitIO.IBrowserSettings).core.key, splitNames ? getControlTreatmentsWithConfig(splitNames) : {});
}

} else { // Split SDK running in Node

// Evaluate Split and return redux action.
const client = splitSdk.factory.client();
const treatments = client.getTreatmentsWithConfig(params.key, splitNames, params.attributes);
const treatments = splitNames ?
client.getTreatmentsWithConfig(params.key, splitNames, params.attributes) :
client.getTreatmentsWithConfigByFlagSets(params.key, flagSets, params.attributes);
return addTreatments(params.key, treatments);

}
Expand All @@ -167,9 +179,9 @@ interface IClientNotDetached extends SplitIO.IClient {
_trackingStatus?: boolean;
/**
* stored evaluations to execute on SDK update. It is an object because we might
* want to change the evaluation parameters (i.e. attributes) per each feature flag name.
* want to change the evaluation parameters (i.e. attributes) per each feature flag name or flag set.
*/
evalOnUpdate: { [featureFlagName: string]: IGetTreatmentsParams };
evalOnUpdate: { [name: string]: IGetTreatmentsParams };
/**
* stored evaluations to execute when the SDK is ready. It is an array, so if multiple evaluations
* are set with the same feature flag name, the result (i.e. treatment) of the last one is the stored one.
Expand Down
4 changes: 2 additions & 2 deletions types/asyncActions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ interface IClientNotDetached extends SplitIO.IClient {
_trackingStatus?: boolean;
/**
* stored evaluations to execute on SDK update. It is an object because we might
* want to change the evaluation parameters (i.e. attributes) per each feature flag name.
* want to change the evaluation parameters (i.e. attributes) per each feature flag name or flag set.
*/
evalOnUpdate: {
[featureFlagName: string]: IGetTreatmentsParams;
[name: string]: IGetTreatmentsParams;
};
/**
* stored evaluations to execute when the SDK is ready. It is an array, so if multiple evaluations
Expand Down

0 comments on commit f241264

Please sign in to comment.