My feature is enabled
} + {!decision.enabled &&My feature is disabled
} + {decision.variationKey === 'variantion_a' &&Current Variation
} + {decision.variationKey === 'variantion_b' &&Better Variation
} + > + ); } ``` -You can also use `Experiment` component which takes the same props as the hook +You can also use the `Decision` component: ```js -import {Experiment} from '@s-ui/pde' - -const EXPERIMENT_NAME = 'experimentX' +import {Decision} from '@s-ui/pde' const MyComponent = () => { return ( -My feature is enabled
:My feature is disabled
} +The feature 'myFeatureKey' is {isActive ? 'active' : 'inactive'}
-} -``` - -You can also use `Feature` component which takes the following optional props - -- `featureName` -- `attributes` -- `queryString` - -```js -import {Feature} from '@s-ui/pde' - -const MyComponent = () => { - return ( -The feature 'myFeatureKey' is {isActive ? 'active' : 'inactive'}
- )} - - ) -} -``` - -#### Feature Flags Variables - -Returns all feature variables for the specified feature flag - -```js -import {useFeature} from '@s-ui/pde' - -const MyComponent = () => { - const {isActive, variables} = useFeature('myFeatureKey') // variables = an object with all the feature variables - - return ( -- The feature 'myFeatureKey' is{' '} - {isActive ? `active and price value is ${variables.price}` : 'inactive'} -
- ) -} -``` - -#### Segment integration - -By default, segment integration will be active, this means that a global `window.optimizelyClientInstance` reference to the `optimizelyIntance` object passed by to the PDE constructor will be created. In case you want to turn this option off, create the optimizely adapter as follows: - -```js -const optimizelyAdapter = new OptimizelyAdapter({ - optimizely: optimizelyInstance, - userId, - activeIntegrations: {segment: false} -}) -``` - -#### Track Experiment Viewed - -In order to reduce unnecessary calls to Segment, the `Experiment Viewed` event is disabled by default. - -If you need to track how many times your experiment has been viewed, you should set the `shouldTrackExperimentViewed` argument to true. - -```js -const {isActive, variables} = useFeature('myFeatureKey', undefined, undefined, undefined, true) -``` - -A refactoring task is pending to transition the hook's positional parameters to named parameters. - -#### Attributes - -In order to pass by attributes, you'll able to do so by adding the second argument as `attributes` when using the useFeature hook. Something like this: - -```js -import {useFeature} from '@s-ui/pde' - -const MyComponent = () => { - const {isActive} = useFeature('myFeatureKey', { - isLoggedIn: true // this second parameter are the attributes - }) - - returnThe feature 'myFeatureKey' is {isActive ? 'active' : 'inactive'}
-} -``` - -⚠️ Remember that common attributes (those attributes that every experiment should send by) are set with the `applicationAttributes` when creating the optimizely adapter. Check out the [react context section](#React-context) - -#### Force feature flag to be on/off - -It's slighty different to force a feature flag to be activated or deactivated. Lets assume we have our feature flag `ff_skills_field` running under `http://myweb.com`. In order to force the flag to be on or off you'll have to add a query param using the flag's name but adding `suipde_` as prefix same way we force an experiment, but the only valid values are on or off. For example, in this case, the url to open in order to force would be `http://myweb.com?suipde_ff_skills_field=on`. This would force the feature flag to be on. `http://myweb.com?suipde_ff_skills_field=off` would set the feature flag as off. If forced, optimizely impression will not be triggered. - -### Multiple Optimizely Adapters - -Meant to exist if you need more than one decision taking optimizely sdk. - -When initializing PDE use `MultipleOptimizelyAdapter` instead of `OptimizelyAdapter` -```js - import MultipleOptimizelyAdapter from '@s-ui/pde/lib/adapters/optimizely/multiple' -... - const optimizelyInstances = MultipleOptimizelyAdapter.createMultipleOptimizelyInstances({ - default: { - sdkKey: DEFAULT_INSTANCE_SDK_KEY, - options: {} // options for default instance - }, - alternate: { - sdkKey: ALTERNATIVE_INSTANCE_SDK_KEY, - options: {} // options for alternative instance - } - }) - - // first id will be used as default adapterId, in this case 'default' but is open to any id - const optimizelyAdapter = new MultipleOptimizelyAdapter({ - default: { - optimizely: optimizelyInstances.default, - ...adapterOptions // like creating single adapter - }, - alternate: { - optimizely: optimizelyInstances.alternative, - ...adapterOptions // like creating single adapter - } - }) - - const pde = new PDE({ - adapter: optimizelyAdapter, - ... - }) -``` - -Using the hooks - -```js -const MyComponent = () => { - const defaultFeature = useFeature('myFeatureKey') // will return the {isActive, variables} object from the default optimizely instance - const alsoDefaultFeature = useFeature('myFeatureKey', null, null, 'default') // will return the {isActive, variables} object from the default optimizely instance - const alternateFeature = useFeature('myFeatureKey', null, null, 'alternative') // will return the {isActive, variables} object from the alternate optimizely instance - - const defaultExperiment = useExperiment({experimentName: 'myExperimentName'}) // will return the experiment object from the default optimizely instance - const alsoDefaultExperiment = useExperiment({experimentName: 'myExperimentName', adapterId: 'default'}) // will return the experiment object from the default optimizely instance - const alternateExperiment = useExperiment({experimentName: 'myExperimentName', adapterId: 'alternate'}) // will return the experiment object from the alternate optimizely instance - ... } ``` -#### :warning: Using segment integration +#### Forcing a decision -Regarding to [Segment documentation](https://segment.com/docs/connections/destinations/catalog/optimizely-web/#optimizely-full-stack-javascript-sdk) +You can force specific decision outcomes during testing by adding a query parameter. -Segment expects a single `window.optimizelyClientInstance` to exist in the browser, so when using multiple optimizely instances, events from multiple instances will be sent to a single Segment source, so the Segment destinations should be properly configured having this in consideration. +- `http://www.fotocasa.es/es?suipde_example=on` will enable the `example` feature test +- `http://www.fotocasa.es/es?suipde_example=off` will disable the `example` feature test +- `http://www.fotocasa.es/es?suipde_example=variation_a` will enable the `example` feature test and will force the variation `variation_a` \ No newline at end of file diff --git a/packages/sui-pde/package.json b/packages/sui-pde/package.json index 9ce8e0941..916c1417a 100644 --- a/packages/sui-pde/package.json +++ b/packages/sui-pde/package.json @@ -17,7 +17,7 @@ "author": "", "license": "MIT", "dependencies": { - "@optimizely/optimizely-sdk": "4.9.4", + "@optimizely/optimizely-sdk": "5.3.4", "@s-ui/js": "2" }, "peerDependencies": { diff --git a/packages/sui-pde/src/adapters/default.js b/packages/sui-pde/src/adapters/default.js index b5c8eb6a0..c89c0f185 100644 --- a/packages/sui-pde/src/adapters/default.js +++ b/packages/sui-pde/src/adapters/default.js @@ -15,6 +15,10 @@ export default class DefaultAdapter { return null } + decide() { + return null + } + updateConsents() { return null } diff --git a/packages/sui-pde/src/adapters/optimizely/index.js b/packages/sui-pde/src/adapters/optimizely/index.js index 1f784d3d5..3ba274c50 100644 --- a/packages/sui-pde/src/adapters/optimizely/index.js +++ b/packages/sui-pde/src/adapters/optimizely/index.js @@ -62,12 +62,14 @@ export default class OptimizelyAdapter { sdkKey = undefined } + const isServer = typeof window === 'undefined' const optimizelyInstance = optimizely.createInstance({ sdkKey, datafileOptions: options, datafile, eventDispatcher, - ...DEFAULT_EVENTS_OPTIONS + ...DEFAULT_EVENTS_OPTIONS, + defaultDecideOptions: isServer ? [optimizely.OptimizelyDecideOption.DISABLE_DECISION_EVENT] : [] }) return optimizelyInstance @@ -117,6 +119,23 @@ export default class OptimizelyAdapter { }) } + /** + * @param {Object} params + * @param {string} params.name + * @param {object} [params.attributes] + * @returns {string=} variation name + */ + decide({name, attributes}) { + if (!this._hasUserConsents) return null + + const user = this._optimizely.createUserContext(this._userId, { + ...this._applicationAttributes, + ...attributes + }) + + return user.decide(name) + } + /** * Gets the variation without tracking the impression * @param {Object} params diff --git a/packages/sui-pde/src/adapters/optimizely/multiple.js b/packages/sui-pde/src/adapters/optimizely/multiple.js index a259a7a00..741afcb08 100644 --- a/packages/sui-pde/src/adapters/optimizely/multiple.js +++ b/packages/sui-pde/src/adapters/optimizely/multiple.js @@ -60,6 +60,10 @@ class MultipleOptimizelyAdapter { return this.#adapters[adapterId].activateExperiment(props) } + decide({adapterId = defaultAdapterId, ...props}) { + return this.#adapters[adapterId].decide(props) + } + getVariation({adapterId = defaultAdapterId, ...props}) { return this.#adapters[adapterId].getVariation(props) } diff --git a/packages/sui-pde/src/components/decision.js b/packages/sui-pde/src/components/decision.js new file mode 100644 index 000000000..c2b698bc0 --- /dev/null +++ b/packages/sui-pde/src/components/decision.js @@ -0,0 +1,24 @@ +import PropTypes from 'prop-types' + +import useDecision from '../hooks/useDecision.js' + +export default function Decision({adapterId, name, attributes, trackExperimentViewed, queryString, children}) { + const data = useDecision(name, { + attributes, + trackExperimentViewed, + queryString, + adapterId + }) + + return children(data) +} + +Decision.propTypes = { + name: PropTypes.string.isRequired, + attributes: PropTypes.object, + trackExperimentViewed: PropTypes.func, + queryString: PropTypes.string, + children: PropTypes.func, + adapterId: PropTypes.string +} +Decision.displayName = 'Decision' diff --git a/packages/sui-pde/src/hooks/common/platformStrategies.js b/packages/sui-pde/src/hooks/common/platformStrategies.js index f56b4a540..c8a6842a5 100644 --- a/packages/sui-pde/src/hooks/common/platformStrategies.js +++ b/packages/sui-pde/src/hooks/common/platformStrategies.js @@ -6,6 +6,9 @@ const getServerStrategy = () => ({ getVariation: ({pde, experimentName, attributes, adapterId}) => { return pde.getVariation({pde, name: experimentName, attributes, adapterId}) }, + decide: ({pde, name, attributes, adapterId}) => { + return pde.decide({pde, name, attributes, adapterId}) + }, trackExperiment: () => {}, getForcedValue: ({key, queryString}) => { if (!queryString) { @@ -27,6 +30,9 @@ const getBrowserStrategy = ({customTrackExperimentViewed, cache}) => ({ return variationName }, + decide: ({pde, name, attributes, adapterId}) => { + return pde.decide({pde, name, attributes, adapterId}) + }, trackExperiment: ({variationName, experimentName}) => { if (customTrackExperimentViewed) { return customTrackExperimentViewed({variationName, experimentName}) diff --git a/packages/sui-pde/src/hooks/useDecision.js b/packages/sui-pde/src/hooks/useDecision.js new file mode 100644 index 000000000..41bce419f --- /dev/null +++ b/packages/sui-pde/src/hooks/useDecision.js @@ -0,0 +1,64 @@ +import {useContext, useMemo} from 'react' + +import PdeContext from '../contexts/PdeContext.js' +import {getPlatformStrategy} from './common/platformStrategies.js' + +/** + * Hook to use a feature test + * @param {string} name + * @param {object} param + * @param {object} param.attributes + * @param {function} param.trackExperimentViewed + * @param {string} param.queryString + * @param {string} param.adapterId Adapter id to be executed + * @return {object} + */ +export default function useDecision(name, {attributes, trackExperimentViewed, queryString, adapterId} = {}) { + const {pde} = useContext(PdeContext) + + if (pde === null) { + throw new Error('[sui-pde: useDecision] sui-pde provider is required to work') + } + + const data = useMemo(() => { + try { + const strategy = getPlatformStrategy({ + customTrackExperimentViewed: trackExperimentViewed + }) + + const forced = strategy.getForcedValue({ + key: name, + queryString + }) + + if (forced) { + if (['on', 'off'].includes(forced)) { + return {enabled: forced === 'on', flagKey: name} + } + + return {enabled: true, flagKey: name, variationKey: forced} + } + + const data = strategy.decide({ + pde, + name, + attributes, + adapterId + }) + + const {ruleKey, variationKey} = data || {} + + const isExperiment = !!ruleKey + + if (isExperiment) { + strategy.trackExperiment({variationName: variationKey, experimentName: ruleKey}) + } + + return data + } catch (error) { + return {enabled: false, flagKey: name} + } + }, [trackExperimentViewed, name, queryString, pde, attributes, adapterId]) + + return data +} diff --git a/packages/sui-pde/src/index.js b/packages/sui-pde/src/index.js index 3401602ea..d23c8f35f 100644 --- a/packages/sui-pde/src/index.js +++ b/packages/sui-pde/src/index.js @@ -2,5 +2,7 @@ export {default as PDE} from './pde.js' export {default as useFeature} from './hooks/useFeature.js' export {default as PdeContext} from './contexts/PdeContext.js' export {default as useExperiment} from './hooks/useExperiment.js' +export {default as useDecision} from './hooks/useDecision.js' export {default as Experiment} from './components/experiment.js' export {default as Feature} from './components/feature.js' +export {default as Decision} from './components/decision.js' diff --git a/packages/sui-pde/src/pde.js b/packages/sui-pde/src/pde.js index e0bf815af..9d4298d85 100644 --- a/packages/sui-pde/src/pde.js +++ b/packages/sui-pde/src/pde.js @@ -28,6 +28,15 @@ export default class PDE { return this._adapter.activateExperiment({name, attributes, adapterId}) } + /** + * @param {object} param + * @param {string} param.name + * @param {object} param.attributes + */ + decide({name, attributes, adapterId}) { + return this._adapter.decide({name, attributes, adapterId}) + } + getInitialData() { return this._adapter.getInitialData() } diff --git a/packages/sui-pde/test/common/index.js b/packages/sui-pde/test/common/index.js index 8de254ec2..4839d40d0 100644 --- a/packages/sui-pde/test/common/index.js +++ b/packages/sui-pde/test/common/index.js @@ -1,3 +1,4 @@ import './pdeSpec.js' -import './useExperimentSpec' // This file has no extension due to sui-test server problem +import './useExperimentSpec.js' import './useFeatureSpec.js' +import './useDecisionSpec.js' diff --git a/packages/sui-pde/test/common/useDecisionSpec.js b/packages/sui-pde/test/common/useDecisionSpec.js new file mode 100644 index 000000000..7ab093891 --- /dev/null +++ b/packages/sui-pde/test/common/useDecisionSpec.js @@ -0,0 +1,337 @@ +/* eslint-disable no-console */ +import {expect} from 'chai' +import sinon from 'sinon' + +import {cleanup, renderHook} from '@testing-library/react-hooks' +import {descriptorsByEnvironmentPatcher} from '@s-ui/test/lib/descriptor-environment-patcher.js' + +import PdeContext from '../../src/contexts/PdeContext.js' +import {SESSION_STORAGE_KEY as PDE_CACHE_STORAGE_KEY} from '../../src/hooks/common/trackedEventsLocalCache.js' +import useDecision from '../../src/hooks/useDecision.js' + +descriptorsByEnvironmentPatcher() + +describe('useDecision hook', () => { + afterEach(() => { + cleanup() + if (typeof window === 'undefined') return + window.sessionStorage.removeItem(PDE_CACHE_STORAGE_KEY) + }) + + describe('when no pde context is set', () => { + it('should throw an error', () => { + const {result} = renderHook(() => useDecision()) + expect(result.error).to.not.be.null + }) + }) + + describe('when pde context is set', () => { + let wrapper + let decide + + before(() => { + decide = sinon.stub().returns({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + // eslint-disable-next-line react/prop-types + wrapper = ({children}) => ( +