Skip to content

Commit d5b25a9

Browse files
authored
feat(decide): Added useDecide hook (#100)
## Summary Added a hook which used `decide` api and returns `OptimizelyDecision` object. It also supports auto updating just like other existing hooks ## Test Plan - Manually tested thoroughly - Added new unit tests
1 parent c796b6f commit d5b25a9

File tree

4 files changed

+386
-7
lines changed

4 files changed

+386
-7
lines changed

src/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,20 +141,20 @@ export interface ReactSDKClient extends Omit<optimizely.Client, 'createUserConte
141141
options?: optimizely.OptimizelyDecideOption[],
142142
overrideUserId?: string,
143143
overrideAttributes?: optimizely.UserAttributes
144-
): OptimizelyDecision | null
144+
): OptimizelyDecision
145145

146146
decideAll(
147147
options?: optimizely.OptimizelyDecideOption[],
148148
overrideUserId?: string,
149149
overrideAttributes?: optimizely.UserAttributes
150-
): { [key: string]: OptimizelyDecision } | null
150+
): { [key: string]: OptimizelyDecision }
151151

152152
decideForKeys(
153153
keys: string[],
154154
options?: optimizely.OptimizelyDecideOption[],
155155
overrideUserId?: string,
156156
overrideAttributes?: optimizely.UserAttributes
157-
): { [key: string]: OptimizelyDecision } | null
157+
): { [key: string]: OptimizelyDecision }
158158
}
159159

160160
export const DEFAULT_ON_READY_TIMEOUT = 5000;

src/hooks.spec.tsx

Lines changed: 294 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,21 @@ import { act } from 'react-dom/test-utils';
2020

2121
import { OptimizelyProvider } from './Provider';
2222
import { OnReadyResult, ReactSDKClient, VariableValuesObject } from './client';
23-
import { useExperiment, useFeature } from './hooks';
23+
import { useExperiment, useFeature, useDecide } from './hooks';
24+
import { OptimizelyDecision } from './utils';
2425

2526
Enzyme.configure({ adapter: new Adapter() });
2627

