diff --git a/package-lock.json b/package-lock.json index 69251490f..46a3706fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4132,40 +4132,26 @@ "integrity": "sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==", "license": "MIT" }, - "node_modules/@optimizely/js-sdk-datafile-manager": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.9.5.tgz", - "integrity": "sha512-O4ujr1nBBAQBtx8YoKNpzzaEZgsE+aU4dxubT17ePqv/YVUWE+JOY21tSRrqZy/BlbbyzL+ElT8hrGB5ZzVoIQ==", - "license": "Apache-2.0", + "node_modules/@optimizely/optimizely-sdk": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@optimizely/optimizely-sdk/-/optimizely-sdk-5.3.4.tgz", + "integrity": "sha512-N9BVFBoWY//cgrZu4dnUCXbbvFtx8bJURvsvQurCqdKn0pqAawDbWpm4mDTl8H3W5J4fXC5s+8xlDywiGHCY6Q==", "dependencies": { - "@optimizely/js-sdk-logging": "^0.3.1", - "@optimizely/js-sdk-utils": "^0.4.0", - "decompress-response": "^4.2.1" + "decompress-response": "^4.2.1", + "json-schema": "^0.4.0", + "murmurhash": "^2.0.1", + "ua-parser-js": "^1.0.37", + "uuid": "^9.0.1" }, "engines": { - "node": ">=8.0.0" - }, - "peerDependencies": { - "@react-native-async-storage/async-storage": "^1.2.0" - }, - "peerDependenciesMeta": { - "@react-native-async-storage/async-storage": { - "optional": true - } - } - }, - "node_modules/@optimizely/js-sdk-event-processor": { - "version": "0.9.5", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.9.5.tgz", - "integrity": "sha512-g5zqAjJuexxgbNvn7dacFkQXQxH3+OtjELfmSswvhxP9EHkyNR0ZdQF/kBxFxr335F2/RRPvAJ9tQBPkwaBg8g==", - "license": "Apache-2.0", - "dependencies": { - "@optimizely/js-sdk-logging": "^0.3.1", - "@optimizely/js-sdk-utils": "^0.4.0" + "node": ">=14.0.0" }, "peerDependencies": { + "@babel/runtime": "^7.0.0", "@react-native-async-storage/async-storage": "^1.2.0", - "@react-native-community/netinfo": "5.9.4" + "@react-native-community/netinfo": "^11.3.2", + "fast-text-encoding": "^1.0.6", + "react-native-get-random-values": "^1.11.0" }, "peerDependenciesMeta": { "@react-native-async-storage/async-storage": { @@ -4173,42 +4159,38 @@ }, "@react-native-community/netinfo": { "optional": true + }, + "fast-text-encoding": { + "optional": true + }, + "react-native-get-random-values": { + "optional": true } } }, - "node_modules/@optimizely/js-sdk-logging": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz", - "integrity": "sha512-K71Jf283FP0E4oXehcXTTM3gvgHZHr7FUrIsw//0mdJlotHJT4Nss4hE0CWPbBxO7LJAtwNnO+VIA/YOcO4vHg==", - "license": "Apache-2.0", - "dependencies": { - "@optimizely/js-sdk-utils": "^0.4.0" - } - }, - "node_modules/@optimizely/js-sdk-utils": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-utils/-/js-sdk-utils-0.4.0.tgz", - "integrity": "sha512-QG2oytnITW+VKTJK+l0RxjaS5VrA6W+AZMzpeg4LCB4Rn4BEKtF+EcW/5S1fBDLAviGq/0TLpkjM3DlFkJ9/Gw==", - "license": "Apache-2.0", - "dependencies": { - "uuid": "^3.3.2" - } - }, - "node_modules/@optimizely/optimizely-sdk": { - "version": "4.9.4", - "resolved": "https://registry.npmjs.org/@optimizely/optimizely-sdk/-/optimizely-sdk-4.9.4.tgz", - "integrity": "sha512-aYxndR6RahnLdX7SQR1YO2dklfNjbCGUUvRaYJZ50LIsDqhkAu426vxHwYO+V+QJxqipypPG5SVdG1m32AgDvw==", - "license": "Apache-2.0", - "dependencies": { - "@optimizely/js-sdk-datafile-manager": "^0.9.5", - "@optimizely/js-sdk-event-processor": "^0.9.2", - "@optimizely/js-sdk-logging": "^0.3.1", - "json-schema": "^0.4.0", - "murmurhash": "0.0.2", - "uuid": "^3.3.2" + "node_modules/@optimizely/optimizely-sdk/node_modules/ua-parser-js": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.39.tgz", + "integrity": "sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "bin": { + "ua-parser-js": "script/cli.js" }, "engines": { - "node": ">=8.0.0" + "node": "*" } }, "node_modules/@pact-foundation/pact": { @@ -10175,7 +10157,6 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", - "license": "MIT", "dependencies": { "mimic-response": "^2.0.0" }, @@ -20281,7 +20262,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", - "license": "MIT", "engines": { "node": ">=8" }, @@ -20911,12 +20891,9 @@ } }, "node_modules/murmurhash": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-0.0.2.tgz", - "integrity": "sha512-LKlwdZKWzvCQpMszb2HO5leJ7P9T4m5XuDKku8bM0uElrzqK9cn0+iozwQS8jO4SNjrp4w7olalgd8WgsIjhWA==", - "engines": { - "node": "*" - } + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-2.0.1.tgz", + "integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==" }, "node_modules/mute-stream": { "version": "0.0.8", @@ -26852,13 +26829,15 @@ } }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "license": "MIT", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/v8-compile-cache": { @@ -28749,7 +28728,7 @@ }, "packages/sui-jest": { "name": "@s-ui/jest", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@swc/jest": "0.2.24", @@ -28893,7 +28872,7 @@ }, "packages/sui-js": { "name": "@s-ui/js", - "version": "2.34.0", + "version": "2.35.0", "license": "MIT", "dependencies": { "bowser": "2.11.0", @@ -28954,7 +28933,7 @@ }, "packages/sui-lint": { "name": "@s-ui/lint", - "version": "4.49.0", + "version": "4.50.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -29400,7 +29379,7 @@ "version": "2.25.0", "license": "MIT", "dependencies": { - "@optimizely/optimizely-sdk": "4.9.4", + "@optimizely/optimizely-sdk": "5.3.4", "@s-ui/js": "2" }, "devDependencies": { @@ -29659,7 +29638,7 @@ }, "packages/sui-segment-wrapper": { "name": "@s-ui/segment-wrapper", - "version": "4.1.0", + "version": "4.3.0", "license": "ISC", "dependencies": { "@s-ui/js": "2", diff --git a/packages/sui-pde/README.md b/packages/sui-pde/README.md index 04ed7354b..8aced1416 100644 --- a/packages/sui-pde/README.md +++ b/packages/sui-pde/README.md @@ -62,261 +62,63 @@ const pde = new PDE({ When client-side rendering, sui-pde will load the datafile saved as `window.__INITIAL_CONTEXT_VALUE__.pde` as initial datafile. Therefore, you'll need to inject the output of the `pde.getInitialContextData()` function in your html when server side rendering. -### Experiments +### Feature Test -Given experiment `experimentX` with 2 variations `variationA` and `variationB` render `MyVariationA` or `MyVariationB` component depending on the variation the user has being assigned. Render `MyVariationA` by default +Feature tests are similar to A/B/n tests that allow you to control whether for each variation the associated feature is on or off via feature flags (aka feature toggles). It also allows you to control the feature variable values for the various variables associated with the feature. -⚠️ If the user did not consent to or if optimizely decides that the user will not be part of the experiment of something goes wrong, `useExperiment` will return as variation value `null` - -⚠️ The `useExperiment` hook will call a global window.analytics.track method with `Experiment Viewed` as event name with the experiment properties so you are able to replicate the experiment in your analytics tool +The `useDecision` hook retrieves the result for a given decision, based on logic from the decision-making tool you are using (e.g., Optimizely). ```js -import {useExperiment} from '@s-ui/pde' - -const EXPERIMENT_NAME = 'experimentX' +import {useDecision} from '@s-ui/pde' -const MyComponent = () => { - const {variation} = useExperiment({experimentName: EXPERIMENT_NAME}) +function Component() { + const decision = useDecision('feature_test', {}) - if (variation === 'variationB') return - return + return ( + <> + {decision.enabled &&

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 ( - - {({variation}) => variation === 'variationB' ? : } - + + {({enabled}) => enabled ?

My feature is enabled

:

My feature is disabled

} +
) } ``` -**Special cases for useExperiment `Experiment Viewed` track** - -Given useExperiment sends `Experiment Viewed` on being executed, some facts could happen: - -- Root: Analytics SDK is loaded async and loads after useExperiment hook has been called -- Cause: `Experiment Viewed` won't be sent. - -- Root: `Experiment Viewed` should has a different name or properties. -- Cause: Send a track with wrong values. - -In order to have a higher controll about that, useExperiment accepts a `trackExperimentViewed` callback to customize it - -```js -import {useExperiment} from '@s-ui/pde' - -const EXPERIMENT_NAME = 'experimentX' - -const trackExperiment = ({experimentName, variationName}) => { - window.analytics.track('Experiment Viewed', { - experimentName, - variationName, - customProperty: 'yay' - }) -} - -const MyComponent = () => { - const {variation} = useExperiment({ - experimentName: EXPERIMENT_NAME, - trackExperimentViewed: trackExperiment - }) - - if (variation === 'variationB') return - return -} -``` - #### Attributes -In order to pass by attributes, you'll able to do so by adding the named parameter `attributes` when using the useExperiment hook. Something like this: +You can pass additional attributes to refine your decision logic: ```js -import {useExperiment} from '@s-ui/pde' - -const EXPERIMENT_NAME = 'experimentX' +import {useDecision} from '@s-ui/pde' const MyComponent = () => { - const {variation} = useExperiment({ - experimentName: EXPERIMENT_NAME, - attributes: { // this will send these attributes + const {variation} = useDecision('feature_test', { + attributes: { isLoggedIn: true } }) - - if (variation === 'variationB') return - return -} -``` - -⚠️ 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 experiment variation - -It's possible to force a variation for our experiment in the browser. For example, lets assume we want to QA a specific variation for our test called `abtest2_recommender` and the test is running in `http://myweb.com`. In order to force a variation you'll have to add a query param using the experiment name but adding `suipde_` as prefix, for example, for our recommender test, the url to open in order to force a variation would be `http://myweb.com?suipde_abtest2_recommender=default`. This would force the default variation. If forced, optimizely impression will not be triggered. - -### Feature Flags and Feature Tests - -⚠️ user consent do apply to feature flags only when used as feature test -⚠️ The `useFeature` hook will call a global window.analytics.track method with `Experiment Viewed` as event name with the experiment properties so you are able to replicate the experiment in your analytics tool. For each linked experiment (feature tests), an extra `Experiment Viewed` event will be send. - -```js -import {useFeature} from '@s-ui/pde' - -const MyComponent = () => { - const {isActive} = useFeature('myFeatureKey') // isActive = true when the feature flag is activated - - return

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 ( - - {({isActive}) => ( -

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 - }) - - return

The 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}) => ( + {children} + ) + }) + + describe.client('and the hook is executed by the browser', () => { + describe('and window.analytics.track exists', () => { + beforeEach(() => { + window.analytics = { + ready: cb => cb(), + track: sinon.spy() + } + sinon.spy(console, 'error') + }) + + afterEach(() => { + delete window.analytics + console.error.restore() + }) + + it('should return the right variationName and launch the Experiment Viewed event', () => { + const {result} = renderHook(() => useDecision('flag'), { + wrapper + }) + expect(result.current).to.be.deep.equal({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + sinon.assert.callCount(console.error, 0) + sinon.assert.callCount(window.analytics.track, 1) + expect(window.analytics.track.args[0][0]).to.equal('Experiment Viewed') + expect(window.analytics.track.args[0][1]).to.deep.equal({ + variationName: 'variation', + experimentName: 'rule' + }) + }) + + describe('when the flag is forced by query param', () => { + it('should return the forced flag of a feature test', () => { + const {result} = renderHook( + () => + useDecision('flag', { + queryString: '?suipde_flag=off' + }), + {wrapper} + ) + expect(result.current).to.be.deep.equal({ + enabled: false, + flagKey: 'flag' + }) + }) + }) + + describe('when the variation is forced by query param', () => { + it('should return the forced variation of a feature test', () => { + const {result} = renderHook( + () => + useDecision('rule', { + queryString: '?suipde_rule=variation_a' + }), + {wrapper} + ) + expect(result.current).to.be.deep.equal({ + variationKey: 'variation_a', + enabled: true, + flagKey: 'rule' + }) + }) + }) + + describe('when the same experiment is loaded more than once', () => { + it('should only track once', () => { + renderHook(() => useDecision('flag'), { + wrapper + }) + renderHook(() => useDecision('flag'), { + wrapper + }) + expect(window.analytics.track.args.length).to.equal(1) + }) + }) + }) + + describe('and window.analytics.track does not exist', () => { + beforeEach(() => { + sinon.spy(console, 'error') + }) + + afterEach(() => { + console.error.restore() + }) + + it('should return the right variationName and log an error', () => { + delete window.analytics + const {result} = renderHook(() => useDecision('flag'), { + wrapper + }) + expect(result.current).to.be.deep.equal({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + sinon.assert.callCount(console.error, 1) + expect(console.error.args[0][0]).to.include('window.analytics.track expected') + }) + }) + + describe('and use a custom track function', () => { + let customTrack + + before(() => { + customTrack = sinon.spy() + sinon.spy(console, 'error') + }) + + after(() => { + customTrack = undefined + console.error.restore() + }) + + it('should return the right variationName and execute custom track function', () => { + const {result} = renderHook( + () => + useDecision('test_experiment_id', { + trackExperimentViewed: customTrack + }), + { + wrapper + } + ) + expect(result.current).to.be.deep.equal({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + sinon.assert.callCount(console.error, 0) + sinon.assert.callCount(customTrack, 1) + }) + }) + }) + + describe.server('and the hook is executed by the server', () => { + before(() => { + sinon.spy(console, 'error') + }) + + after(() => { + console.error.restore() + }) + + it('should return the right variationName and launch the Experiment Viewed event', () => { + const {result} = renderHook(() => useDecision('flag'), { + wrapper + }) + expect(result.current.variation).to.be.deep.equal({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + sinon.assert.callCount(console.error, 0) + }) + }) + }) + + describe('when the activation returns an error', () => { + let decide + let wrapper + beforeEach(() => { + decide = sinon.stub().throws(new Error('fake activation error')) + // eslint-disable-next-line react/prop-types + wrapper = ({children}) => ( + {children} + ) + }) + + it('should return the default variation', () => { + const {result} = renderHook(() => useDecision('flag'), {wrapper}) + expect(result.current).to.be.deep.equal({enabled: false, flagKey: 'flag'}) + }) + }) + + describe.client('when calling twice the useDecision hook with the same feature key', () => { + let wrapper + let stubFactory + + beforeEach(() => { + window.analytics = { + ready: cb => cb(), + track: sinon.spy() + } + + stubFactory = decide => { + // eslint-disable-next-line react/prop-types + wrapper = ({children}) => ( + {children} + ) + } + }) + + afterEach(() => { + delete window.analytics + }) + + describe('when the second time returns the same value as the first time', () => { + beforeEach(() => { + const decide = sinon.stub() + + decide.onCall(0).returns({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + decide.onCall(1).returns({ + variationKey: 'variation', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + + stubFactory(decide) + }) + + it('should send only one experiment viewed event', () => { + renderHook(() => useDecision('flag'), { + wrapper + }) + renderHook(() => useDecision('flag'), { + wrapper + }) + expect(window.analytics.track.args.length).to.equal(1) + }) + }) + + describe('when the second time returns a different value as the first time', () => { + beforeEach(() => { + const decide = sinon.stub() + + decide.onCall(0).returns({ + variationKey: 'variation_a', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + decide.onCall(1).returns({ + variationKey: 'variation_b', + enabled: true, + variables: {}, + ruleKey: 'rule', + flagKey: 'flag', + userContext: {}, + reasons: [] + }) + + stubFactory(decide) + }) + + it('should send two experiment viewed events', () => { + renderHook(() => useDecision('flag'), { + wrapper + }) + renderHook(() => useDecision('flag'), { + wrapper + }) + expect(window.analytics.track.args.length).to.equal(2) + }) + }) + }) +})