28+
const defaultDecision: OptimizelyDecision = {
29+
enabled: false,
30+
variables: {},
31+
flagKey: '',
32+
reasons: [],
33+
ruleKey: '',
34+
userContext: { id: null },
35+
variationKey: '',
36+
};
37+
2738
const MyFeatureComponent = ({ options = {}, overrides = {} }: any) => {
2839
const [isEnabled, variables, clientReady, didTimeout] = useFeature('feature1', { ...options }, { ...overrides });
2940
return <>{`${isEnabled ? 'true' : 'false'}|${JSON.stringify(variables)}|${clientReady}|${didTimeout}`}</>;
@@ -34,6 +45,11 @@ const MyExperimentComponent = ({ options = {}, overrides = {} }: any) => {
3445
return <>{`${variation}|${clientReady}|${didTimeout}`}</>;
3546
};
3647

48+
const MyDecideComponent = ({ options = {}, overrides = {} }: any) => {
49+
const [decision, clientReady, didTimeout] = useDecide('feature1', { ...options }, { ...overrides });
50+
return <>{`${(decision.enabled) ? 'true' : 'false'}|${JSON.stringify(decision.variables)}|${clientReady}|${didTimeout}`}</>;
51+
};
52+
3753
const mockFeatureVariables: VariableValuesObject = {
3854
foo: 'bar',
3955
};
@@ -50,8 +66,10 @@ describe('hooks', () => {
5066
let userUpdateCallbacks: Array<() => void>;
5167
let UseExperimentLoggingComponent: React.FunctionComponent<any>;
5268
let UseFeatureLoggingComponent: React.FunctionComponent<any>;
69+
let UseDecideLoggingComponent: React.FunctionComponent<any>;
5370
let mockLog: jest.Mock;
5471
let forcedVariationUpdateCallbacks: Array<() => void>;
72+
let decideMock: jest.Mock<OptimizelyDecision>;
5573

5674
beforeEach(() => {
5775
getOnReadyPromise = ({ timeout = 0 }: any): Promise<OnReadyResult> =>
@@ -78,6 +96,7 @@ describe('hooks', () => {
7896
readySuccess = true;
7997
notificationListenerCallbacks = [];
8098
forcedVariationUpdateCallbacks = [];
99+
decideMock = jest.fn();
81100

82101
optimizelyMock = ({
83102
activate: activateMock,
@@ -104,6 +123,7 @@ describe('hooks', () => {
104123
return () => {};
105124
}),
106125
getForcedVariations: jest.fn().mockReturnValue({}),
126+
decide: decideMock,
107127
} as unknown) as ReactSDKClient;
108128

109129
mockLog = jest.fn();
@@ -118,6 +138,12 @@ describe('hooks', () => {
118138
mockLog(isEnabled);
119139
return <div>{isEnabled}</div>;
120140
};
141+
142+
UseDecideLoggingComponent = ({ options = {}, overrides = {} }: any) => {
143+
const [decision] = useDecide('feature1', { ...options }, { ...overrides });
144+
mockLog(decision.enabled);
145+
return <div>{decision.enabled}</div>;
146+
};
121147
});
122148

123149
afterEach(async () => {
@@ -641,4 +667,271 @@ describe('hooks', () => {
641667
expect(isFeatureEnabledMock).not.toHaveBeenCalled();
642668
});
643669
});
670+
671+
describe('useDecide', () => {
672+
it('should render true when the flag is enabled', async () => {
673+
decideMock.mockReturnValue({
674+
... defaultDecision,
675+
enabled: true,
676+
variables: { 'foo': 'bar' },
677+
});
678+
const component = Enzyme.mount(
679+
<OptimizelyProvider optimizely={optimizelyMock}>
680+
<MyDecideComponent />
681+
</OptimizelyProvider>
682+
);
683+
await optimizelyMock.onReady();
684+
component.update();
685+
expect(component.text()).toBe('true|{"foo":"bar"}|true|false');
686+
});
687+
688+
it('should render false when the flag is disabled', async () => {
689+
decideMock.mockReturnValue({
690+
... defaultDecision,
691+
enabled: false,
692+
variables: { 'foo': 'bar' },
693+
});
694+
const component = Enzyme.mount(
695+
<OptimizelyProvider optimizely={optimizelyMock}>
696+
<MyDecideComponent />
697+
</OptimizelyProvider>
698+
);
699+
await optimizelyMock.onReady();
700+
component.update();
701+
expect(component.text()).toBe('false|{"foo":"bar"}|true|false');
702+
});
703+
704+
it('should respect the timeout option passed', async () => {
705+
decideMock.mockReturnValue({ ... defaultDecision });
706+
readySuccess = false;
707+
708+
const component = Enzyme.mount(
709+
<OptimizelyProvider optimizely={optimizelyMock}>
710+
<MyDecideComponent options={{ timeout: mockDelay }} />
711+
</OptimizelyProvider>
712+
);
713+
expect(component.text()).toBe('false|{}|false|false');
714+
715+
await optimizelyMock.onReady();
716+
component.update();
717+
expect(component.text()).toBe('false|{}|false|true');
718+
719+
// Simulate datafile fetch completing after timeout has already passed
720+
// flag is now true and decision contains variables
721+
decideMock.mockReturnValue({
722+
... defaultDecision,
723+
enabled: true,
724+
variables: { 'foo': 'bar' },
725+
});
726+
727+
await optimizelyMock.onReady().then(res => res.dataReadyPromise);
728+
component.update();
729+
730+
// Simulate datafile fetch completing after timeout has already passed
731+
// Wait for completion of dataReadyPromise
732+
await optimizelyMock.onReady().then(res => res.dataReadyPromise);
733+
component.update();
734+
735+
expect(component.text()).toBe('true|{"foo":"bar"}|true|true'); // when clientReady
736+
});
737+
738+
it('should gracefully handle the client promise rejecting after timeout', async () => {
739+
console.log('hola')
740+
readySuccess = false;
741+
decideMock.mockReturnValue({ ... defaultDecision });
742+
getOnReadyPromise = () =>
743+
new Promise((res, rej) => {
744+
setTimeout(() => rej('some error with user'), mockDelay);
745+
});
746+
const component = Enzyme.mount(
747+
<OptimizelyProvider optimizely={optimizelyMock}>
748+
<MyDecideComponent options={{ timeout: mockDelay }} />
749+
</OptimizelyProvider>
750+
);
751+
expect(component.text()).toBe('false|{}|false|false'); // initial render
752+
await new Promise(r => setTimeout(r, mockDelay * 3));
753+
component.update();
754+
expect(component.text()).toBe('false|{}|false|false');
755+
});
756+
757+
it('should re-render when the user attributes change using autoUpdate', async () => {
758+
decideMock.mockReturnValue({ ...defaultDecision });
759+
const component = Enzyme.mount(
760+
<OptimizelyProvider optimizely={optimizelyMock}>
761+
<MyDecideComponent options={{ autoUpdate: true }} />
762+
</OptimizelyProvider>
763+
);
764+
765+
// TODO - Wrap this with async act() once we upgrade to React 16.9
766+
// See https://github.com/facebook/react/issues/15379
767+
await optimizelyMock.onReady();
768+
component.update();
769+
expect(component.text()).toBe('false|{}|true|false');
770+
771+
decideMock.mockReturnValue({
772+
...defaultDecision,
773+
enabled: true,
774+
variables: { 'foo': 'bar' }
775+
});
776+
// Simulate the user object changing
777+
act(() => {
778+
userUpdateCallbacks.forEach(fn => fn());
779+
});
780+
component.update();
781+
expect(component.text()).toBe('true|{"foo":"bar"}|true|false');
782+
});
783+
784+
it('should not re-render when the user attributes change without autoUpdate', async () => {
785+
decideMock.mockReturnValue({ ...defaultDecision });
786+
const component = Enzyme.mount(
787+
<OptimizelyProvider optimizely={optimizelyMock}>
788+
<MyDecideComponent />
789+
</OptimizelyProvider>
790+
);
791+
792+
// TODO - Wrap this with async act() once we upgrade to React 16.9
793+
// See https://github.com/facebook/react/issues/15379
794+
await optimizelyMock.onReady();
795+
component.update();
796+
expect(component.text()).toBe('false|{}|true|false');
797+
798+
decideMock.mockReturnValue({
799+
...defaultDecision,
800+
enabled: true,
801+
variables: { 'foo': 'bar' }
802+
});
803+
// Simulate the user object changing
804+
act(() => {
805+
userUpdateCallbacks.forEach(fn => fn());
806+
});
807+
component.update();
808+
expect(component.text()).toBe('false|{}|true|false');
809+
});
810+
811+
it('should return the decision immediately on the first call when the client is already ready', async () => {
812+
readySuccess = true;
813+
decideMock.mockReturnValue({ ...defaultDecision });
814+
const component = Enzyme.mount(
815+
<OptimizelyProvider optimizely={optimizelyMock}>
816+
<UseDecideLoggingComponent />
817+
</OptimizelyProvider>
818+
);
819+
component.update();
820+
expect(mockLog).toHaveBeenCalledTimes(1);
821+
expect(mockLog).toHaveBeenCalledWith(false);
822+
});
823+
824+
it('should re-render after the client becomes ready', async () => {
825+
readySuccess = false;
826+
let resolveReadyPromise: (result: { success: boolean; dataReadyPromise: Promise<any> }) => void;
827+
const readyPromise: Promise<any> = new Promise(res => {
828+
resolveReadyPromise = (result): void => {
829+
readySuccess = true;
830+
res(result);
831+
};
832+
});
833+
getOnReadyPromise = (): Promise<any> => readyPromise;
834+
decideMock.mockReturnValue({ ...defaultDecision });
835+
836+
const component = Enzyme.mount(
837+
<OptimizelyProvider optimizely={optimizelyMock}>
838+
<UseDecideLoggingComponent />
839+
</OptimizelyProvider>
840+
);
841+
component.update();
842+
843+
expect(mockLog).toHaveBeenCalledTimes(1);
844+
expect(mockLog).toHaveBeenCalledWith(false);
845+
846+
mockLog.mockReset();
847+
848+
// Simulate datafile fetch completing after timeout has already passed
849+
// decision now returns true
850+
decideMock.mockReturnValue({ ...defaultDecision, enabled: true });
851+
// Wait for completion of dataReadyPromise
852+
const dataReadyPromise = Promise.resolve();
853+
resolveReadyPromise!({ success: true, dataReadyPromise });
854+
await dataReadyPromise;
855+
component.update();
856+
857+
expect(mockLog).toHaveBeenCalledTimes(1);
858+
expect(mockLog).toHaveBeenCalledWith(true);
859+
});
860+
861+
it('should re-render after updating the override user ID argument', async () => {
862+
decideMock.mockReturnValue({ ...defaultDecision });
863+
const component = Enzyme.mount(
864+
<OptimizelyProvider optimizely={optimizelyMock}>
865+
<MyDecideComponent options={{ autoUpdate: true }} />
866+
</OptimizelyProvider>
867+
);
868+
869+
component.update();
870+
expect(component.text()).toBe('false|{}|true|false');
871+
872+
decideMock.mockReturnValue({ ...defaultDecision, enabled: true });
873+
component.setProps({
874+
children: <MyDecideComponent options={{ autoUpdate: true }} overrides={{ overrideUserId: 'matt' }} />,
875+
});
876+
component.update();
877+
expect(component.text()).toBe('true|{}|true|false');
878+
});
879+
880+
it('should re-render after updating the override user attributes argument', async () => {
881+
decideMock.mockReturnValue({ ...defaultDecision });
882+
const component = Enzyme.mount(
883+
<OptimizelyProvider optimizely={optimizelyMock}>
884+
<MyDecideComponent options={{ autoUpdate: true }} />
885+
</OptimizelyProvider>
886+
);
887+
888+
component.update();
889+
expect(component.text()).toBe('false|{}|true|false');
890+
891+
decideMock.mockReturnValue({ ...defaultDecision, enabled: true });
892+
component.setProps({
893+
children: (
894+
<MyDecideComponent options={{ autoUpdate: true }} overrides={{ overrideAttributes: { my_attr: 'x' } }} />
895+
),
896+
});
897+
component.update();
898+
expect(component.text()).toBe('true|{}|true|false');
899+
900+
decideMock.mockReturnValue({ ...defaultDecision, enabled: false, variables: { myvar: 3 } });
901+
component.setProps({
902+
children: (
903+
<MyDecideComponent
904+
options={{ autoUpdate: true }}
905+
overrides={{ overrideAttributes: { my_attr: 'z', other_attr: 25 } }}
906+
/>
907+
),
908+
});
909+
component.update();
910+
expect(component.text()).toBe('false|{"myvar":3}|true|false');
911+
});
912+
913+
it('should not recompute the decision when passed the same override attributes', async () => {
914+
decideMock.mockReturnValue({ ...defaultDecision });
915+
const component = Enzyme.mount(
916+
<OptimizelyProvider optimizely={optimizelyMock}>
917+
<UseDecideLoggingComponent
918+
options={{ autoUpdate: true }}
919+
overrides={{ overrideAttributes: { other_attr: 'y' } }}
920+
/>
921+
</OptimizelyProvider>
922+
);
923+
expect(decideMock).toHaveBeenCalledTimes(1);
924+
decideMock.mockReset();
925+
component.setProps({
926+
children: (
927+
<UseDecideLoggingComponent
928+
options={{ autoUpdate: true }}
929+
overrides={{ overrideAttributes: { other_attr: 'y' } }}
930+
/>
931+
),
932+
});
933+
component.update();
934+
expect(decideMock).not.toHaveBeenCalled();
935+
});
936+
});
644937
});

0 commit comments

Comments
 (0)