├── .dockerignore ├── .github └── workflows │ ├── ci-push.yml │ ├── ci.yml │ ├── npm-cd-alpha.yml │ ├── npm-cd-beta.yml │ ├── npm-cd-latest.yml │ └── npm-cd-v2.yml ├── .gitignore ├── .np-config.json ├── .npmignore ├── .vscode └── settings.json ├── .yarnrc.yml ├── README.md ├── RELEASENOTES.md ├── babel.config.json ├── codecov.yml ├── demo ├── .dockerignore ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── fsOnboardingConfig.json ├── index.html ├── package.json ├── public │ ├── duvet_cover.jpg │ └── vite.svg ├── run-docker.sh ├── src │ ├── AddCartButton.tsx │ ├── App.tsx │ ├── DiscountedPrice.tsx │ ├── Item.tsx │ ├── ItemDetail.tsx │ ├── ItemImage.tsx │ ├── ItemPrice.tsx │ ├── Loading.tsx │ ├── PriceLabel.tsx │ ├── VipSwitch.tsx │ ├── assets │ │ └── react.svg │ ├── itemData.ts │ ├── main.tsx │ ├── theme.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock ├── eslint.config.mjs ├── jest.config.js ├── package.json ├── setupTests.ts ├── src ├── FSFlag.ts ├── FlagshipContext.tsx ├── FlagshipHooks.ts ├── FlagshipProvider.tsx ├── constants.ts ├── index.tsx ├── internalType.ts ├── sdkVersion.ts ├── type.ts └── utils.ts ├── test ├── Flag.test.ts ├── FlagshipContext.test.tsx ├── FlagshipHooks.test.tsx ├── campaigns.ts └── utils.test.tsx ├── tsconfig.json ├── tsconfig.test.json ├── webpack.config.js ├── webpack ├── webpack.browser.js ├── webpack.common.js └── webpack.node.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | **/**/node_modules 4 | coverage 5 | node_modules 6 | dist 7 | test 8 | **/test-react-next 9 | **/**/build -------------------------------------------------------------------------------- /.github/workflows/ci-push.yml: -------------------------------------------------------------------------------- 1 | name: Test & Code coverage 2 | on: 3 | push: 4 | branches: [master] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Enable Corepack 11 | run: corepack enable 12 | - name: Set Yarn Version 13 | run: corepack prepare yarn@4.7.0 --activate 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | cache: "yarn" 19 | cache-dependency-path: yarn.lock 20 | - name: Install modules 21 | run: yarn install 22 | - name: Run tests 23 | run: yarn run test 24 | - name: Upload codecov report 25 | uses: codecov/codecov-action@v4 26 | with: 27 | token: ${{ secrets.CODECOV_TOKEN }} 28 | file: ./coverage/clover.xml 29 | flags: unittests 30 | name: codecov-flagship-react-coverage 31 | fail_ci_if_error: true 32 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: [master] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Enable Corepack 12 | run: corepack enable 13 | - name: Set Yarn Version 14 | run: corepack prepare yarn@4.7.0 --activate 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: "yarn" 20 | cache-dependency-path: yarn.lock 21 | - name: Install modules 22 | run: yarn install 23 | - name: Run tests 24 | run: yarn run test 25 | -------------------------------------------------------------------------------- /.github/workflows/npm-cd-alpha.yml: -------------------------------------------------------------------------------- 1 | name: NPM CD 2 | on: 3 | push: 4 | tags: 5 | - "[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]+" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Enable Corepack 12 | run: corepack enable 13 | - name: Set Yarn Version 14 | run: corepack prepare yarn@4.7.0 --activate 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: "yarn" 20 | cache-dependency-path: yarn.lock 21 | - name: Install modules 22 | run: yarn install 23 | - run: yarn build 24 | - run: npm publish --tag alpha 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/npm-cd-beta.yml: -------------------------------------------------------------------------------- 1 | name: NPM CD 2 | on: 3 | push: 4 | tags: 5 | - "[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Enable Corepack 12 | run: corepack enable 13 | - name: Set Yarn Version 14 | run: corepack prepare yarn@4.7.0 --activate 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: "yarn" 20 | cache-dependency-path: yarn.lock 21 | - name: Install modules 22 | run: yarn install 23 | - run: yarn build 24 | - run: npm publish --tag beta 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/npm-cd-latest.yml: -------------------------------------------------------------------------------- 1 | name: NPM CD 2 | on: 3 | push: 4 | tags: 5 | - "5.[0-9]+.[0-9]+" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Enable Corepack 12 | run: corepack enable 13 | - name: Set Yarn Version 14 | run: corepack prepare yarn@4.7.0 --activate 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '23.x' 19 | registry-url: 'https://registry.npmjs.org' 20 | cache: "yarn" 21 | cache-dependency-path: yarn.lock 22 | - name: Install modules 23 | run: yarn install 24 | - run: yarn test --coverage 25 | - run: yarn build 26 | - run: npm publish 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/npm-cd-v2.yml: -------------------------------------------------------------------------------- 1 | name: NPM CD 2 | on: 3 | push: 4 | tags: 5 | - "2.[0-9]+.[0-9]+" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '20.x' 14 | registry-url: 'https://registry.npmjs.org' 15 | - run: yarn install 16 | - run: yarn test --coverage 17 | - run: yarn build 18 | - run: npm publish --tag version.2 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | example/test-react-next/config.js 25 | .yarn/ 26 | -------------------------------------------------------------------------------- /.np-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "cleanup": false 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .babelrc 3 | webpack.config.js 4 | webpack 5 | node_modules -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["javascript", "typescript"], 3 | "cSpell.words": [ 4 | "allowlist", 5 | "rerender", 6 | "sprintf", 7 | "targetings", 8 | "unauthenticate" 9 | ], 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit" 12 | }, 13 | "[javascript]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "[typescript]": { 17 | "editor.defaultFormatter": null 18 | }, 19 | "[typescriptreact]": { 20 | "editor.defaultFormatter": "esbenp.prettier-vscode" 21 | }, 22 | "[javascriptreact]": { 23 | "editor.defaultFormatter": null 24 | }, 25 | "liveServer.settings.port": 5501 26 | } 27 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Test & Code coverage](https://github.com/flagship-io/flagship-react-sdk/actions/workflows/ci-push.yml/badge.svg?branch=master&event=push)](https://github.com/flagship-io/flagship-react-sdk/actions/workflows/ci-push.yml) [![codecov](https://codecov.io/gh/flagship-io/flagship-react-sdk/branch/master/graph/badge.svg?token=jRh0h1XcT3)](https://codecov.io/gh/flagship-io/flagship-react-sdk) [![npm version](https://badge.fury.io/js/@flagship.io%2Freact-sdk.svg)](https://badge.fury.io/js/@flagship.io%2Freact-sdk) 2 | 3 | ## About Flagship 4 | 5 | ​ 6 | drawing 7 | ​ 8 | [Flagship by AB Tasty](https://www.flagship.io/) is a feature flagging platform for modern engineering and product teams. It eliminates the risks of future releases by separating code deployments from these releases :bulb: With Flagship, you have full control over the release process. You can: 9 | ​ 10 | 11 | - Switch features on or off through remote config. 12 | - Automatically roll-out your features gradually to monitor performance and gather feedback from your most relevant users. 13 | - Roll back any feature should any issues arise while testing in production. 14 | - Segment users by granting access to a feature based on certain user attributes. 15 | - Carry out A/B tests by easily assigning feature variations to groups of users. 16 | ​ 17 | drawing 18 | ​ 19 | Flagship also allows you to choose whatever implementation method works for you from our many available SDKs or directly through a REST API. Additionally, our architecture is based on multi-cloud providers that offer high performance and highly-scalable managed services. 20 | ​ 21 | **To learn more:** 22 | ​ 23 | - [Solution overview](https://www.flagship.io/#showvideo) - A 5mn video demo :movie_camera: 24 | - [Documentation](https://docs.developers.flagship.io/) - Our dev portal with guides, how tos, API and SDK references 25 | - [Sign up for a free trial](https://www.flagship.io/sign-up/) - Create your free account 26 | - [Guide to feature flagging](https://www.flagship.io/feature-flags/) - Everyhting you need to know about feature flag related use cases 27 | - [Blog](https://www.flagship.io/blog/) - Additional resources about release management 28 | -------------------------------------------------------------------------------- /RELEASENOTES.md: -------------------------------------------------------------------------------- 1 | # Flagship REACT SDK - Release notes 2 | ## ➡️ Version 2.1.5 3 | Minor bug fixes & improvements 4 | 5 | ## ➡️ Version 2.1.2 6 | Minor bug fixes & improvements 7 | 8 | ## ➡️ Version 2.1.1 9 | Minor bug fixes & improvements 10 | 11 | ## ➡️ Version 2.1.0 12 | 13 | In this new release, we are launching **experience continuity** which means that the SDK will adopt specific behavior according the data you'll provide to the visitor (in `visitorData` prop). 14 | 15 | ### New features 🎉 16 | 17 | - `visitorData` property has a new attribute `isAuthenticated` that takes a boolean and is optional (`false` by default). 18 | 19 | **NOTE**: Implementing visitor reconciliation will require to ALWAYS consider wether your visitor is authenticated (`visitorData.isAuthenticated=true`) or anonymous (`visitorData.isAuthenticated=false`) 20 | 21 | Here an example: 22 | 23 | - Your visitor arrives on your app for the first time (not authenticated). 24 | 25 | ```javascript 26 | import React from 'react'; 27 | import { FlagshipProvider } from '@flagship.io/react-sdk'; 28 | 29 | const App = () => ( 30 | <> 31 | 42 | {/* [...] */} 43 | 44 | 45 | ); 46 | ``` 47 | 48 | The visitor will match some campaigns and receive some modifications. 49 | 50 | - Now, the visitor is logging in. No problem, we need to update the `visitorData` accordingly to tell the SDK about those changes: 51 | 52 | ```javascript 53 | import React from 'react'; 54 | import { FlagshipProvider } from '@flagship.io/react-sdk'; 55 | 56 | const App = () => ( 57 | <> 58 | 69 | {/* [...] */} 70 | 71 | 72 | ); 73 | ``` 74 | 75 | **NOTE**: When switching from `visitorData.isAuthenticated=false` to `visitorData.isAuthenticated=true`, you can still keep the previous value of `visitorData.id` (for example when your visitor has just signed up). 76 | 77 | Great, the visitor will keep its previous (anonymous) experience, meaning that it'll still keep same variations for campaigns that it still match. 78 | 79 | - There is still a possible scenario that can happen. What if the visitor is signing out ? Well.. you'll have to decide between two options: 80 | 81 | 1. I want my visitor to keep its anonymous experience as it was before being authenticated. 82 | 2. I want my visitor to be a brand new visitor. 83 | 84 | Process for option #1: 85 | 86 | - Change the value of `visitorData.isAuthenticated` from `true` to `false` 87 | - Make sure `visitorData.id` has the same value as before being authenticate. 88 | 89 | Process for option #2: 90 | 91 | - Change the value of `visitorData.isAuthenticated` from `true` to `false` 92 | - Make sure `visitorData.id` has a new unique value. 93 | 94 | ## ➡️ Version 2.0.11 95 | 96 | - Adds Pageview hit 97 | - Changes Screen hit to Screenview hit 98 | - Minor bug fixes 99 | 100 | ## ➡️ Version 2.0.10 101 | 102 | - Bumps axios version from 0.19.2 to 0.21.1 103 | 104 | ## ➡️ Version 2.0.9 105 | 106 | - Minor changes. 107 | - function `getModificationInfo` output now contains a new attribute `isReference` (`boolean`) telling you if the modification is the reference (`true`) or not (`false`). 108 | 109 | ## ➡️ Version 2.0.8 110 | 111 | ### Improvements 💪 112 | 113 | - `onInitStart` and `onInitDone` prop is triggered in a better way. 114 | 115 | - Flagship React SDK is forced to runs synchronously in SSR. We will update [the documentation](https://developers.flagship.io/docs/sdk/react/v2.0) to indicate features available or not in SSR. 116 | 117 | ### New features 🎉 118 | 119 | - Add universal app demo. [Click here](./examples/react-universal-demo/README.md) to see the source code. 120 | 121 | - Add full ssr app demo. [Click here](./examples/react-ssr-demo/README.md) to see the source code. 122 | 123 | ## ➡️ Version 2.0.7 124 | 125 | ### Improvements 💪 126 | 127 | - `onUpdate` prop is triggered in a better way and `status` object is giving more data, have a look to the [documentation](https://developers.flagship.io/docs/sdk/react/v2.0#useflagship-output-status) for full details. 128 | 129 | ### Bug fixes 🐛 130 | 131 | ## ➡️ Version 2.0.6 132 | 133 | - `fetchNow` prop was not true by default. 134 | 135 | ### Bug fixes 🐛 136 | 137 | - Safe mode side effects still processing even if `enableSafeMode` is falsy. Not anymore. 138 | 139 | - Fix rendering issue, not immediately considering modifications from `initialModifications`, when it's set. 140 | 141 | ## ➡️ Version 2.0.5 142 | 143 | - Minor change. 144 | 145 | ## ➡️ Version 2.0.4 146 | 147 | ### Bug fixes 🐛 148 | 149 | - Fix Flagship decision API V2 which was not used when `apiKey` props is defined. 150 | 151 | - Fix `loadingComponent` not ignored when `fetchNow` is set to `false`. 152 | 153 | ### Breaking changes ⚠️ 154 | 155 | - `fetchNow` prop is now `true` by default. 156 | 157 | ## ➡️ Version 2.0.3 158 | 159 | - Minor change. 160 | 161 | ## ➡️ Version 2.0.2 162 | 163 | ### New features 🎉 164 | 165 | - New optimization when sending activate calls. The visitor instance in the SDK is updated instead of being recreated from scratch. 166 | 167 | ## ➡️ Version 2.0.1 168 | 169 | ### New features 🎉 170 | 171 | - Panic mode supported. When you've enabled panic mode through the web dashboard, the SDK will detect it and be in safe mode. Logs will appear to warns you and default values for modifications will be return. 172 | 173 | - `timeout` setting added. It specify the timeout duration when fetching campaigns via API mode (`decisionMode = "API"`), defined in **seconds**. Minimal value should be greater than 0. More to come on this setting soon... 174 | 175 | ### Breaking changes ⚠️ 176 | 177 | - `pollingInterval` setting is now a period interval defined in **seconds** (not minutes). Minimal value is 1 second. 178 | 179 | ## ➡️ Version 2.0.0 180 | 181 | ### New features 🎉 182 | 183 | - Add `initialBucketing` prop. It takes the data received from the flagship bucketing api endpoint. Can be useful when you save this data in cache. 184 | 185 | - Add `onBucketingSuccess` and `onBucketingFail` callback props. Those callbacks are called after a bucketing polling has either succeed or failed. 186 | 187 | Example: 188 | 189 | ```javascript 190 | { 193 | // shape of bucketingData: { status: string; payload: BucketingApiResponse } 194 | console.log('Bucketing polling succeed with following data: ' + JSON.stringify(bucketingData)); 195 | }} 196 | onBucketingFail={(error) => { 197 | console.log('Bucketing polling failed with following error: ' + error); 198 | }} 199 | > 200 | {children} 201 | 202 | ``` 203 | 204 | - Add `startBucketingPolling` and `stopBucketingPolling` function available in `useFlagship` hook. It allows to start/stop the bucketing polling whenever you want. 205 | 206 | Example: 207 | 208 | ```javascript 209 | import { useFlagship } from '@flagship.io/react-sdk'; 210 | 211 | const { startBucketingPolling, stopBucketingPolling } = useFlagship(); 212 | 213 | // [...] 214 | 215 | return ( 216 | <> 217 | 229 | 241 | 242 | ); 243 | ``` 244 | 245 | ### Bug fixes 🐛 246 | 247 | - Bucketing is stopped automatically when value of `decisionMode` changes dynamically from `"Bucketing"` to another value. 248 | 249 | - When bucketing enabled, fix event's http request sent twice. 250 | 251 | ### Breaking changes #1 ⚠️ 252 | 253 | Due to bucketing optimization, the bucketing allocate a variation to a visitor differently than SDK v1.3.X 254 | 255 | - As a result, assuming you have campaign with the following traffic allocation: 256 | 257 | - 50% => `variation1` 258 | - 50% => `variation2` 259 | 260 | By upgrading to this version, you might see your visitor switching from `variation1` to `variation2` and vice-versa. 261 | 262 | ### Breaking changes #2 ⚠️ 263 | 264 | Be aware that `apiKey` will be mandatory in the next major release as it will use the [Decision API v2](http://developers.flagship.io/api/v2/). 265 | 266 | - Make sure to initialize your `FlagshipProvider` component is set correctly: 267 | 268 | - **BEFORE**: 269 | 270 | ```javascript 271 | 278 | ``` 279 | 280 | - **NOW**: 281 | 282 | ```javascript 283 | 291 | ``` 292 | 293 | ### Breaking changes #3 ⚠️ 294 | 295 | - `getModificationInfo` attribute from `useFlagship` hook, is now always defined: 296 | 297 | - **BEFORE**: 298 | 299 | ```javascript 300 | import { useFlagship } from '@flagship.io/react-sdk'; 301 | 302 | const { getModificationInfo } = useFlagship(); 303 | 304 | // [...] 305 | 306 | return ( 307 | <> 308 | 325 | 326 | ); 327 | ``` 328 | 329 | - **NOW**: 330 | 331 | ```javascript 332 | import { useFlagship } from '@flagship.io/react-sdk'; 333 | 334 | const { getModificationInfo } = useFlagship(); 335 | 336 | // [...] 337 | 338 | return ( 339 | <> 340 | 353 | 354 | ); 355 | ``` 356 | 357 | ### Breaking changes #5 ⚠️ 358 | 359 | - `useFsSynchronize` has been removed. Campaigns synchronization is now handle using `useFlagship` hook: 360 | 361 | - **BEFORE**: 362 | 363 | ```jsx 364 | import { useFsSynchronize } from '@flagship.io/react-sdk'; 365 | 366 | var activateAllModifications = false; 367 | 368 | useFsSynchronize([listenedValue], activateAllModifications); // when "listenedValue" changes, it triggers a synchronize 369 | 370 | // [...] 371 | 372 | return ( 373 | <> 374 | 381 | 382 | ); 383 | ``` 384 | 385 | - **NOW**: 386 | 387 | ```jsx 388 | import { useFlagship } from '@flagship.io/react-sdk'; 389 | 390 | var activateAllModifications = false; 391 | 392 | const { synchronizeModifications } = useFlagship(); 393 | 394 | // [...] 395 | 396 | return ( 397 | <> 398 | 415 | 416 | ); 417 | ``` 418 | 419 | ### Breaking changes #6 ⚠️ 420 | 421 | - `pollingInterval` setting is now a period interval defined in **seconds** (not minutes). Minimal value is 1 second. 422 | 423 | ## ➡️ Version 1.3.1 424 | 425 | ### Bug fixes 🐛 426 | 427 | - Fix timestamp displayed in logs. 428 | 429 | ## ➡️ Version 1.3.0 430 | 431 | ### Breaking changes ⚠️ 432 | 433 | - `config` prop is not supported anymore. Currently deprecated and will be deleted in next major release. 434 | 435 | ### New features 🎉 436 | 437 | - Now supports Bucketing behavior: 438 | - `decisionMode` prop added, value is either "API" or "Bucketing". 439 | - `pollingInterval` prop added, value is a number. Must be specified when `decisionMode=Bucketing`. 440 | 441 | ## ➡️ Version 1.2.2 442 | 443 | ### Bug fixes 🐛 444 | 445 | - Fixed getModificationInfo always returning an error. 446 | 447 | ## ➡️ Version 1.2.1 448 | 449 | ### Bug fixes 🐛 450 | 451 | - Minor log fix when sending hits. 452 | 453 | ## ➡️ Version 1.2.0 454 | 455 | ### Breaking changes ⚠️ 456 | 457 | - When sending a hit "Item", the attribute `ic`(="item code") is now **required** (was optional before). If you do not specify it, the hit won't be send and an error log will be display. 458 | 459 | ### New features 🎉 460 | 461 | - `onUpdate` prop's first argument, has a new attribute `config`. It gives you ability to check current React SDK config during an update. 462 | 463 | ## ➡️ Version 1.1.0 464 | 465 | ### New features 🎉 466 | 467 | - useFlagship hook now returns a new node `getModificationInfo`. 468 | 469 | ### Breaking changes ⚠️ 470 | 471 | - Safe mode is now disable by default because we're working on some improvements. You can still give it a try by enabling it with: 472 | 473 | ``` 474 | 477 | ``` 478 | 479 | - `config` props is now deprecated and will be deleted in the next major release. All attributes are now directly plugged as a FlagshipProvider's props. 480 | 481 | For example: 482 | 483 | ``` 484 | 487 | ``` 488 | 489 | is now: 490 | 491 | ``` 492 | 496 | ``` 497 | 498 | ## ➡️ Version 1.0.1 499 | 500 | - Jumped version. 501 | 502 | ## ➡️ Version 1.0.0 503 | 504 | - Release version. 505 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es": { 4 | "presets": [ 5 | "@babel/preset-typescript", 6 | [ 7 | "@babel/env", 8 | { 9 | "modules": false, 10 | "targets": { 11 | "node": "current" 12 | } 13 | } 14 | ], 15 | ["@babel/preset-react", { 16 | "runtime": "automatic" 17 | }] 18 | ] 19 | }, 20 | "cjs": { 21 | "presets": [ 22 | "@babel/preset-typescript", 23 | [ 24 | "@babel/preset-env", 25 | { 26 | "useBuiltIns": "usage", 27 | "corejs": { 28 | "version": 3 29 | } 30 | } 31 | ], 32 | ["@babel/preset-react"] 33 | ], 34 | "plugins": [ 35 | "@babel/proposal-class-properties", 36 | [ 37 | "add-module-exports", 38 | { 39 | "addDefaultProperty": true 40 | } 41 | ] 42 | ] 43 | }, 44 | "esm": { 45 | "presets": [ 46 | "@babel/preset-typescript", 47 | [ 48 | "@babel/preset-env", 49 | { 50 | "modules": false, 51 | "targets": { 52 | "browsers": ["last 2 versions", "ie >= 11"] 53 | } 54 | } 55 | ], 56 | ["@babel/preset-react"] 57 | ], 58 | "plugins": [ 59 | "@babel/proposal-class-properties", 60 | [ 61 | "add-module-exports", 62 | { 63 | "addDefaultProperty": true 64 | } 65 | ] 66 | ] 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: '70...100' 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: no 16 | 17 | comment: 18 | layout: 'reach,diff,flags,tree' 19 | behavior: default 20 | require_changes: no 21 | -------------------------------------------------------------------------------- /demo/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | **/**/node_modules 4 | **/coverage 5 | **/node_modules 6 | **/dist 7 | **/test 8 | **/demo-test 9 | node_modules 10 | dist 11 | build -------------------------------------------------------------------------------- /demo/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /demo/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1 - the build process 2 | FROM node:20-alpine as build-deps 3 | WORKDIR /app 4 | COPY package.json yarn.lock ./ 5 | RUN npm install 6 | COPY . ./ 7 | RUN npm run build 8 | 9 | # Stage 2 - the production environment 10 | FROM nginx:1.25.5-alpine 11 | COPY --from=build-deps /app/dist /usr/share/nginx/html 12 | EXPOSE 80 13 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Flagship Demo React Application 2 | 3 | Welcome to the Flagship Demo React Application. This application is a demonstration of how to use Flagship for feature flagging and A/B testing in a ReactJs application. 4 | 5 | This implementation is based on two use cases: 6 | 7 | 1. **Fs demo toggle use case**: This feature toggle campaign enables a discount for VIP users. 8 | 2. **Fs demo A/B Test use case**: This A/B test campaign allows you to test the color of the 'Add to Cart' button. 9 | 10 | ## Prerequisites 11 | 12 | Before you begin, ensure you have met the following requirements: 13 | 14 | - You have installed the latest version of [Node.js](https://nodejs.org/en/download/) 15 | - You have installed [Yarn](https://yarnpkg.com/getting-started/install) 16 | - You have [Docker](https://www.docker.com/products/docker-desktop) installed (optional) 17 | - [Flagship account](https://www.abtasty.com) 18 | 19 | ## Getting Started 20 | 21 | ### Running the Application Locally 22 | 23 | Follow these steps to get up and running quickly on your local machine: 24 | 25 | 1. Install the dependencies: 26 | 27 | ```bash 28 | yarn install 29 | ``` 30 | 31 | 2. Start the application: 32 | 33 | ```bash 34 | yarn start 35 | ``` 36 | 37 | The application will be accessible at `http://localhost:3000`. 38 | 39 | ### Running the Application in Docker 40 | 41 | If you prefer to use Docker, you can build and run the application using the provided shell script: 42 | 43 | ```bash 44 | chmod +x run-docker.sh && ./run-docker.sh 45 | ``` 46 | 47 | ### Running the Application in a Sandbox 48 | 49 | You can also run this application in a sandbox environment. Click [here](https://githubbox.com/flagship-io/flagship-react-sdk/tree/demo-example/demo) to open the sandbox. 50 | -------------------------------------------------------------------------------- /demo/fsOnboardingConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdkInstallSteps": [ 3 | { 4 | "order": 1, 5 | "description": "Install the SDK", 6 | "rawCode": "yarn add @flagship.io/react-sdk", 7 | "language": "shell" 8 | } 9 | ], 10 | "sdkUseSteps": [ 11 | { 12 | "fileName": "src/App.tsx", 13 | "order": 1, 14 | "description": "1 - Initialize the SDK", 15 | "codeRegexPatterns": [ 16 | "\/\/start demo([\\s\\S]*?)\/\/end demo" 17 | ], 18 | "language": "jsx" 19 | }, 20 | { 21 | "fileName": "src/Item.tsx", 22 | "order": 1, 23 | "description": "2 - Get your flag", 24 | "codeRegexPatterns": [ 25 | "\/\/start demo([\\s\\S]*?)\/\/end demo" 26 | ], 27 | "language": "jsx" 28 | } 29 | ] 30 | } -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | Vite + React + TS 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.11.4", 14 | "@emotion/styled": "^11.11.5", 15 | "@flagship.io/react-sdk": "^4.0.0", 16 | "@fontsource/roboto": "^5.0.13", 17 | "@mui/material": "^5.15.15", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.2.66", 23 | "@types/react-dom": "^18.2.22", 24 | "@typescript-eslint/eslint-plugin": "^7.2.0", 25 | "@typescript-eslint/parser": "^7.2.0", 26 | "@vitejs/plugin-react": "^4.2.1", 27 | "eslint": "^8.57.0", 28 | "eslint-plugin-react-hooks": "^4.6.0", 29 | "eslint-plugin-react-refresh": "^0.4.6", 30 | "typescript": "^5.2.2", 31 | "vite": "^5.2.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /demo/public/duvet_cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flagship-io/flagship-react-sdk/31c1baf3aa58f8d1b126797ce6f09af0dee881dd/demo/public/duvet_cover.jpg -------------------------------------------------------------------------------- /demo/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/run-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker build -t flagship-demo-react-js . && docker run -p 8080:80 flagship-demo-react-js -------------------------------------------------------------------------------- /demo/src/AddCartButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from "@mui/material"; 2 | 3 | type AddCartButtonProps = Omit & { 4 | backgroundColor: string; 5 | onClick: () => void; 6 | }; 7 | 8 | export function AddCartButton({ 9 | backgroundColor, 10 | onClick, 11 | ...props 12 | }: AddCartButtonProps) { 13 | return ( 14 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | //start demo 2 | //Path: demo/src/App.tsx 3 | import { FlagshipProvider } from "@flagship.io/react-sdk"; 4 | import { Item } from "./Item"; 5 | import { Container } from "@mui/material"; 6 | import { useState } from "react"; 7 | import { VipSwitch } from "./VipSwitch"; 8 | import { Loading } from "./Loading"; 9 | 10 | function App() { 11 | const [isVip, setIsVip] = useState(false); 12 | return ( 13 | 14 | 15 | {/* Step 1: Initialize the SDK with FlagshipProvider */} 16 | } 27 | > 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default App; 35 | //end demo 36 | -------------------------------------------------------------------------------- /demo/src/DiscountedPrice.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Typography } from "@mui/material"; 2 | 3 | type DiscountedPriceProps = { 4 | price: string; 5 | discountPrice: string; 6 | } 7 | 8 | export function DiscountedPrice({ price, discountPrice }: DiscountedPriceProps) { 9 | return ( 10 | 11 | 16 | {price} 17 | 18 | 23 | {discountPrice} 24 | 25 | 26 | ); 27 | } -------------------------------------------------------------------------------- /demo/src/Item.tsx: -------------------------------------------------------------------------------- 1 | //start demo 2 | //Path: demo/src/Item.tsx 3 | import { Grid } from "@mui/material"; 4 | import { itemData } from "./itemData"; 5 | import { EventCategory, HitType, useFlagship, useFsFlag } from "@flagship.io/react-sdk"; 6 | import { ItemImage } from "./ItemImage"; 7 | import { ItemDetail } from "./ItemDetail"; 8 | import { DiscountedPrice } from "./DiscountedPrice"; 9 | import { ItemPrice } from "./ItemPrice"; 10 | import { AddCartButton } from "./AddCartButton"; 11 | import { PriceLabel } from "./PriceLabel"; 12 | 13 | export const Item = () => { 14 | const { title, subtitle, refNumber, price, imageUrl, imageAlt, imageTitle, discountPrice } = itemData; 15 | 16 | const fs = useFlagship(); 17 | 18 | /*Step 2: Get the values of the flags for the visitor*/ 19 | const enableDiscountFlag = useFsFlag("fs_enable_discount").getValue(false); 20 | const addToCartBtnColorFlag = useFsFlag("fs_add_to_cart_btn_color").getValue("#556cd6"); 21 | 22 | const handleAddToCart = () => { 23 | // Step 3: Send a hit to track an action 24 | fs.sendHits({ 25 | type: HitType.EVENT, 26 | category: EventCategory.ACTION_TRACKING, 27 | action: "add-to-cart-clicked", 28 | }); 29 | 30 | alert("Item added to cart"); 31 | }; 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | Price: 39 | {enableDiscountFlag ? ( 40 | 41 | ) : ( 42 | 43 | )} 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | //end demo 53 | -------------------------------------------------------------------------------- /demo/src/ItemDetail.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Typography } from "@mui/material"; 2 | 3 | type ItemDetailProps = { 4 | title: string; 5 | subtitle: string; 6 | refNumber: string; 7 | } 8 | 9 | export function ItemDetail({ title, subtitle, refNumber }: ItemDetailProps) { 10 | return ( 11 | 12 | 13 | {title} 14 | 15 | 16 | {subtitle} 17 | 18 | 23 | {refNumber} 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/ItemImage.tsx: -------------------------------------------------------------------------------- 1 | import { CardMedia, Grid } from "@mui/material"; 2 | 3 | type ItemImageProps = { 4 | imageUrl: string; 5 | imageAlt: string; 6 | imageTitle: string; 7 | }; 8 | 9 | export const ItemImage = ({ 10 | imageUrl, 11 | imageAlt, 12 | imageTitle, 13 | }: ItemImageProps) => { 14 | return ( 15 | 16 | 17 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /demo/src/ItemPrice.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@mui/material"; 2 | 3 | type ItemPriceProps = { 4 | price: string; 5 | } 6 | 7 | export function ItemPrice({ price }: ItemPriceProps) { 8 | return ( 9 | 10 | {price} 11 | 12 | ); 13 | } -------------------------------------------------------------------------------- /demo/src/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress } from "@mui/material"; 2 | 3 | export function Loading() { 4 | return (); 5 | } 6 | -------------------------------------------------------------------------------- /demo/src/PriceLabel.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from "@mui/material"; 2 | 3 | interface PriceLabelProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function PriceLabel({ children }: PriceLabelProps) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } -------------------------------------------------------------------------------- /demo/src/VipSwitch.tsx: -------------------------------------------------------------------------------- 1 | import { FormControlLabel, Grid, Switch } from "@mui/material"; 2 | 3 | interface VipSwitchProps { 4 | isVip: boolean; 5 | setIsVip: (value: boolean) => void; 6 | } 7 | 8 | export function VipSwitch({ isVip, setIsVip }: VipSwitchProps) { 9 | return ( 10 | 11 | setIsVip(!isVip)} 16 | /> 17 | } 18 | label="Is vip client" 19 | /> 20 | 21 | ); 22 | } -------------------------------------------------------------------------------- /demo/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/src/itemData.ts: -------------------------------------------------------------------------------- 1 | 2 | export const itemData = { 3 | title: "Duvet cover", 4 | subtitle: "Eloge floral", 5 | refNumber: "Ref: 123456", 6 | price: "€99.99", 7 | imageUrl: "./duvet_cover.jpg", 8 | imageAlt: "Contemplative Reptile", 9 | imageTitle: "Contemplative Reptile", 10 | discountPrice: "€79.99", 11 | } -------------------------------------------------------------------------------- /demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import { ThemeProvider } from "@emotion/react"; 5 | import { CssBaseline } from "@mui/material"; 6 | import theme from "./theme"; 7 | 8 | ReactDOM.createRoot(document.getElementById("root")!).render( 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /demo/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { red } from '@mui/material/colors'; 3 | 4 | // A custom theme for this app 5 | const theme = createTheme({ 6 | palette: { 7 | primary: { 8 | main: '#556cd6', 9 | }, 10 | secondary: { 11 | main: '#19857b', 12 | }, 13 | error: { 14 | main: red.A400, 15 | }, 16 | }, 17 | }); 18 | 19 | export default theme; -------------------------------------------------------------------------------- /demo/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import globals from "globals"; 3 | import js from "@eslint/js"; 4 | import tseslint from "typescript-eslint"; 5 | import pluginReact from "eslint-plugin-react"; 6 | 7 | export default defineConfig([ 8 | tseslint.configs.recommended, 9 | 10 | { 11 | files: ["**/*.{jsx,tsx}"], 12 | ... pluginReact.configs.flat.recommended, 13 | languageOptions: { 14 | ... pluginReact.configs.flat.recommended.languageOptions, 15 | globals: globals.browser, 16 | parser: tseslint.ESLintParser, 17 | parserOptions: { 18 | ecmaVersion: "latest", 19 | sourceType: "module", 20 | ecmaFeatures: { 21 | jsx: true, 22 | }, 23 | project: ["./tsconfig.json"] 24 | }, 25 | }, 26 | plugins: { js }, 27 | extends: ["js/recommended"], 28 | rules: { 29 | 30 | "no-console": "error", 31 | "no-debugger": "error", 32 | "no-alert": "error", 33 | 34 | 35 | "complexity": ["error", 15], 36 | "max-depth": ["error", 3], 37 | "max-params": ["error", 4], 38 | "no-duplicate-imports": "error", 39 | 40 | "no-restricted-globals": "error", 41 | "prefer-const": "error", 42 | "no-var": "error", 43 | 44 | "@typescript-eslint/explicit-function-return-type": ["error", { 45 | "allowExpressions": true, 46 | "allowTypedFunctionExpressions": true, 47 | "allowHigherOrderFunctions": true, 48 | "allowDirectConstAssertionInArrowFunctions": true, 49 | "allowConciseArrowFunctionExpressionsStartingWithVoid": true 50 | }], 51 | "@typescript-eslint/no-explicit-any": ["warn", { 52 | "ignoreRestArgs": false, 53 | "fixToUnknown": false 54 | }], 55 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 56 | "@typescript-eslint/no-non-null-assertion": "warn", 57 | "@typescript-eslint/ban-ts-comment": "warn", 58 | 59 | 60 | }, 61 | 62 | }, 63 | ...tseslint.configs.recommended, 64 | { 65 | files: ["**/*.ts"], 66 | languageOptions: { 67 | globals: { ...globals.browser, ...globals.node }, 68 | parser: tseslint.parser, 69 | parserOptions: { 70 | ecmaVersion: "latest", 71 | sourceType: "module", 72 | project: ["./tsconfig.json"] 73 | } 74 | }, 75 | rules: { 76 | 77 | "no-console": "error", 78 | "no-debugger": "error", 79 | "no-alert": "error", 80 | 81 | "complexity": ["error", 15], 82 | "max-depth": ["error", 3], 83 | "max-params": ["error", 4], 84 | "no-duplicate-imports": "error", 85 | 86 | 87 | "no-restricted-globals": "error", 88 | "prefer-const": "error", 89 | "no-var": "error", 90 | 91 | 92 | "@typescript-eslint/explicit-function-return-type": ["error", { 93 | "allowExpressions": true, 94 | "allowTypedFunctionExpressions": true, 95 | "allowHigherOrderFunctions": true, 96 | "allowDirectConstAssertionInArrowFunctions": true, 97 | "allowConciseArrowFunctionExpressionsStartingWithVoid": true 98 | }], 99 | "@typescript-eslint/no-explicit-any": ["warn", { 100 | "ignoreRestArgs": false, 101 | "fixToUnknown": false 102 | }], 103 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 104 | "@typescript-eslint/no-non-null-assertion": "warn", 105 | "@typescript-eslint/ban-ts-comment": "warn" 106 | } 107 | }, 108 | { 109 | files: ["**/test/**/*.ts", "**/*.test.ts"], 110 | languageOptions: { 111 | parser: tseslint.parser, 112 | parserOptions: { 113 | ecmaVersion: "latest", 114 | sourceType: "module", 115 | project: ["./tsconfig.test.json"] 116 | } 117 | }, 118 | rules: { 119 | "max-lines": "off", 120 | "complexity": "off", 121 | "@typescript-eslint/explicit-function-return-type": "off", 122 | "@typescript-eslint/no-explicit-any": "off" 123 | } 124 | } 125 | ]); 126 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | // All imported modules in your tests should be mocked automatically 5 | // automock: false, 6 | 7 | // Stop running tests after `n` failures 8 | // bail: 0, 9 | 10 | // The directory where Jest should store its cached dependency information 11 | // cacheDirectory: "C:\\Users\\hp\\AppData\\Local\\Temp\\jest", 12 | 13 | // Automatically clear mock calls and instances between every test 14 | clearMocks: true, 15 | 16 | // Indicates whether the coverage information should be collected while executing the test 17 | collectCoverage: true, 18 | 19 | // An array of glob patterns indicating a set of files for which coverage information should be collected 20 | // collectCoverageFrom: undefined, 21 | 22 | // The directory where Jest should output its coverage files 23 | coverageDirectory: 'coverage', 24 | 25 | // An array of regexp pattern strings used to skip coverage collection 26 | // coveragePathIgnorePatterns: [ 27 | // "\\\\node_modules\\\\" 28 | // ], 29 | 30 | // Indicates which provider should be used to instrument code for coverage 31 | coverageProvider: 'babel', 32 | 33 | // A list of reporter names that Jest uses when writing coverage reports 34 | // coverageReporters: [ 35 | // "json", 36 | // "text", 37 | // "lcov", 38 | // "clover" 39 | // ], 40 | 41 | // An object that configures minimum threshold enforcement for coverage results 42 | // coverageThreshold: undefined, 43 | 44 | // A path to a custom dependency extractor 45 | // dependencyExtractor: undefined, 46 | 47 | // Make calling deprecated APIs throw helpful error messages 48 | // errorOnDeprecated: false, 49 | 50 | // Force coverage collection from ignored files using an array of glob patterns 51 | // forceCoverageMatch: [], 52 | 53 | // A path to a module which exports an async function that is triggered once before all test suites 54 | // globalSetup: undefined, 55 | 56 | // A path to a module which exports an async function that is triggered once after all test suites 57 | // globalTeardown: undefined, 58 | 59 | // A set of global variables that need to be available in all test environments 60 | // globals: {}, 61 | 62 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 63 | // maxWorkers: "50%", 64 | 65 | // An array of directory names to be searched recursively up from the requiring module's location 66 | // moduleDirectories: [ 67 | // "node_modules" 68 | // ], 69 | 70 | // An array of file extensions your modules use 71 | // moduleFileExtensions: [ 72 | // "js", 73 | // "jsx", 74 | // "ts", 75 | // "tsx", 76 | // "json", 77 | // "node" 78 | // ], 79 | 80 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 81 | // moduleNameMapper: {}, 82 | 83 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 84 | // modulePathIgnorePatterns: [], 85 | 86 | // Activates notifications for test results 87 | // notify: false, 88 | 89 | // An enum that specifies notification mode. Requires { notify: true } 90 | // notifyMode: "failure-change", 91 | 92 | // A preset that is used as a base for Jest's configuration 93 | // preset: undefined, 94 | 95 | // Run tests from one or more projects 96 | // projects: undefined, 97 | 98 | // Use this configuration option to add custom reporters to Jest 99 | // reporters: undefined, 100 | 101 | // Automatically reset mock state between every test 102 | // resetMocks: false, 103 | 104 | // Reset the module registry before running each individual test 105 | // resetModules: false, 106 | 107 | // A path to a custom resolver 108 | // resolver: undefined, 109 | 110 | // Automatically restore mock state between every test 111 | // restoreMocks: false, 112 | 113 | // The root directory that Jest should scan for tests and modules within 114 | // rootDir: undefined, 115 | 116 | // A list of paths to directories that Jest should use to search for files in 117 | roots: [ 118 | 'test' 119 | ], 120 | 121 | // Allows you to use a custom runner instead of Jest's default test runner 122 | // runner: "jest-runner", 123 | 124 | // The paths to modules that run some code to configure or set up the testing environment before each test 125 | // setupFiles: [], 126 | 127 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 128 | setupFilesAfterEnv: ['/setupTests.ts'] 129 | 130 | // The number of seconds after which a test is considered as slow and reported as such in the results. 131 | // slowTestThreshold: 5, 132 | 133 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 134 | // snapshotSerializers: [], 135 | 136 | // The test environment that will be used for testing 137 | // testEnvironment: "jest-environment-node", 138 | 139 | // Options that will be passed to the testEnvironment 140 | // testEnvironmentOptions: {}, 141 | 142 | // Adds a location field to test results 143 | // testLocationInResults: false, 144 | 145 | // The glob patterns Jest uses to detect test files 146 | // testMatch: [ 147 | // "**/__tests__/**/*.[jt]s?(x)", 148 | // "**/?(*.)+(spec|test).[tj]s?(x)" 149 | // ], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | // testPathIgnorePatterns: [ 153 | // "\\\\node_modules\\\\" 154 | // ], 155 | 156 | // The regexp pattern or array of patterns that Jest uses to detect test files 157 | // testRegex: [], 158 | 159 | // This option allows the use of a custom results processor 160 | // testResultsProcessor: undefined, 161 | 162 | // This option allows use of a custom test runner 163 | // testRunner: "jest-circus/runner", 164 | 165 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 166 | // testURL: "http://localhost", 167 | 168 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 169 | // timers: "real", 170 | 171 | // A map from regular expressions to paths to transformers 172 | // transform: undefined, 173 | 174 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 175 | // transformIgnorePatterns: [ 176 | // "\\\\node_modules\\\\", 177 | // "\\.pnp\\.[^\\\\]+$" 178 | // ], 179 | 180 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 181 | // unmockedModulePathPatterns: undefined, 182 | 183 | // Indicates whether each individual test should be reported during the run 184 | // verbose: undefined, 185 | 186 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 187 | // watchPathIgnorePatterns: [], 188 | 189 | // Whether to use watchman for file crawling 190 | // watchman: true, 191 | } 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flagship.io/react-sdk", 3 | "sideEffects": false, 4 | "version": "5.1.0", 5 | "license": "Apache-2.0", 6 | "description": "Flagship REACT SDK", 7 | "main": "dist/index.node.js", 8 | "module": "dist/src/index.js", 9 | "react-native": "dist/src/index.js", 10 | "types": "dist/src/index.d.ts", 11 | "files": [ 12 | "dist/**/*", 13 | "CONTRIBUTING.md", 14 | "README.md" 15 | ], 16 | "exports": { 17 | "types": "./dist/src/index.d.ts", 18 | "import": "./dist/src/index.js", 19 | "require": "./dist/index.node.js", 20 | "react-native": "./dist/src/index.js" 21 | }, 22 | "dependencies": { 23 | "@flagship.io/js-sdk": "^5.1.1", 24 | "encoding": "^0.1.13" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16.8.0" 28 | }, 29 | "homepage": "https://github.com/flagship-io/flagship-react-sdk", 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/flagship-io/flagship-react-sdk.git" 33 | }, 34 | "keywords": [ 35 | "flagship", 36 | "abtasty", 37 | "react", 38 | "sdk" 39 | ], 40 | "devDependencies": { 41 | "@babel/cli": "^7.24.1", 42 | "@babel/core": "^7.24.3", 43 | "@babel/plugin-proposal-class-properties": "^7.18.6", 44 | "@babel/preset-env": "^7.24.3", 45 | "@babel/preset-react": "^7.24.1", 46 | "@babel/preset-typescript": "^7.24.1", 47 | "@eslint/js": "^9.23.0", 48 | "@testing-library/dom": "^10.1.0", 49 | "@testing-library/jest-dom": "^6.4.2", 50 | "@testing-library/react": "^16.0.0", 51 | "@testing-library/react-hooks": "^8.0.1", 52 | "@testing-library/user-event": "^14.5.2", 53 | "@types/jest": "^29.5.12", 54 | "@types/node": "^20.11.30", 55 | "@types/react": "^18.3.3", 56 | "@types/react-dom": "^18.3.0", 57 | "@typescript-eslint/eslint-plugin": "^7.4.0", 58 | "@typescript-eslint/parser": "^7.4.0", 59 | "babel-loader": "^9.1.3", 60 | "babel-plugin-add-module-exports": "^1.0.4", 61 | "core-js": "^3.36.1", 62 | "eslint": "^9.23.0", 63 | "genversion": "^3.2.0", 64 | "globals": "^16.0.0", 65 | "jest": "^29.7.0", 66 | "jest-environment-jsdom": "^29.7.0", 67 | "react": "^18.3.1", 68 | "react-dom": "^18.3.1", 69 | "react-test-renderer": "^18.2.0", 70 | "regenerator-runtime": "^0.14.1", 71 | "terser-webpack-plugin": "^5.3.10", 72 | "ts-jest": "^29.1.2", 73 | "ts-loader": "^9.5.1", 74 | "typescript": "^5.4.3", 75 | "typescript-eslint": "^8.28.0", 76 | "webpack": "^5.91.0", 77 | "webpack-cli": "^5.1.4", 78 | "webpack-merge": "^5.10.0", 79 | "webpack-node-externals": "^3.0.0" 80 | }, 81 | "scripts": { 82 | "dev": "tsc --watch", 83 | "test": "jest", 84 | "lint": "eslint src --ext .tsx", 85 | "clean": "rm -rf dist && mkdir dist", 86 | "generate:version": "genversion --es6 src/sdkVersion.ts", 87 | "build:esm": "BABEL_ENV=esm babel src --extensions '.ts,.tsx' --out-dir dist/esm --copy-files", 88 | "build:types": "tsc", 89 | "build:prod": "NODE_ENV=production npm run build", 90 | "prebuild": "npm run clean && npm run generate:version && tsc", 91 | "build": "yarn run prebuild && yarn run webpack" 92 | }, 93 | "eslintConfig": { 94 | "extends": [ 95 | "react-app", 96 | "react-app/jest" 97 | ] 98 | }, 99 | "browserslist": { 100 | "production": [ 101 | ">0.2%", 102 | "not dead", 103 | "not op_mini all" 104 | ], 105 | "development": [ 106 | "last 1 chrome version", 107 | "last 1 firefox version", 108 | "last 1 safari version" 109 | ] 110 | }, 111 | "packageManager": "yarn@4.7.0+sha512.5a0afa1d4c1d844b3447ee3319633797bcd6385d9a44be07993ae52ff4facabccafb4af5dcd1c2f9a94ac113e5e9ff56f6130431905884414229e284e37bb7c9" 112 | } 113 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/jest-globals' 2 | -------------------------------------------------------------------------------- /src/FSFlag.ts: -------------------------------------------------------------------------------- 1 | import { Flagship, FlagDTO, FSFlagMetadata, IFSFlag, IFSFlagMetadata, FSFlagStatus } from '@flagship.io/js-sdk' 2 | 3 | import { GET_FLAG_CAST_ERROR, noVisitorMessage } from './constants' 4 | import { FsContextState } from './type' 5 | import { hasSameType, logInfo, logWarn, sprintf } from './utils' 6 | 7 | export class FSFlag implements IFSFlag { 8 | private key: string 9 | private flag?: FlagDTO 10 | constructor (key: string, state:FsContextState) { 11 | const flagsData = state.flags 12 | if (!state.hasVisitorData) { 13 | logWarn(Flagship.getConfig(), noVisitorMessage, 'GetFlag') 14 | } 15 | this.key = key 16 | this.flag = flagsData?.get(key) 17 | } 18 | 19 | getValue (defaultValue: T): (T extends null ? unknown : T) { 20 | if (!this.flag) { 21 | return defaultValue as (T extends null ? unknown : T) 22 | } 23 | 24 | if (this.flag.value === null || this.flag.value === undefined) { 25 | return defaultValue as (T extends null ? unknown : T) 26 | } 27 | 28 | if (defaultValue !== null && defaultValue !== undefined && !hasSameType(this.flag.value, defaultValue)) { 29 | logInfo( 30 | Flagship.getConfig(), 31 | sprintf(GET_FLAG_CAST_ERROR, this.key), 32 | 'getValue' 33 | ) 34 | return defaultValue as (T extends null ? unknown : T) 35 | } 36 | return this.flag.value 37 | } 38 | 39 | exists ():boolean { 40 | if (!this.flag) { 41 | return false 42 | } 43 | return !!(this.flag.campaignId && this.flag.variationId && this.flag.variationGroupId) 44 | } 45 | 46 | async visitorExposed () : Promise { 47 | // do nothing 48 | } 49 | 50 | get metadata ():IFSFlagMetadata { 51 | if (!this.flag) { 52 | return FSFlagMetadata.Empty() 53 | } 54 | return new FSFlagMetadata({ 55 | campaignId: this.flag.campaignId, 56 | campaignName: this.flag.campaignName, 57 | variationGroupId: this.flag.variationGroupId, 58 | variationGroupName: this.flag.variationGroupName, 59 | variationId: this.flag.variationId, 60 | variationName: this.flag.variationName, 61 | isReference: !!this.flag.isReference, 62 | campaignType: this.flag.campaignType as string, 63 | slug: this.flag.slug 64 | }) 65 | } 66 | 67 | get status ():FSFlagStatus { 68 | if (!this.exists()) { 69 | return FSFlagStatus.NOT_FOUND 70 | } 71 | return FSFlagStatus.FETCHED 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/FlagshipContext.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { createContext } from 'react' 3 | 4 | import { FsContext, FsContextState } from './type' 5 | 6 | export const initStat: FsContextState = { 7 | isInitializing: true 8 | } 9 | 10 | export const FlagshipContext = createContext({ 11 | state: { ...initStat } 12 | }) 13 | -------------------------------------------------------------------------------- /src/FlagshipHooks.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useCallback, useContext, useMemo } from 'react' 3 | 4 | import { 5 | Flagship, 6 | IFSFlag, 7 | IHit, 8 | primitive, 9 | FSFlagCollection, 10 | Visitor, 11 | IFlagshipConfig 12 | } from '@flagship.io/js-sdk' 13 | 14 | import { noVisitorMessage } from './constants' 15 | import { FlagshipContext } from './FlagshipContext' 16 | import { FSFlag } from './FSFlag' 17 | import { UseFlagshipOutput } from './type' 18 | import { deepClone, hasContextChanged, logError, logWarn } from './utils' 19 | 20 | /** 21 | * This hook returns a flag object by its key. If no flag match the given key an empty flag will be returned. 22 | * @param key 23 | * @param defaultValue 24 | * @returns 25 | */ 26 | export const useFsFlag = ( 27 | key: string 28 | ): IFSFlag => { 29 | const { state } = useContext(FlagshipContext) 30 | const { visitor } = state 31 | 32 | if (!visitor) { 33 | return new FSFlag(key, state) 34 | } 35 | 36 | return visitor.getFlag(key) 37 | } 38 | 39 | const handleContextChange = (param:{ 40 | updateFunction: () => void, functionName: string, 41 | visitor?:Visitor, config?:IFlagshipConfig}): void => { 42 | const { updateFunction, functionName, visitor, config } = param 43 | if (!visitor) { 44 | logError(config, noVisitorMessage, functionName) 45 | return 46 | } 47 | const originalContextClone = deepClone(visitor.context) 48 | 49 | updateFunction() 50 | 51 | const updatedContext = visitor.context 52 | if (hasContextChanged(originalContextClone, updatedContext)) { 53 | visitor.fetchFlags() 54 | } 55 | } 56 | 57 | export const useFlagship = (): UseFlagshipOutput => { 58 | const { state } = useContext(FlagshipContext) 59 | const { visitor, config } = state 60 | 61 | const fsUpdateContext = useCallback((context: Record): void => { 62 | handleContextChange({ 63 | config, 64 | visitor, 65 | updateFunction: () => visitor?.updateContext(context), 66 | functionName: 'updateContext' 67 | }) 68 | }, [visitor]) 69 | 70 | const fsClearContext = useCallback((): void => { 71 | handleContextChange({ 72 | config, 73 | visitor, 74 | updateFunction: () => visitor?.clearContext(), 75 | functionName: 'cleanContext' 76 | }) 77 | }, [visitor]) 78 | 79 | const fsAuthenticate = useCallback((visitorId: string): void => { 80 | const functionName = 'authenticate' 81 | if (!visitor) { 82 | logError(config, noVisitorMessage, functionName) 83 | return 84 | } 85 | const originalVisitorId = visitor.visitorId 86 | visitor.authenticate(visitorId) 87 | if (originalVisitorId !== visitorId) { 88 | visitor.fetchFlags() 89 | } 90 | }, [visitor]) 91 | 92 | const fsUnauthenticate = useCallback((): void => { 93 | const functionName = 'unauthenticate' 94 | if (!visitor) { 95 | logError(config, noVisitorMessage, functionName) 96 | return 97 | } 98 | const originalVisitorId = visitor.visitorId 99 | visitor.unauthenticate() 100 | if (originalVisitorId !== visitor.visitorId) { 101 | visitor.fetchFlags() 102 | } 103 | }, [visitor]) 104 | 105 | const fsSendHit = useCallback((hit: IHit[] | IHit): Promise => { 106 | const functionName = 'sendHit' 107 | if (!visitor) { 108 | logError(config, noVisitorMessage, functionName) 109 | return Promise.resolve() 110 | } 111 | if (Array.isArray(hit)) { 112 | return visitor.sendHits(hit) 113 | } 114 | return visitor.sendHit(hit) 115 | }, [visitor]) 116 | 117 | const getFlag = useCallback((key: string): IFSFlag => { 118 | if (!visitor) { 119 | return new FSFlag(key, state) 120 | } 121 | return visitor.getFlag(key) 122 | }, [visitor]) 123 | 124 | const fetchFlags = useCallback(async (): Promise => { 125 | if (!visitor) { 126 | logWarn(config, noVisitorMessage, 'fetchFlags') 127 | return Promise.resolve() 128 | } 129 | return visitor.fetchFlags() 130 | }, [visitor]) 131 | 132 | const setConsent = useCallback((hasConsented: boolean): void => { 133 | if (!visitor) { 134 | logWarn(config, noVisitorMessage, 'setConsent') 135 | return 136 | } 137 | visitor.setConsent(hasConsented) 138 | }, [visitor]) 139 | 140 | const close = useCallback((): Promise => { 141 | return Flagship.close() 142 | }, []) 143 | 144 | const getFlags = useCallback(() => { 145 | if (!visitor) { 146 | const flags = new Map() 147 | state.flags?.forEach((flag, key) => { 148 | flags.set(key, new FSFlag(key, state)) 149 | }) 150 | return new FSFlagCollection({ flags }) 151 | } 152 | return visitor.getFlags() 153 | }, [visitor]) 154 | 155 | const collectEAIEventsAsync = useCallback(async (...args: unknown[]): Promise => { 156 | if (!visitor) { 157 | return 158 | } 159 | return visitor.collectEAIEventsAsync(...args) 160 | }, [visitor]) 161 | 162 | return useMemo(() => ({ 163 | visitorId: visitor?.visitorId, 164 | anonymousId: visitor?.anonymousId, 165 | context: { ...visitor?.context }, 166 | hasConsented: visitor?.hasConsented, 167 | sdkStatus: Flagship.getStatus(), 168 | flagsStatus: visitor?.flagsStatus, 169 | setConsent, 170 | updateContext: fsUpdateContext, 171 | clearContext: fsClearContext, 172 | authenticate: fsAuthenticate, 173 | unauthenticate: fsUnauthenticate, 174 | sendHits: fsSendHit, 175 | getFlag, 176 | fetchFlags, 177 | close, 178 | getFlags, 179 | collectEAIEventsAsync 180 | 181 | }), [visitor, setConsent, 182 | fsUpdateContext, 183 | fsClearContext, fsAuthenticate, fsUnauthenticate, 184 | fsSendHit, getFlag, fetchFlags, close, 185 | getFlags, 186 | collectEAIEventsAsync]) 187 | } 188 | -------------------------------------------------------------------------------- /src/FlagshipProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import React, { useState, useRef, ReactNode, useEffect } from 'react' 4 | 5 | import Flagship, { 6 | DecisionMode, 7 | Visitor, 8 | FSSdkStatus 9 | } from '@flagship.io/js-sdk' 10 | 11 | import { 12 | FlagshipContext, 13 | initStat 14 | } from './FlagshipContext' 15 | import { INTERNAL_EVENTS } from './internalType' 16 | import { version as SDK_VERSION } from './sdkVersion' 17 | import { FsContextState, FlagshipProviderProps } from './type' 18 | import { useNonInitialEffect, logError, extractFlagsMap } from './utils' 19 | 20 | export function FlagshipProvider ({ 21 | children, 22 | envId, 23 | apiKey, 24 | decisionMode = DecisionMode.DECISION_API, 25 | visitorData, 26 | loadingComponent, 27 | onSdkStatusChanged, 28 | onBucketingUpdated, 29 | initialCampaigns, 30 | initialFlagsData, 31 | fetchFlagsOnBucketingUpdated, 32 | hitDeduplicationTime = 2, 33 | fetchNow = true, 34 | language = 1, 35 | sdkVersion = SDK_VERSION, 36 | onFlagsStatusChanged, 37 | shouldSaveInstance, 38 | ...props 39 | }: FlagshipProviderProps): React.JSX.Element { 40 | const flags = extractFlagsMap(initialFlagsData, initialCampaigns) 41 | 42 | const [state, setState] = useState({ 43 | ...initStat, 44 | flags, 45 | hasVisitorData: !!visitorData 46 | }) 47 | const [lastModified, setLastModified] = useState() 48 | const stateRef = useRef() 49 | stateRef.current = state 50 | 51 | const visitorDataRef = useRef(visitorData) 52 | 53 | // #region functions 54 | 55 | const onBucketingLastModified = (lastUpdate: Date):void => { 56 | if (onBucketingUpdated) { 57 | onBucketingUpdated(lastUpdate) 58 | } 59 | setLastModified(lastUpdate) 60 | } 61 | 62 | const statusChanged = (status: FSSdkStatus):void => { 63 | if (onSdkStatusChanged) { 64 | onSdkStatusChanged(status) 65 | } 66 | 67 | switch (status) { 68 | case FSSdkStatus.SDK_PANIC: 69 | case FSSdkStatus.SDK_INITIALIZED: 70 | createVisitor() 71 | break 72 | case FSSdkStatus.SDK_NOT_INITIALIZED: 73 | setState((prev) => ({ 74 | ...prev, 75 | config: Flagship.getConfig(), 76 | isInitializing: false 77 | })) 78 | break 79 | } 80 | } 81 | 82 | const initSdk = ():void => { 83 | Flagship.start(envId, apiKey, { 84 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 85 | decisionMode: decisionMode as any, 86 | fetchNow, 87 | onSdkStatusChanged: statusChanged, 88 | onBucketingUpdated: onBucketingLastModified, 89 | hitDeduplicationTime, 90 | language, 91 | sdkVersion, 92 | ...props 93 | }) 94 | } 95 | 96 | function initializeState (param: { 97 | fsVisitor: Visitor; 98 | }):void { 99 | setState((currentState) => ({ 100 | ...currentState, 101 | visitor: param.fsVisitor, 102 | config: Flagship.getConfig(), 103 | isInitializing: false, 104 | hasVisitorData: !!visitorData 105 | }) 106 | ) 107 | } 108 | 109 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 110 | const onVisitorReady = (fsVisitor: Visitor, error: any):void => { 111 | if (error) { 112 | logError(Flagship.getConfig(), error.message || error, 'onReady') 113 | } 114 | initializeState({ fsVisitor }) 115 | } 116 | 117 | const createVisitor = ():void => { 118 | if (!visitorDataRef.current) { 119 | return 120 | } 121 | const fsVisitor = Flagship.newVisitor({ 122 | visitorId: visitorDataRef.current.id, 123 | context: visitorDataRef.current.context, 124 | isAuthenticated: visitorDataRef.current.isAuthenticated, 125 | hasConsented: visitorDataRef.current.hasConsented, 126 | initialCampaigns, 127 | initialFlagsData, 128 | onFlagsStatusChanged, 129 | shouldSaveInstance 130 | }) 131 | 132 | fsVisitor?.on('ready', (error) => { 133 | onVisitorReady(fsVisitor, error) 134 | }) 135 | 136 | if (!fetchNow) { 137 | initializeState({ fsVisitor }) 138 | } 139 | } 140 | 141 | function updateVisitor ():void { 142 | if (!visitorDataRef.current || Flagship.getStatus() !== FSSdkStatus.SDK_INITIALIZED) { 143 | return 144 | } 145 | if (!state.visitor || 146 | (state.visitor.visitorId !== visitorDataRef.current.id && 147 | (!visitorDataRef.current.isAuthenticated || (visitorDataRef.current.isAuthenticated && state.visitor.anonymousId))) 148 | ) { 149 | state.visitor?.cleanup() 150 | createVisitor() 151 | return 152 | } 153 | 154 | if (visitorDataRef.current.hasConsented !== state.visitor.hasConsented) { 155 | state.visitor.setConsent(visitorDataRef.current.hasConsented ?? true) 156 | } 157 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 158 | state.visitor.updateContext(visitorDataRef.current.context as any) 159 | 160 | if (!state.visitor.anonymousId && visitorDataRef.current.isAuthenticated) { 161 | state.visitor.authenticate(visitorDataRef.current.id as string) 162 | } 163 | if (state.visitor.anonymousId && !visitorDataRef.current.isAuthenticated) { 164 | state.visitor.unauthenticate() 165 | } 166 | state.visitor.fetchFlags() 167 | } 168 | 169 | // #endregion 170 | 171 | useNonInitialEffect(() => { 172 | if (fetchFlagsOnBucketingUpdated) { 173 | state.visitor?.fetchFlags() 174 | } 175 | }, [lastModified]) 176 | 177 | useNonInitialEffect(() => { 178 | visitorDataRef.current = visitorData 179 | updateVisitor() 180 | }, [JSON.stringify(visitorData)]) 181 | 182 | useEffect(() => { 183 | initSdk() 184 | }, [envId, apiKey, decisionMode]) 185 | 186 | const handleDisplay = (): ReactNode => { 187 | const isFirstInit = !state.visitor 188 | if ( 189 | state.isInitializing && 190 | loadingComponent && 191 | isFirstInit && 192 | fetchNow 193 | ) { 194 | return <>{loadingComponent} 195 | } 196 | return <>{children} 197 | } 198 | 199 | useEffect(() => { 200 | window?.addEventListener?.(INTERNAL_EVENTS.FsTriggerRendering, onVariationsForced) 201 | return () => window?.removeEventListener?.(INTERNAL_EVENTS.FsTriggerRendering, onVariationsForced) 202 | }, [state.config?.isQAModeEnabled]) 203 | 204 | const onVariationsForced = (e:Event):void => { 205 | const { detail } = e as CustomEvent<{ forcedReFetchFlags: boolean }> 206 | if (detail.forcedReFetchFlags) { 207 | stateRef.current?.visitor?.fetchFlags() 208 | } else { 209 | setState(state => ({ ...state, toggleForcedVariations: !state.toggleForcedVariations })) 210 | } 211 | } 212 | 213 | return ( 214 | 215 | {handleDisplay()} 216 | 217 | ) 218 | } 219 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const noVisitorMessage = 'flagship Visitor not initialized.' 2 | export const noVisitorDefault = 'fsVisitor not initialized, returns default value' 3 | 4 | export const GET_FLAG_CAST_ERROR = 'Flag for key {0} has a different type. Default value is returned.' 5 | export const GET_METADATA_CAST_ERROR = 'Flag for key {0} has a different type with defaultValue, an empty metadata object is returned' 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { FlagshipProvider } from './FlagshipProvider' 2 | 3 | export * from '@flagship.io/js-sdk' 4 | export * from './FlagshipHooks' 5 | export * from './type' 6 | 7 | export { FlagshipProvider } from './FlagshipProvider' 8 | 9 | export default FlagshipProvider 10 | -------------------------------------------------------------------------------- /src/internalType.ts: -------------------------------------------------------------------------------- 1 | export enum INTERNAL_EVENTS { 2 | FsTriggerRendering = 'FS_TRIGGER_RENDERING' 3 | } 4 | -------------------------------------------------------------------------------- /src/sdkVersion.ts: -------------------------------------------------------------------------------- 1 | // Generated by genversion. 2 | export const version = '5.1.0' 3 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, ReactNode, SetStateAction } from 'react' 2 | 3 | import { Visitor, IFlagshipConfig, FlagDTO, CampaignDTO, primitive, BucketingDTO, FSSdkStatus, IHit, IFSFlag, IFSFlagCollection, SerializedFlagMetadata, FlagsStatus } from '@flagship.io/js-sdk' 4 | 5 | export interface FsContextState { 6 | visitor?: Visitor; 7 | config?: IFlagshipConfig; 8 | flags?: Map; 9 | initialCampaigns?: CampaignDTO[]; 10 | initialFlags?: Map | FlagDTO[]; 11 | isInitializing: boolean; 12 | hasVisitorData?: boolean; 13 | toggleForcedVariations?: boolean 14 | } 15 | 16 | export type VisitorData = { 17 | id?: string; 18 | context?: Record; 19 | isAuthenticated?: boolean; 20 | hasConsented: boolean; 21 | }; 22 | export interface FsContext { 23 | state: FsContextState; 24 | setState?: Dispatch>; 25 | } 26 | 27 | /** 28 | * Props for the FlagshipProvider component. 29 | */ 30 | export interface FlagshipProviderProps extends IFlagshipConfig { 31 | /** 32 | * This is the data to identify the current visitor using your app. 33 | */ 34 | visitorData: VisitorData | null; 35 | /** 36 | * The environment ID for your Flagship project. 37 | */ 38 | envId: string; 39 | /** 40 | * The API key for your Flagship project. 41 | */ 42 | apiKey: string; 43 | /** 44 | * This component will be rendered when Flagship is loading at first initialization only. 45 | * By default, the value is undefined. It means it will display your app and it might 46 | * display default modifications value for a very short moment. 47 | */ 48 | loadingComponent?: ReactNode; 49 | /** 50 | * The child components to be rendered within the FlagshipProvider. 51 | */ 52 | children?: ReactNode; 53 | /** 54 | * This is an object of the data received when fetching bucketing endpoint. 55 | * Providing this prop will make bucketing ready to use and the first polling will immediately check for an update. 56 | * If the shape of an element is not correct, an error log will give the reason why. 57 | */ 58 | initialBucketing?: BucketingDTO; 59 | /** 60 | * An array of initial campaigns to be used by the SDK. 61 | */ 62 | initialCampaigns?: CampaignDTO[]; 63 | 64 | /** 65 | * This is a set of flag data provided to avoid the SDK to have an empty cache during the first initialization. 66 | */ 67 | initialFlagsData?: SerializedFlagMetadata[]; 68 | /** 69 | * If true, it'll automatically call fetchFlags when the bucketing file has updated. 70 | */ 71 | fetchFlagsOnBucketingUpdated?: boolean; 72 | 73 | /** 74 | * Callback function that will be called when the fetch flags status changes. 75 | * 76 | * @param newStatus - The new status of the flags fetch. 77 | * @param reason - The reason for the status change. 78 | */ 79 | onFlagsStatusChanged?: ({ status, reason }: FlagsStatus) => void; 80 | /** 81 | * If true, the newly created visitor instance won't be saved and will simply be returned. Otherwise, the newly created visitor instance will be returned and saved into the Flagship. 82 | * 83 | * Note: By default, it is false on server-side and true on client-side. 84 | */ 85 | shouldSaveInstance?: boolean; 86 | } 87 | 88 | /** 89 | * Represents the output of the `useFlagship` hook. 90 | */ 91 | export type UseFlagshipOutput = { 92 | /** 93 | * The visitor ID. 94 | */ 95 | visitorId?: string; 96 | /** 97 | * The anonymous ID. 98 | */ 99 | anonymousId?: string | null; 100 | /** 101 | * The visitor context. 102 | */ 103 | context?: Record; 104 | /** 105 | * Indicates whether the visitor has consented for protected data usage. 106 | */ 107 | hasConsented?: boolean; 108 | /** 109 | * Sets whether the visitor has consented for protected data usage. 110 | * @param hasConsented - True if the visitor has consented, false otherwise. 111 | */ 112 | setConsent: (hasConsented: boolean) => void; 113 | 114 | readonly sdkStatus: FSSdkStatus; 115 | 116 | readonly flagsStatus?: FlagsStatus 117 | /** 118 | * Updates the visitor context values, matching the given keys, used for targeting. 119 | * A new context value associated with this key will be created if there is no previous matching value. 120 | * Context keys must be strings, and value types must be one of the following: number, boolean, string. 121 | * @param context - A collection of keys and values. 122 | */ 123 | updateContext(context: Record): void; 124 | /** 125 | * Clears the actual visitor context. 126 | */ 127 | clearContext(): void; 128 | /** 129 | * Authenticates an anonymous visitor. 130 | * @param visitorId - The visitor ID. 131 | */ 132 | authenticate(visitorId: string): void; 133 | /** 134 | * Changes an authenticated visitor to an anonymous visitor. 135 | */ 136 | unauthenticate(): void; 137 | 138 | /** 139 | * Sends a hit or multiple hits to the Flagship server. 140 | * @param hit - The hit or array of hits to send. 141 | */ 142 | sendHits(hit: IHit): Promise; 143 | sendHits(hits: IHit[]): Promise; 144 | sendHits(hits: IHit | IHit[]): Promise; 145 | 146 | /** 147 | * Return a [Flag](#flag-class) object by its key. 148 | * If no flag matches the given key, an empty flag will be returned. 149 | * Call exists() to check if the flag has been found. 150 | * @param key - The flag key. 151 | * @param defaultValue - The default value. 152 | * @returns The flag object. 153 | */ 154 | getFlag(key: string): IFSFlag; 155 | /** 156 | * Invokes the `decision API` or refers to the `bucketing file` to refresh all campaign flags based on the visitor's context. 157 | */ 158 | fetchFlags: () => Promise; 159 | /** 160 | * Batches and sends all hits that are in the pool before the application is closed. 161 | * @returns A promise that resolves when all hits are sent. 162 | */ 163 | close(): Promise; 164 | 165 | /** 166 | * Returns a collection of all flags fetched for the visitor. 167 | * @returns An IFSFlagCollection object. 168 | */ 169 | getFlags(): IFSFlagCollection; 170 | 171 | /** 172 | * Collects Emotion AI data for the visitor. 173 | */ 174 | collectEAIEventsAsync(): Promise; 175 | }; 176 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { EffectCallback, DependencyList, useEffect, useRef } from 'react' 3 | 4 | import Flagship, { 5 | CampaignDTO, 6 | FlagDTO, 7 | IFlagshipConfig, 8 | LogLevel, 9 | SerializedFlagMetadata 10 | } from '@flagship.io/js-sdk' 11 | 12 | export function logError ( 13 | config: IFlagshipConfig | undefined, 14 | message: string, 15 | tag: string 16 | ): void { 17 | if (!config || !config.logLevel || config.logLevel < LogLevel.ERROR) { 18 | return 19 | } 20 | 21 | if (typeof config.onLog === 'function') { 22 | config.onLog(LogLevel.ERROR, tag, message) 23 | } 24 | 25 | if (config.logManager && typeof config.logManager.error === 'function') { 26 | config.logManager.error(message, tag) 27 | } 28 | } 29 | 30 | export function logInfo ( 31 | config: IFlagshipConfig | undefined, 32 | message: string, 33 | tag: string 34 | ): void { 35 | if (!config || !config.logLevel || config.logLevel < LogLevel.INFO) { 36 | return 37 | } 38 | 39 | if (typeof config.onLog === 'function') { 40 | config.onLog(LogLevel.INFO, tag, message) 41 | } 42 | 43 | if (config.logManager && typeof config.logManager.info === 'function') { 44 | config.logManager.info(message, tag) 45 | } 46 | } 47 | 48 | export function logWarn ( 49 | config: IFlagshipConfig | undefined, 50 | message: string, 51 | tag: string 52 | ): void { 53 | if (!config || !config.logLevel || config.logLevel < LogLevel.WARNING) { 54 | return 55 | } 56 | 57 | if (typeof config.onLog === 'function') { 58 | config.onLog(LogLevel.WARNING, tag, message) 59 | } 60 | 61 | if (config.logManager && typeof config.logManager.warning === 'function') { 62 | config.logManager.warning(message, tag) 63 | } 64 | } 65 | 66 | export const getFlagsFromCampaigns = ( 67 | campaigns: Array 68 | ): Map => { 69 | const flags = new Map() 70 | if (!campaigns || !Array.isArray(campaigns)) { 71 | return flags 72 | } 73 | campaigns.forEach((campaign) => { 74 | const object = campaign.variation.modifications.value 75 | for (const key in object) { 76 | const value = object[key] 77 | flags.set(key, { 78 | key, 79 | campaignId: campaign.id, 80 | campaignName: campaign.name || '', 81 | variationGroupId: campaign.variationGroupId, 82 | variationGroupName: campaign.variationGroupName || '', 83 | variationId: campaign.variation.id, 84 | variationName: campaign.variation.name || '', 85 | isReference: campaign.variation.reference, 86 | slug: campaign.slug || '', 87 | value 88 | }) 89 | } 90 | }) 91 | return flags 92 | } 93 | 94 | export function uuidV4 (): string { 95 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( 96 | /[xy]/g, 97 | function (char) { 98 | const rand = (Math.random() * 16) | 0 99 | const value = char === 'x' ? rand : (rand & 0x3) | 0x8 100 | return value.toString(16) 101 | } 102 | ) 103 | } 104 | 105 | export function useNonInitialEffect ( 106 | effect: EffectCallback, 107 | deps?: DependencyList 108 | ): void { 109 | const initialRender = useRef(true) 110 | 111 | useEffect(() => { 112 | if (initialRender.current) { 113 | initialRender.current = false 114 | return 115 | } 116 | 117 | if (typeof effect === 'function') { 118 | return effect() 119 | } 120 | }, deps) 121 | } 122 | 123 | export function hasSameType (flagValue:unknown, defaultValue:unknown):boolean { 124 | if (typeof flagValue !== typeof defaultValue) { 125 | return false 126 | } 127 | if (typeof flagValue === 'object' && typeof defaultValue === 'object' && 128 | Array.isArray(flagValue) !== Array.isArray(defaultValue) 129 | ) { 130 | return false 131 | } 132 | return true 133 | } 134 | 135 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 136 | export function sprintf (format: string, ...value: any[]): string { 137 | let formatted = format 138 | for (let i = 0; i < value.length; i++) { 139 | const element = value[i] 140 | formatted = formatted.replace(new RegExp(`\\{${i}\\}`, 'g'), element) 141 | } 142 | return formatted 143 | } 144 | 145 | export function hexToValue (hex: string, config: IFlagshipConfig): {v: unknown} | null { 146 | if (typeof hex !== 'string') { 147 | logError(config, `Invalid hex string: ${hex}`, 'hexToValue') 148 | return null 149 | } 150 | 151 | let jsonString = '' 152 | 153 | for (let i = 0; i < hex.length; i += 2) { 154 | const hexChar = hex.slice(i, i + 2) 155 | const charCode = parseInt(hexChar, 16) 156 | 157 | if (isNaN(charCode)) { 158 | logError(config, `Invalid hex character: ${hexChar}`, 'hexToValue') 159 | return null 160 | } 161 | 162 | jsonString += String.fromCharCode(charCode) 163 | } 164 | 165 | try { 166 | const value: {v: unknown} = JSON.parse(jsonString) 167 | return value 168 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 169 | } catch (error:any) { 170 | logError(config, `Error while parsing JSON: ${error?.message}`, 'hexToValue') 171 | return null 172 | } 173 | } 174 | 175 | export function extractFlagsMap (initialFlagsData?: SerializedFlagMetadata[], initialCampaigns?:CampaignDTO[]): Map { 176 | let flags = new Map() 177 | 178 | if (Array.isArray(initialFlagsData)) { 179 | initialFlagsData.forEach((flag) => { 180 | flags.set(flag.key, { 181 | key: flag.key, 182 | campaignId: flag.campaignId, 183 | campaignName: flag.campaignName, 184 | variationGroupId: flag.variationGroupId, 185 | variationGroupName: flag.variationGroupName, 186 | variationId: flag.variationId, 187 | variationName: flag.variationName, 188 | isReference: flag.isReference, 189 | campaignType: flag.campaignType, 190 | slug: flag.slug, 191 | value: hexToValue(flag.hex, Flagship.getConfig())?.v 192 | }) 193 | }) 194 | } else if (initialCampaigns) { 195 | flags = getFlagsFromCampaigns(initialCampaigns) 196 | } 197 | 198 | return flags 199 | } 200 | 201 | export function deepClone (obj: T): T { 202 | return JSON.parse(JSON.stringify(obj)) 203 | } 204 | 205 | export function hasContextChanged (original: T, updated: T): boolean { 206 | return JSON.stringify(original) !== JSON.stringify(updated) 207 | } 208 | -------------------------------------------------------------------------------- /test/Flag.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, it, describe } from '@jest/globals' 2 | 3 | import { FSFlagStatus, FlagDTO, FSFlagMetadata } from '@flagship.io/js-sdk' 4 | 5 | import { FSFlag } from '../src/FSFlag' 6 | import { FsContextState } from '../src/type' 7 | 8 | describe('Flag tests', () => { 9 | const defaultValue = 'value' 10 | const key = 'key' 11 | const flag = new FSFlag(key, { 12 | 13 | } as FsContextState) 14 | 15 | it('should have default value and empty metadata', () => { 16 | expect(flag.getValue(defaultValue)).toBe(defaultValue) 17 | expect(flag.exists()).toBe(false) 18 | expect(flag.visitorExposed()).resolves.toBeUndefined() 19 | expect(flag.metadata).toEqual(FSFlagMetadata.Empty()) 20 | expect(flag.status).toEqual(FSFlagStatus.NOT_FOUND) 21 | }) 22 | 23 | it('should have default value and empty metadata', () => { 24 | const flagsData = new Map() 25 | const flag = new FSFlag(key, { 26 | flags: flagsData 27 | } as FsContextState) 28 | expect(flag.getValue(defaultValue)).toBe(defaultValue) 29 | expect(flag.exists()).toBe(false) 30 | expect(flag.visitorExposed()).resolves.toBeUndefined() 31 | expect(flag.metadata).toEqual(FSFlagMetadata.Empty()) 32 | expect(flag.status).toEqual(FSFlagStatus.NOT_FOUND) 33 | }) 34 | 35 | it('should have overridden value and populated metadata', () => { 36 | const defaultValue = 'DefaultValue' 37 | const key = 'key' 38 | const value = 'value' 39 | const flagsData = new Map() 40 | flagsData.set(key, { 41 | key, 42 | campaignId: 'campaignId', 43 | campaignName: 'campaignName', 44 | campaignType: 'ab', 45 | variationGroupId: 'ab', 46 | variationGroupName: 'variationGroupName', 47 | variationId: 'varId', 48 | variationName: 'variationName', 49 | slug: 'slug', 50 | value 51 | }) 52 | const flag = new FSFlag(key, { 53 | flags: flagsData 54 | } as FsContextState) 55 | expect(flag.getValue(defaultValue)).toBe(value) 56 | expect(flag.exists()).toBe(true) 57 | expect(flag.visitorExposed()).resolves.toBeUndefined() 58 | expect(flag.metadata).toEqual({ 59 | campaignId: 'campaignId', 60 | campaignName: 'campaignName', 61 | campaignType: 'ab', 62 | isReference: false, 63 | variationGroupId: 'ab', 64 | variationGroupName: 'variationGroupName', 65 | variationId: 'varId', 66 | variationName: 'variationName', 67 | slug: 'slug' 68 | }) 69 | expect(flag.status).toEqual(FSFlagStatus.FETCHED) 70 | }) 71 | 72 | it('should have default value for non-string value', () => { 73 | const defaultValue = 'DefaultValue' 74 | const key = 'key' 75 | const value = 1 76 | const flagsData = new Map() 77 | flagsData.set(key, { 78 | key, 79 | campaignId: 'campaignId', 80 | campaignName: 'campaignName', 81 | campaignType: 'ab', 82 | variationGroupId: 'ab', 83 | variationGroupName: 'variationGroupName', 84 | variationId: 'varId', 85 | variationName: 'variationName', 86 | slug: 'slug', 87 | value 88 | }) 89 | const flag = new FSFlag(key, { 90 | flags: flagsData 91 | } as FsContextState) 92 | expect(flag.getValue(defaultValue)).toBe(defaultValue) 93 | expect(flag.exists()).toBe(true) 94 | expect(flag.visitorExposed()).resolves.toBeUndefined() 95 | }) 96 | 97 | it('should have default value when flag value is null', () => { 98 | const defaultValue = 'DefaultValue' 99 | const key = 'key' 100 | const value = null 101 | const flagsData = new Map() 102 | flagsData.set(key, { 103 | key, 104 | campaignId: 'campaignId', 105 | campaignName: 'campaignName', 106 | campaignType: 'ab', 107 | variationGroupId: 'ab', 108 | variationGroupName: 'variationGroupName', 109 | variationId: 'varId', 110 | variationName: 'variationName', 111 | slug: 'slug', 112 | value 113 | }) 114 | const flag = new FSFlag(key, { 115 | flags: flagsData 116 | } as FsContextState) 117 | expect(flag.getValue(defaultValue)).toBe(defaultValue) 118 | expect(flag.exists()).toBe(true) 119 | expect(flag.visitorExposed()).resolves.toBeUndefined() 120 | }) 121 | 122 | it('should have flag value when default value is null', () => { 123 | const defaultValue = null 124 | const key = 'key' 125 | const value = 'value1' 126 | const flagsData = new Map() 127 | flagsData.set(key, { 128 | key, 129 | campaignId: 'campaignId', 130 | campaignName: 'campaignName', 131 | campaignType: 'ab', 132 | variationGroupId: 'ab', 133 | variationGroupName: 'variationGroupName', 134 | variationId: 'varId', 135 | variationName: 'variationName', 136 | slug: 'slug', 137 | value 138 | }) 139 | const flag = new FSFlag(key, { 140 | flags: flagsData 141 | } as FsContextState) 142 | expect(flag.getValue(defaultValue)).toBe(value) 143 | expect(flag.exists()).toBe(true) 144 | expect(flag.visitorExposed()).resolves.toBeUndefined() 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /test/FlagshipContext.test.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-use-before-define 2 | import React from 'react' 3 | 4 | import { jest, expect, it, describe, beforeEach, afterEach } from '@jest/globals' 5 | import { render, waitFor } from '@testing-library/react' 6 | import { SpyInstance } from 'jest-mock' 7 | 8 | import Flagship, { DecisionMode, FSSdkStatus, FlagDTO, SerializedFlagMetadata } from '@flagship.io/js-sdk' 9 | 10 | import { useFlagship } from '../src/FlagshipHooks' 11 | import { FlagshipProvider } from '../src/FlagshipProvider' 12 | import { INTERNAL_EVENTS } from '../src/internalType' 13 | import { hexToValue } from '../src/utils' 14 | 15 | function sleep (ms: number): Promise { 16 | return new Promise((resolve) => setTimeout(resolve, ms)) 17 | } 18 | 19 | const mockStart = Flagship.start as unknown as SpyInstance 20 | 21 | const newVisitor = Flagship.newVisitor as unknown as SpyInstance 22 | const flagsData = new Map() 23 | const updateContext = jest.fn() 24 | const unauthenticate = jest.fn() 25 | const authenticate = jest.fn<(params: string)=>void>() 26 | const setConsent = jest.fn() 27 | const clearContext = jest.fn() 28 | const fetchFlags = jest.fn() 29 | const cleanup = jest.fn() 30 | const getFlagsDataArray = jest.fn() 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 32 | const getFlag = jest.fn() as any 33 | 34 | getFlag.mockImplementation((key: string) => { 35 | return { 36 | getValue: (defaultValue: string) => { 37 | return flagsData.get(key)?.value ?? defaultValue 38 | } 39 | } 40 | }) 41 | 42 | let onEventError = false 43 | 44 | let fsSdkStatus = FSSdkStatus.SDK_INITIALIZED 45 | 46 | jest.mock('@flagship.io/js-sdk', () => { 47 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 48 | const flagship = jest.requireActual('@flagship.io/js-sdk') as any 49 | 50 | const mockStart = jest.spyOn(flagship.Flagship, 'start') 51 | const newVisitor = jest.spyOn(flagship.Flagship, 'newVisitor') 52 | const getStatus = jest.spyOn(flagship.Flagship, 'getStatus') 53 | 54 | let fistStart = true 55 | let localFetchNow = true 56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 | mockStart.mockImplementation( 58 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 59 | (_apiKey, _envId, { onBucketingUpdated, onSdkStatusChanged, fetchNow, decisionMode }: any) => { 60 | localFetchNow = fetchNow 61 | onSdkStatusChanged(fsSdkStatus) 62 | getStatus.mockImplementation(() => fsSdkStatus) 63 | if (fistStart && decisionMode === DecisionMode.BUCKETING) { 64 | onBucketingUpdated(new Date()) 65 | fistStart = false 66 | } 67 | } 68 | ) 69 | 70 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 71 | let OnReadyCallback: (error?: any) => void 72 | 73 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 74 | newVisitor.mockImplementation(({ visitorId, isAuthenticated, initialFlagsData }:any) => { 75 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 | const EventOn = jest.fn<(e:string, callback:(error?: any) => void)=>void>() 77 | 78 | if (initialFlagsData) { 79 | initialFlagsData.forEach((item: SerializedFlagMetadata) => { 80 | flagsData.set(item.key, { 81 | key: item.key, 82 | campaignId: item.campaignId, 83 | campaignName: item.campaignName, 84 | variationGroupId: item.variationGroupId, 85 | variationGroupName: item.variationGroupName, 86 | variationId: item.variationId, 87 | variationName: item.variationName, 88 | isReference: item.isReference, 89 | value: hexToValue(item.hex, {})?.v, 90 | slug: item.slug, 91 | campaignType: item.campaignType 92 | }) 93 | }) 94 | } 95 | 96 | EventOn.mockImplementation((_e, callback) => { 97 | if (callback) { 98 | OnReadyCallback = callback 99 | } 100 | }) 101 | 102 | fetchFlags.mockImplementation(async () => { 103 | await sleep(0) 104 | if (OnReadyCallback) { 105 | OnReadyCallback(onEventError ? new Error() : null) 106 | } 107 | }) 108 | 109 | const newVisitor = { 110 | visitorId, 111 | anonymousId: isAuthenticated ? Math.random().toString(36).substr(2, 9) : '', 112 | on: EventOn, 113 | getFlagsDataArray, 114 | fetchFlags, 115 | flagsData, 116 | updateContext, 117 | unauthenticate, 118 | authenticate, 119 | setConsent, 120 | clearContext, 121 | cleanup, 122 | getFlag 123 | } 124 | 125 | authenticate.mockImplementation((visitorId) => { 126 | newVisitor.anonymousId = visitorId 127 | }) 128 | unauthenticate.mockImplementation(() => { 129 | newVisitor.anonymousId = '' 130 | }) 131 | 132 | if (localFetchNow) { 133 | newVisitor.fetchFlags() 134 | } 135 | return newVisitor 136 | }) 137 | 138 | return flagship 139 | }) 140 | 141 | describe('FlagshipProvide test', () => { 142 | const visitorData = { 143 | id: 'visitor_id', 144 | context: {}, 145 | isAuthenticated: false, 146 | hasConsented: true 147 | } 148 | const envId = 'EnvId' 149 | const apiKey = 'apiKey' 150 | const onSdkStatusChanged = jest.fn() 151 | const onBucketingUpdated = jest.fn() 152 | const props = { 153 | envId, 154 | apiKey, 155 | decisionMode: DecisionMode.DECISION_API, 156 | visitorData, 157 | onSdkStatusChanged, 158 | fetchNow: true, 159 | onBucketingUpdated, 160 | loadingComponent:
Loading
, 161 | fetchFlagsOnBucketingUpdated: true 162 | } 163 | 164 | it('should ', async () => { 165 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 166 | const MyChildrenComponent = () => { 167 | return
children
168 | } 169 | const { rerender, getByTestId } = render( 170 | 171 | 172 | 173 | ) 174 | 175 | expect(getByTestId('loading').textContent).toBe('Loading') 176 | 177 | await waitFor(() => { 178 | expect(mockStart).toBeCalledTimes(1) 179 | expect(mockStart).toBeCalledWith( 180 | envId, 181 | apiKey, 182 | expect.objectContaining({ 183 | decisionMode: DecisionMode.DECISION_API, 184 | onBucketingUpdated: expect.anything() 185 | }) 186 | ) 187 | expect(newVisitor).toBeCalledTimes(1) 188 | 189 | expect(getByTestId('body').textContent).toBe('children') 190 | expect(fetchFlags).toBeCalledTimes(1) 191 | // expect(onBucketingUpdated).toBeCalledTimes(1) 192 | expect(onSdkStatusChanged).toBeCalledTimes(1) 193 | }) 194 | 195 | // Authenticate visitor 196 | rerender( 197 | 201 |
children
202 |
203 | ) 204 | 205 | await waitFor(() => { 206 | expect(mockStart).toBeCalledTimes(1) 207 | expect(authenticate).toBeCalledTimes(1) 208 | expect(fetchFlags).toBeCalledTimes(2) 209 | // expect(onBucketingUpdated).toBeCalledTimes(1) 210 | expect(onSdkStatusChanged).toBeCalledTimes(1) 211 | }) 212 | 213 | rerender( 214 | 218 |
children
219 |
220 | ) 221 | 222 | await waitFor(() => { 223 | expect(mockStart).toBeCalledTimes(1) 224 | expect(authenticate).toBeCalledTimes(1) 225 | expect(fetchFlags).toBeCalledTimes(3) 226 | // expect(onBucketingUpdated).toBeCalledTimes(1) 227 | expect(onSdkStatusChanged).toBeCalledTimes(1) 228 | expect(newVisitor).toBeCalledTimes(2) 229 | }) 230 | 231 | // Unauthenticate visitor 232 | rerender( 233 | 237 |
children
238 |
239 | ) 240 | 241 | await waitFor(() => { 242 | expect(mockStart).toBeCalledTimes(1) 243 | expect(unauthenticate).toBeCalledTimes(1) 244 | expect(newVisitor).toBeCalledTimes(2) 245 | expect(fetchFlags).toBeCalledTimes(4) 246 | }) 247 | 248 | // Unauthenticate visitor 249 | rerender( 250 | 254 |
children
255 |
256 | ) 257 | 258 | await waitFor(() => { 259 | expect(mockStart).toBeCalledTimes(1) 260 | expect(unauthenticate).toBeCalledTimes(1) 261 | expect(newVisitor).toBeCalledTimes(3) 262 | expect(fetchFlags).toBeCalledTimes(5) 263 | }) 264 | 265 | rerender( 266 | 270 |
children
271 |
272 | ) 273 | 274 | await waitFor(() => { 275 | expect(mockStart).toBeCalledTimes(1) 276 | expect(unauthenticate).toBeCalledTimes(1) 277 | expect(newVisitor).toBeCalledTimes(3) 278 | expect(fetchFlags).toBeCalledTimes(5) 279 | }) 280 | 281 | // Update envId props 282 | render( 283 | 284 |
children
285 |
286 | ) 287 | 288 | await waitFor(() => { 289 | expect(mockStart).toBeCalledTimes(2) 290 | expect(mockStart).toBeCalledWith( 291 | 'new_env_id', 292 | apiKey, 293 | expect.objectContaining({ 294 | decisionMode: DecisionMode.DECISION_API, 295 | onBucketingUpdated: expect.anything() 296 | }) 297 | ) 298 | expect(newVisitor).toBeCalledTimes(4) 299 | expect(fetchFlags).toBeCalledTimes(6) 300 | }) 301 | 302 | onEventError = true 303 | 304 | render( 305 | 306 |
children
307 |
308 | ) 309 | }) 310 | 311 | it('Test fetchNow false', async () => { 312 | // Update envId props 313 | render( 314 | 315 |
children
316 |
317 | ) 318 | 319 | await waitFor(() => { 320 | expect(mockStart).toBeCalledTimes(1) 321 | expect(mockStart).toBeCalledWith( 322 | 'new_env_id', 323 | apiKey, 324 | expect.objectContaining({ 325 | decisionMode: DecisionMode.DECISION_API, 326 | onBucketingUpdated: expect.anything() 327 | }) 328 | ) 329 | expect(newVisitor).toBeCalledTimes(1) 330 | }) 331 | }) 332 | }) 333 | describe('FlagshipProvide test bucketing', () => { 334 | const visitorData = { 335 | id: 'visitor_id', 336 | context: {}, 337 | isAuthenticated: false, 338 | hasConsented: true 339 | } 340 | const envId = 'EnvId' 341 | const apiKey = 'apiKey' 342 | const onSdkStatusChanged = jest.fn() 343 | const onBucketingUpdated = jest.fn() 344 | const props = { 345 | envId, 346 | apiKey, 347 | decisionMode: DecisionMode.BUCKETING, 348 | visitorData, 349 | onSdkStatusChanged, 350 | fetchNow: true, 351 | onBucketingUpdated, 352 | loadingComponent:
Loading
, 353 | fetchFlagsOnBucketingUpdated: true 354 | } 355 | 356 | it('should ', async () => { 357 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 358 | const MyChildrenComponent = () => { 359 | return
children
360 | } 361 | const { rerender, getByTestId } = render( 362 | 363 | 364 | 365 | ) 366 | 367 | expect(getByTestId('loading').textContent).toBe('Loading') 368 | 369 | await waitFor(() => { 370 | expect(mockStart).toBeCalledTimes(1) 371 | expect(mockStart).toBeCalledWith( 372 | envId, 373 | apiKey, 374 | expect.objectContaining({ 375 | decisionMode: DecisionMode.BUCKETING, 376 | onBucketingUpdated: expect.anything() 377 | }) 378 | ) 379 | expect(newVisitor).toBeCalledTimes(1) 380 | 381 | expect(getByTestId('body').textContent).toBe('children') 382 | expect(fetchFlags).toBeCalledTimes(1) 383 | expect(onBucketingUpdated).toBeCalledTimes(1) 384 | expect(onSdkStatusChanged).toBeCalledTimes(1) 385 | }) 386 | 387 | // Authenticate visitor 388 | rerender( 389 | 393 |
children
394 |
395 | ) 396 | 397 | await waitFor(() => { 398 | expect(mockStart).toBeCalledTimes(1) 399 | expect(authenticate).toBeCalledTimes(1) 400 | expect(fetchFlags).toBeCalledTimes(2) 401 | expect(onBucketingUpdated).toBeCalledTimes(1) 402 | expect(onSdkStatusChanged).toBeCalledTimes(1) 403 | }) 404 | }) 405 | }) 406 | 407 | describe('FlagshipProvide test SDK_NOT_INITIALIZED', () => { 408 | beforeEach(() => { 409 | fsSdkStatus = FSSdkStatus.SDK_NOT_INITIALIZED 410 | }) 411 | afterEach(() => { 412 | fsSdkStatus = FSSdkStatus.SDK_INITIALIZED 413 | }) 414 | 415 | const visitorData = { 416 | id: 'visitor_id', 417 | context: {}, 418 | isAuthenticated: false, 419 | hasConsented: true 420 | } 421 | const envId = 'EnvId' 422 | const apiKey = 'apiKey' 423 | const onSdkStatusChanged = jest.fn() 424 | const onBucketingUpdated = jest.fn() 425 | const props = { 426 | envId, 427 | apiKey, 428 | decisionMode: DecisionMode.BUCKETING, 429 | visitorData, 430 | onSdkStatusChanged, 431 | fetchNow: true, 432 | onBucketingUpdated, 433 | loadingComponent:
Loading
, 434 | fetchFlagsOnBucketingUpdated: true 435 | } 436 | 437 | it('should ', async () => { 438 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 439 | const MyChildrenComponent = () => { 440 | return
children
441 | } 442 | const { rerender, getByTestId } = render( 443 | 444 | 445 | 446 | ) 447 | 448 | await waitFor(() => { 449 | expect(mockStart).toBeCalledTimes(1) 450 | expect(mockStart).toBeCalledWith( 451 | envId, 452 | apiKey, 453 | expect.objectContaining({ 454 | decisionMode: DecisionMode.BUCKETING, 455 | onBucketingUpdated: expect.anything() 456 | }) 457 | ) 458 | expect(newVisitor).toBeCalledTimes(0) 459 | 460 | expect(getByTestId('body').textContent).toBe('children') 461 | expect(fetchFlags).toBeCalledTimes(0) 462 | expect(onBucketingUpdated).toBeCalledTimes(0) 463 | expect(onSdkStatusChanged).toBeCalledTimes(1) 464 | }) 465 | 466 | // Authenticate visitor 467 | rerender( 468 | 472 |
children
473 |
474 | ) 475 | 476 | await waitFor(() => { 477 | expect(mockStart).toBeCalledTimes(1) 478 | expect(authenticate).toBeCalledTimes(0) 479 | expect(fetchFlags).toBeCalledTimes(0) 480 | expect(onBucketingUpdated).toBeCalledTimes(0) 481 | expect(onSdkStatusChanged).toBeCalledTimes(1) 482 | }) 483 | }) 484 | }) 485 | 486 | describe('Test visitorData null', () => { 487 | const visitorData = { 488 | id: 'visitor_id', 489 | context: {}, 490 | isAuthenticated: false, 491 | hasConsented: true 492 | } 493 | const envId = 'EnvId' 494 | const apiKey = 'apiKey' 495 | const onSdkStatusChanged = jest.fn() 496 | const onInitStart = jest.fn() 497 | const onInitDone = jest.fn() 498 | const onUpdate = jest.fn() 499 | const onBucketingUpdated = jest.fn() 500 | 501 | it('should ', async () => { 502 | const props = { 503 | envId, 504 | apiKey, 505 | decisionMode: DecisionMode.DECISION_API, 506 | visitorData: null, 507 | onSdkStatusChanged, 508 | onInitStart, 509 | onInitDone, 510 | onUpdate, 511 | onBucketingUpdated, 512 | loadingComponent:
, 513 | synchronizeOnBucketingUpdated: true 514 | } 515 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 516 | const { rerender } = render( 517 | 518 |
children
519 |
520 | ) 521 | 522 | await waitFor(() => { 523 | expect(mockStart).toBeCalledTimes(1) 524 | expect(mockStart).toBeCalledWith( 525 | envId, 526 | apiKey, 527 | expect.objectContaining({ 528 | decisionMode: DecisionMode.DECISION_API, 529 | onBucketingUpdated: expect.anything() 530 | }) 531 | ) 532 | expect(newVisitor).toBeCalledTimes(0) 533 | 534 | expect(fetchFlags).toBeCalledTimes(0) 535 | expect(onBucketingUpdated).toBeCalledTimes(0) 536 | expect(onSdkStatusChanged).toBeCalledTimes(1) 537 | expect(onUpdate).toBeCalledTimes(0) 538 | }) 539 | 540 | rerender( 541 | 542 |
children
543 |
544 | ) 545 | 546 | await waitFor(() => { 547 | expect(mockStart).toBeCalledTimes(1) 548 | expect(mockStart).toBeCalledWith( 549 | envId, 550 | apiKey, 551 | expect.objectContaining({ 552 | decisionMode: DecisionMode.DECISION_API, 553 | onBucketingUpdated: expect.anything() 554 | }) 555 | ) 556 | expect(newVisitor).toBeCalledTimes(1) 557 | 558 | expect(newVisitor).toBeCalledWith({ 559 | visitorId: visitorData.id, 560 | context: visitorData.context, 561 | isAuthenticated: visitorData.isAuthenticated, 562 | hasConsented: visitorData.hasConsented 563 | }) 564 | 565 | expect(fetchFlags).toBeCalledTimes(1) 566 | expect(onBucketingUpdated).toBeCalledTimes(0) 567 | expect(onSdkStatusChanged).toBeCalledTimes(1) 568 | }) 569 | }) 570 | }) 571 | 572 | describe('Test initial data', () => { 573 | const visitorData = { 574 | id: 'visitor_id', 575 | context: {}, 576 | isAuthenticated: false, 577 | hasConsented: true 578 | } 579 | const envId = 'EnvId' 580 | const apiKey = 'apiKey' 581 | 582 | const ChildComponent = () => { 583 | const fs = useFlagship() 584 | const flag1 = fs.getFlag('key1') 585 | const flag2 = fs.getFlag('key2') 586 | return
587 |
{flag1.getValue('default')}
588 |
{flag2.getValue('default')}
589 |
590 | } 591 | 592 | it('test initialFlagsData ', async () => { 593 | const props = { 594 | envId, 595 | apiKey, 596 | decisionMode: DecisionMode.DECISION_API, 597 | visitorData, 598 | initialFlagsData: [ 599 | { 600 | key: 'key1', 601 | campaignId: 'campaignId1', 602 | campaignName: 'campaignName1', 603 | variationGroupId: 'variationGroupId2', 604 | variationGroupName: 'variationGroupName1', 605 | variationId: 'variationId3', 606 | variationName: 'variationName1', 607 | isReference: false, 608 | campaignType: 'ab', 609 | hex: '7b2276223a2274657374227d' 610 | 611 | }, 612 | { 613 | key: 'key2', 614 | campaignId: 'campaignId2', 615 | campaignName: 'campaignName2', 616 | variationGroupId: 'variationGroupId2', 617 | variationGroupName: 'variationGroupName', 618 | variationId: 'variationId3', 619 | variationName: 'variationName', 620 | isReference: false, 621 | campaignType: 'ab', 622 | hex: '7b2276223a2274657374227d' 623 | 624 | } 625 | ] 626 | } 627 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 628 | const { getByTestId } = render( 629 | 630 | 631 | 632 | ) 633 | 634 | await waitFor(() => { 635 | expect(mockStart).toBeCalledTimes(1) 636 | expect(mockStart).toBeCalledWith( 637 | envId, 638 | apiKey, 639 | expect.objectContaining({ 640 | decisionMode: DecisionMode.DECISION_API 641 | }) 642 | ) 643 | expect(newVisitor).toBeCalledTimes(1) 644 | expect(newVisitor).toBeCalledWith(expect.objectContaining({ 645 | initialFlagsData: props.initialFlagsData 646 | })) 647 | 648 | expect(getByTestId('key1').textContent).toBe('test') 649 | expect(getByTestId('key2').textContent).toBe('test') 650 | }) 651 | }) 652 | }) 653 | 654 | describe('Force variations', () => { 655 | const visitorData = { 656 | id: 'visitor_id', 657 | context: {}, 658 | isAuthenticated: false, 659 | hasConsented: true 660 | } 661 | const envId = 'EnvId' 662 | const apiKey = 'apiKey' 663 | 664 | const ChildComponent = () => { 665 | const fs = useFlagship() 666 | const flag1 = fs.getFlag('key1') 667 | const flag2 = fs.getFlag('key2') 668 | return
669 |
{flag1.getValue('default')}
670 |
{flag2.getValue('default')}
671 |
672 | } 673 | 674 | it('test initialFlagsData ', async () => { 675 | const props = { 676 | envId, 677 | apiKey, 678 | decisionMode: DecisionMode.DECISION_API, 679 | visitorData 680 | } 681 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 682 | render( 683 | 684 | 685 | 686 | ) 687 | 688 | await waitFor(() => { 689 | expect(mockStart).toBeCalledTimes(1) 690 | expect(mockStart).toBeCalledWith( 691 | envId, 692 | apiKey, 693 | expect.objectContaining({ 694 | decisionMode: DecisionMode.DECISION_API 695 | }) 696 | ) 697 | expect(newVisitor).toBeCalledTimes(1) 698 | expect(fetchFlags).toBeCalledTimes(1) 699 | }) 700 | 701 | await waitFor(() => { 702 | const triggerRenderEvent = new CustomEvent<{ forcedReFetchFlags: boolean }>(INTERNAL_EVENTS.FsTriggerRendering, { 703 | detail: { 704 | forcedReFetchFlags: true 705 | } 706 | }) 707 | window.dispatchEvent(triggerRenderEvent) 708 | 709 | expect(fetchFlags).toBeCalledTimes(2) 710 | }) 711 | 712 | await waitFor(() => { 713 | const triggerRenderEvent = new CustomEvent<{ forcedReFetchFlags: boolean }>(INTERNAL_EVENTS.FsTriggerRendering, { 714 | detail: { 715 | forcedReFetchFlags: false 716 | } 717 | }) 718 | window.dispatchEvent(triggerRenderEvent) 719 | 720 | expect(fetchFlags).toBeCalledTimes(2) 721 | }) 722 | }) 723 | }) 724 | -------------------------------------------------------------------------------- /test/FlagshipHooks.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { jest, expect, it, describe, beforeEach, afterEach } from '@jest/globals' 4 | import { renderHook } from '@testing-library/react-hooks' 5 | import { Mock } from 'jest-mock' 6 | 7 | import Flagship, { FSFlagCollection, HitType, LogLevel, primitive } from '@flagship.io/js-sdk' 8 | 9 | import * as FsHooks from '../src/FlagshipHooks' 10 | import { FSFlag } from '../src/FSFlag' 11 | 12 | describe('test FlagshipHooks', () => { 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | let realUseContext:(context: React.Context)=> any 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | let useContextMock:Mock<(context: React.Context)=> any> 17 | // Setup mock 18 | beforeEach(() => { 19 | realUseContext = React.useContext 20 | useContextMock = jest.fn() 21 | React.useContext = useContextMock 22 | }) 23 | // Cleanup mock 24 | afterEach(() => { 25 | React.useContext = realUseContext 26 | }) 27 | 28 | it('useFsGetFlag test', async () => { 29 | const visitor = { 30 | getFlag: jest.fn() 31 | } 32 | const expected = { 33 | value: () => true 34 | } 35 | 36 | visitor.getFlag.mockReturnValue(expected) 37 | useContextMock.mockReturnValue({ state: { visitor } }) 38 | 39 | const key = 'key' 40 | const { result } = renderHook(() => FsHooks.useFsFlag(key)) 41 | expect(result.current).toEqual(expected) 42 | expect(visitor.getFlag).toBeCalledTimes(1) 43 | expect(visitor.getFlag).toBeCalledWith(key) 44 | }) 45 | 46 | it('useFsGetFlag test sdk not ready', async () => { 47 | useContextMock.mockReturnValue({ state: { } }) 48 | 49 | const key = 'key' 50 | const { result } = renderHook(() => FsHooks.useFsFlag(key)) 51 | expect(result.current).toBeInstanceOf(FSFlag) 52 | }) 53 | 54 | it('useFsModifications with useFsFlag ', async () => { 55 | const state = { 56 | status: { 57 | isSdkReady: false 58 | }, 59 | flags: new Map([ 60 | ['key', { key: 'key', value: 'value1' }], 61 | ['key1', { key: 'key1', value: 'value2' }], 62 | ['key2', { key: 'key2', value: { key: 2 } }], 63 | ['key3', { key: 'key3', value: [2, 2, 2] }] 64 | ]) 65 | } 66 | useContextMock.mockReturnValue({ 67 | state 68 | }) 69 | 70 | const { result } = renderHook(() => FsHooks.useFsFlag('key')) 71 | 72 | expect(result.current.getValue('default')).toEqual('value1') 73 | }) 74 | 75 | it('should test FlagshipHooks', async () => { 76 | const config = { 77 | logManager: { 78 | error: jest.fn(), 79 | warning: jest.fn() 80 | }, 81 | logLevel: LogLevel.ALL 82 | } 83 | 84 | function updateContext (context: Record) { 85 | visitor.context = { ...visitor.context, ...context } 86 | } 87 | 88 | function clearContext () { 89 | visitor.context = {} 90 | } 91 | 92 | function authenticate (visitorId: string) { 93 | visitor.anonymousId = visitor.visitorId 94 | visitor.visitorId = visitorId 95 | } 96 | 97 | function unauthenticate () { 98 | visitor.visitorId = visitor.anonymousId 99 | visitor.anonymousId = '' 100 | } 101 | 102 | const visitor = { 103 | anonymousId: '', 104 | visitorId: 'AnonymousVisitorId', 105 | updateContext: jest.fn(), 106 | clearContext: jest.fn(), 107 | authenticate: jest.fn(), 108 | unauthenticate: jest.fn(), 109 | sendHit: jest.fn(), 110 | sendHits: jest.fn(), 111 | getFlag: jest.fn(), 112 | getFlags: jest.fn(), 113 | fetchFlags: jest.fn(), 114 | getFlagsDataArray: jest.fn(), 115 | setConsent: jest.fn(), 116 | collectEAIEventsAsync: jest.fn<()=> Promise>(), 117 | context: {} 118 | } 119 | 120 | visitor.updateContext.mockImplementation(updateContext) 121 | visitor.clearContext.mockImplementation(clearContext) 122 | visitor.authenticate.mockImplementation(authenticate) 123 | visitor.unauthenticate.mockImplementation(unauthenticate) 124 | 125 | useContextMock.mockReturnValue({ 126 | state: { 127 | config, 128 | sdkState: { 129 | isSdkReady: true 130 | } 131 | } 132 | }) 133 | 134 | Flagship.close = jest.fn<()=>Promise>() 135 | 136 | const { result } = renderHook(() => FsHooks.useFlagship()) 137 | 138 | let fs = result.current 139 | 140 | const context = { key: 'context' } 141 | 142 | fs.updateContext(context) 143 | fs.clearContext() 144 | fs.sendHits({ type: HitType.PAGE, documentLocation: 'home' }) 145 | fs.sendHits([{ type: HitType.PAGE, documentLocation: 'home' }]) 146 | fs.authenticate('visitor_id') 147 | fs.unauthenticate() 148 | expect(config.logManager.error).toBeCalledTimes(6) 149 | 150 | useContextMock.mockReturnValue({ 151 | state: { 152 | config, 153 | visitor, 154 | sdkState: { 155 | isSdkReady: true 156 | } 157 | } 158 | 159 | }) 160 | 161 | const { result: result2 } = renderHook(() => FsHooks.useFlagship()) 162 | 163 | fs = result2.current 164 | 165 | const flagKey = 'key' 166 | fs.getFlag(flagKey) 167 | 168 | expect(visitor.getFlag).toBeCalledTimes(1) 169 | expect(visitor.getFlag).toBeCalledWith(flagKey) 170 | 171 | fs.fetchFlags() 172 | expect(visitor.fetchFlags).toBeCalledTimes(1) 173 | 174 | fs.updateContext(context) 175 | fs.updateContext(context) 176 | 177 | expect(visitor.updateContext).toBeCalledTimes(2) 178 | expect(visitor.updateContext).toBeCalledWith(context) 179 | expect(visitor.fetchFlags).toBeCalledTimes(2) 180 | 181 | fs.clearContext() 182 | fs.clearContext() 183 | expect(visitor.clearContext).toBeCalledTimes(2) 184 | expect(visitor.fetchFlags).toBeCalledTimes(3) 185 | fs.setConsent(true) 186 | 187 | expect(visitor.setConsent).toBeCalledTimes(1) 188 | expect(visitor.setConsent).toBeCalledWith(true) 189 | 190 | const hit = { type: HitType.PAGE, documentLocation: 'home' } 191 | await fs.sendHits(hit) 192 | expect(visitor.sendHit).toBeCalledTimes(1) 193 | expect(visitor.sendHit).toBeCalledWith(hit) 194 | 195 | await fs.sendHits([hit]) 196 | expect(visitor.sendHits).toBeCalledTimes(1) 197 | expect(visitor.sendHits).toBeCalledWith([hit]) 198 | 199 | const visitorId = 'visitor_id' 200 | fs.authenticate(visitorId) 201 | fs.authenticate(visitorId) 202 | 203 | expect(visitor.authenticate).toBeCalledTimes(2) 204 | expect(visitor.authenticate).toBeCalledWith(visitorId) 205 | expect(visitor.fetchFlags).toBeCalledTimes(4) 206 | 207 | fs.unauthenticate() 208 | fs.unauthenticate() 209 | 210 | expect(visitor.unauthenticate).toBeCalledTimes(2) 211 | expect(visitor.fetchFlags).toBeCalledTimes(5) 212 | 213 | await fs.close() 214 | expect(Flagship.close).toBeCalledTimes(1) 215 | 216 | fs.getFlags() 217 | expect(visitor.getFlags).toBeCalledTimes(1) 218 | 219 | fs.collectEAIEventsAsync() 220 | expect(visitor.collectEAIEventsAsync).toBeCalledTimes(1) 221 | }) 222 | 223 | it('test without visitor', () => { 224 | const config = { 225 | logManager: { 226 | error: jest.fn(), 227 | warning: jest.fn() 228 | }, 229 | logLevel: LogLevel.ALL 230 | } 231 | const expected = { key: 'key', campaignId: 'campaignId', variationGroupId: 'variationGroupId', variation: 'variation', isReference: true, value: 'value' } 232 | 233 | useContextMock.mockReturnValue({ 234 | state: { 235 | config, 236 | sdkState: { 237 | isSdkReady: false 238 | }, 239 | flags: new Map([ 240 | ['key', expected] 241 | ]) 242 | } 243 | }) 244 | 245 | const { result } = renderHook(() => FsHooks.useFlagship()) 246 | 247 | const fs = result.current 248 | 249 | const flagKey = 'key' 250 | const flagDefaultValue = 'value' 251 | // 252 | const flag = fs.getFlag(flagKey) 253 | 254 | expect(flag).toBeDefined() 255 | expect(flag.getValue(flagDefaultValue)).toEqual(flagDefaultValue) 256 | 257 | fs.fetchFlags() 258 | expect(config.logManager.warning).toBeCalledTimes(1) 259 | 260 | fs.setConsent(true) 261 | expect(config.logManager.warning).toBeCalledTimes(2) 262 | 263 | const flags = fs.getFlags() 264 | expect(flags).toBeInstanceOf(FSFlagCollection) 265 | 266 | fs.collectEAIEventsAsync() 267 | }) 268 | }) 269 | -------------------------------------------------------------------------------- /test/campaigns.ts: -------------------------------------------------------------------------------- 1 | export const campaigns = [ 2 | { 3 | id: 'c1ndsu87m030114t8uu0', 4 | variationGroupId: 'c1ndta129mp0114nbtn0', 5 | variation: { 6 | id: 'c1ndta129mp0114nbtng', 7 | modifications: { 8 | type: 'FLAG', 9 | value: { 10 | background: 'rouge bordeau', 11 | btnColor: 'blue', 12 | keyBoolean: false, 13 | keyNumber: 558 14 | } 15 | }, 16 | reference: false 17 | } 18 | }, 19 | { 20 | id: 'c2nreb1jg50l9nkl3hn0', 21 | variationGroupId: 'c2nreb1jg50l9nkl3ho0', 22 | variation: { 23 | id: 'c2nreb1jg50l9nkl3hog', 24 | modifications: { 25 | type: 'FLAG', 26 | value: { 27 | 'Feature 1': '1.0.0' 28 | } 29 | }, 30 | reference: false 31 | } 32 | }, 33 | { 34 | id: 'c2nrh1hjg50l9thhu8bg', 35 | variationGroupId: 'c2nrh1hjg50l9thhu8cg', 36 | variation: { 37 | id: 'c2nrh1hjg50l9thhu8dg', 38 | modifications: { 39 | type: 'JSON', 40 | value: { 41 | key: 'value' 42 | } 43 | }, 44 | reference: false 45 | } 46 | }, 47 | { 48 | id: 'c3ev1afkprbg5u3burag', 49 | variationGroupId: 'c3ev1afkprbg5u3burbg', 50 | variation: { 51 | id: 'c3ev1afkprbg5u3burc0', 52 | modifications: { 53 | type: 'JSON', 54 | value: {} 55 | }, 56 | reference: true 57 | } 58 | } 59 | ] 60 | -------------------------------------------------------------------------------- /test/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import { jest, expect, it, describe } from '@jest/globals' 2 | 3 | import { DecisionApiConfig, LogLevel, IFlagshipLogManager, SerializedFlagMetadata, FlagDTO, CampaignDTO } from '@flagship.io/js-sdk' 4 | 5 | import { 6 | extractFlagsMap, 7 | getFlagsFromCampaigns, 8 | hasSameType, 9 | hexToValue, 10 | logError, 11 | logInfo, 12 | logWarn, 13 | sprintf, 14 | uuidV4 15 | } from '../src/utils' 16 | import { campaigns } from './campaigns' 17 | 18 | describe('test logError function', () => { 19 | const config = new DecisionApiConfig() 20 | 21 | const logManager = {} as IFlagshipLogManager 22 | 23 | const onLog = jest.fn<(level: LogLevel, tag: string, message: string) => void>() 24 | 25 | const errorMethod = jest.fn<()=>void>() 26 | 27 | config.onLog = onLog 28 | 29 | logManager.error = errorMethod 30 | 31 | config.logManager = logManager 32 | 33 | const messageAll = 'this is a log message' 34 | const tag = 'tag' 35 | 36 | it('test logError level ALL', () => { 37 | logError(config, messageAll, tag) 38 | expect(errorMethod).toBeCalledTimes(1) 39 | expect(errorMethod).toBeCalledWith(messageAll, tag) 40 | expect(onLog).toBeCalledTimes(1) 41 | expect(onLog).toBeCalledWith(LogLevel.ERROR, tag, messageAll) 42 | }) 43 | 44 | it('test level EMERGENCY', () => { 45 | config.logLevel = LogLevel.EMERGENCY 46 | const messageEmergency = 'emergency' 47 | logError(config, messageEmergency, tag) 48 | expect(errorMethod).toBeCalledTimes(0) 49 | expect(onLog).toBeCalledTimes(0) 50 | }) 51 | 52 | it('test level NONE', () => { 53 | config.logLevel = LogLevel.NONE 54 | const messageNone = 'none' 55 | logError(config, messageNone, tag) 56 | expect(errorMethod).toBeCalledTimes(0) 57 | expect(onLog).toBeCalledTimes(0) 58 | }) 59 | 60 | it('test level INFO', () => { 61 | config.logLevel = LogLevel.INFO 62 | const messageInfo = 'this a message with info level' 63 | logError(config, messageInfo, tag) 64 | expect(errorMethod).toBeCalledTimes(1) 65 | expect(errorMethod).toBeCalledWith(messageInfo, tag) 66 | expect(onLog).toBeCalledTimes(1) 67 | expect(onLog).toBeCalledWith(LogLevel.ERROR, tag, messageInfo) 68 | }) 69 | 70 | it('test invalid config', () => { 71 | logError({} as DecisionApiConfig, messageAll, tag) 72 | expect(errorMethod).toBeCalledTimes(0) 73 | }) 74 | }) 75 | 76 | describe('test logInfo function', () => { 77 | const config = new DecisionApiConfig() 78 | 79 | const logManager = {} as IFlagshipLogManager 80 | 81 | const infoMethod = jest.fn<()=>void>() 82 | 83 | logManager.info = infoMethod 84 | 85 | const onLog = jest.fn<(level: LogLevel, tag: string, message: string) => void>() 86 | 87 | config.onLog = onLog 88 | 89 | config.logManager = logManager 90 | 91 | const messageAll = 'this is a log message' 92 | const tag = 'tag' 93 | 94 | it('test logError level ALL', () => { 95 | logInfo(config, messageAll, tag) 96 | expect(infoMethod).toBeCalledTimes(1) 97 | expect(infoMethod).toBeCalledWith(messageAll, tag) 98 | expect(onLog).toBeCalledTimes(1) 99 | expect(onLog).toBeCalledWith(LogLevel.INFO, tag, messageAll) 100 | }) 101 | 102 | it('test level EMERGENCY', () => { 103 | config.logLevel = LogLevel.EMERGENCY 104 | const messageEmergency = 'emergency' 105 | logInfo(config, messageEmergency, tag) 106 | expect(infoMethod).toBeCalledTimes(0) 107 | expect(onLog).toBeCalledTimes(0) 108 | }) 109 | 110 | it('test level NONE', () => { 111 | config.logLevel = LogLevel.NONE 112 | const messageNone = 'none' 113 | logInfo(config, messageNone, tag) 114 | expect(infoMethod).toBeCalledTimes(0) 115 | expect(onLog).toBeCalledTimes(0) 116 | }) 117 | 118 | it('test level INFO', () => { 119 | config.logLevel = LogLevel.INFO 120 | const messageInfo = 'this a message with info level' 121 | logInfo(config, messageInfo, tag) 122 | expect(infoMethod).toBeCalledTimes(1) 123 | expect(infoMethod).toBeCalledWith(messageInfo, tag) 124 | expect(onLog).toBeCalledTimes(1) 125 | expect(onLog).toBeCalledWith(LogLevel.INFO, tag, messageInfo) 126 | }) 127 | 128 | it('test invalid config', () => { 129 | logInfo({} as DecisionApiConfig, messageAll, tag) 130 | expect(infoMethod).toBeCalledTimes(0) 131 | }) 132 | }) 133 | 134 | describe('test logWarn function', () => { 135 | const config = new DecisionApiConfig() 136 | 137 | const logManager = {} as IFlagshipLogManager 138 | 139 | const warnMethod = jest.fn<()=>void>() 140 | 141 | logManager.warning = warnMethod 142 | 143 | config.logManager = logManager 144 | 145 | const onLog = jest.fn<(level: LogLevel, tag: string, message: string) => void>() 146 | 147 | config.onLog = onLog 148 | 149 | const messageAll = 'this is a log message' 150 | const tag = 'tag' 151 | 152 | it('test logError level ALL', () => { 153 | logWarn(config, messageAll, tag) 154 | expect(warnMethod).toBeCalledTimes(1) 155 | expect(warnMethod).toBeCalledWith(messageAll, tag) 156 | expect(onLog).toBeCalledTimes(1) 157 | expect(onLog).toBeCalledWith(LogLevel.WARNING, tag, messageAll) 158 | }) 159 | 160 | it('test level EMERGENCY', () => { 161 | config.logLevel = LogLevel.EMERGENCY 162 | const messageEmergency = 'emergency' 163 | logWarn(config, messageEmergency, tag) 164 | expect(warnMethod).toBeCalledTimes(0) 165 | expect(onLog).toBeCalledTimes(0) 166 | }) 167 | 168 | it('test level NONE', () => { 169 | config.logLevel = LogLevel.NONE 170 | const messageNone = 'none' 171 | logWarn(config, messageNone, tag) 172 | expect(warnMethod).toBeCalledTimes(0) 173 | expect(onLog).toBeCalledTimes(0) 174 | }) 175 | 176 | it('test level WARNING', () => { 177 | config.logLevel = LogLevel.WARNING 178 | const messageInfo = 'this a message with info level' 179 | logWarn(config, messageInfo, tag) 180 | expect(warnMethod).toBeCalledTimes(1) 181 | expect(warnMethod).toBeCalledWith(messageInfo, tag) 182 | expect(onLog).toBeCalledTimes(1) 183 | expect(onLog).toBeCalledWith(LogLevel.WARNING, tag, messageInfo) 184 | }) 185 | 186 | it('test invalid config', () => { 187 | logWarn({} as DecisionApiConfig, messageAll, tag) 188 | expect(warnMethod).toBeCalledTimes(0) 189 | }) 190 | }) 191 | 192 | describe('test getModificationsFromCampaigns', () => { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | const getNull = (): any => null 195 | it('should ', () => { 196 | const modifications = getFlagsFromCampaigns(getNull()) 197 | expect(modifications).toBeInstanceOf(Map) 198 | expect(modifications.size).toBe(0) 199 | }) 200 | it('should ', () => { 201 | const modifications = getFlagsFromCampaigns(campaigns) 202 | expect(modifications).toBeInstanceOf(Map) 203 | expect(modifications.size).toBe(6) 204 | expect(modifications.get('btnColor')?.value).toEqual('blue') 205 | expect(modifications.get('keyNumber')?.value).toEqual(558) 206 | }) 207 | }) 208 | 209 | describe('test uuidV4', () => { 210 | it('should ', () => { 211 | const id = uuidV4() 212 | expect(id).toBeDefined() 213 | expect(id.length).toBe(36) 214 | }) 215 | }) 216 | 217 | describe('test sprintf function', () => { 218 | it('should ', () => { 219 | const textToTest = 'My name is {0} {1}' 220 | const output = sprintf(textToTest, 'merveille', 'kitoko') 221 | expect(output).toBe('My name is merveille kitoko') 222 | }) 223 | }) 224 | 225 | describe('test hasSameType function', () => { 226 | it('should ', () => { 227 | let output = hasSameType('value1', 'value2') 228 | expect(output).toBeTruthy() 229 | 230 | output = hasSameType(1, 'value2') 231 | expect(output).toBeFalsy() 232 | 233 | output = hasSameType([1, 2], [1, 5]) 234 | expect(output).toBeTruthy() 235 | 236 | output = hasSameType({}, { key: 'value' }) 237 | expect(output).toBeTruthy() 238 | 239 | output = hasSameType([1, 2], {}) 240 | expect(output).toBeFalsy() 241 | }) 242 | }) 243 | 244 | describe('Test hexToValue function', () => { 245 | const config = new DecisionApiConfig() 246 | 247 | const logManager = { 248 | error: jest.fn<() => void>() 249 | } as unknown as IFlagshipLogManager 250 | 251 | const errorMethod = jest.spyOn(logManager, 'error') 252 | 253 | config.logManager = logManager 254 | 255 | it('should return null for invalid hex string', () => { 256 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 257 | const result = hexToValue(true as any, config) 258 | expect(result).toBeNull() 259 | expect(errorMethod).toBeCalledTimes(1) 260 | }) 261 | 262 | it('should return null for hex string with invalid characters', () => { 263 | const result = hexToValue('zz', config) 264 | expect(result).toBeNull() 265 | expect(errorMethod).toBeCalledTimes(1) 266 | }) 267 | 268 | it('should return parsed value for valid hex string', () => { 269 | const hex = Buffer.from(JSON.stringify({ v: 'test' })).toString('hex') 270 | const result = hexToValue(hex, config) 271 | expect(result).toEqual({ v: 'test' }) 272 | }) 273 | 274 | it('should return null for hex string that does not represent valid JSON', () => { 275 | const hex = Buffer.from('invalid').toString('hex') 276 | const result = hexToValue(hex, config) 277 | expect(result).toBeNull() 278 | expect(errorMethod).toBeCalledTimes(1) 279 | }) 280 | }) 281 | 282 | describe('extractFlagsMap', () => { 283 | it('should correctly extract flags from initialFlagsData', () => { 284 | const initialFlagsData: SerializedFlagMetadata[] = [ 285 | { 286 | key: 'key1', 287 | campaignId: 'campaignId', 288 | campaignName: 'campaignName', 289 | campaignType: 'ab', 290 | variationGroupId: 'ab', 291 | variationGroupName: 'variationGroupName', 292 | variationId: 'varId', 293 | variationName: 'variationName', 294 | slug: 'slug', 295 | hex: '7b2276223a2274657374227d' 296 | }, 297 | { 298 | key: 'key2', 299 | campaignId: 'campaignId', 300 | campaignName: 'campaignName', 301 | campaignType: 'ab', 302 | variationGroupId: 'ab', 303 | variationGroupName: 'variationGroupName', 304 | variationId: 'varId', 305 | variationName: 'variationName', 306 | slug: 'slug', 307 | hex: '7b2276223a2274657374227d' 308 | } 309 | ] 310 | 311 | const result = extractFlagsMap(initialFlagsData) 312 | // Add your expected result here 313 | const expectedResult = new Map() 314 | expectedResult.set('key1', { 315 | key: 'key1', 316 | campaignId: 'campaignId', 317 | campaignName: 'campaignName', 318 | campaignType: 'ab', 319 | variationGroupId: 'ab', 320 | variationGroupName: 'variationGroupName', 321 | variationId: 'varId', 322 | variationName: 'variationName', 323 | slug: 'slug', 324 | value: 'test' 325 | }) 326 | expectedResult.set('key2', { 327 | key: 'key2', 328 | campaignId: 'campaignId', 329 | campaignName: 'campaignName', 330 | campaignType: 'ab', 331 | variationGroupId: 'ab', 332 | variationGroupName: 'variationGroupName', 333 | variationId: 'varId', 334 | variationName: 'variationName', 335 | slug: 'slug', 336 | value: 'test' 337 | }) 338 | 339 | expect(result).toEqual(expectedResult) 340 | }) 341 | 342 | it('should correctly extract flags from initialCampaigns when initialFlagsData is not an array', () => { 343 | const initialCampaigns: CampaignDTO[] = [ 344 | { 345 | id: 'campaignId', 346 | name: 'campaignName', 347 | variationGroupId: 'ab', 348 | variationGroupName: 'variationGroupName', 349 | slug: 'slug', 350 | variation: { 351 | id: 'varId', 352 | name: 'variationName', 353 | reference: false, 354 | modifications: { 355 | type: 'JSON', 356 | value: { key1: 'test' } 357 | } 358 | } 359 | }, 360 | { 361 | id: 'campaignId', 362 | name: 'campaignName', 363 | variationGroupId: 'ab', 364 | variationGroupName: 'variationGroupName', 365 | slug: 'slug', 366 | variation: { 367 | id: 'varId', 368 | name: 'variationName', 369 | reference: false, 370 | modifications: { 371 | type: 'JSON', 372 | value: { key2: 'test' } 373 | } 374 | } 375 | } 376 | ] 377 | 378 | const result = extractFlagsMap(undefined, initialCampaigns) 379 | // Add your expected result here 380 | const expectedResult = new Map() 381 | expectedResult.set('key1', { 382 | key: 'key1', 383 | campaignId: 'campaignId', 384 | campaignName: 'campaignName', 385 | variationGroupId: 'ab', 386 | variationGroupName: 'variationGroupName', 387 | variationId: 'varId', 388 | variationName: 'variationName', 389 | slug: 'slug', 390 | value: 'test', 391 | isReference: false 392 | }) 393 | expectedResult.set('key2', { 394 | key: 'key2', 395 | campaignId: 'campaignId', 396 | campaignName: 'campaignName', 397 | variationGroupId: 'ab', 398 | variationGroupName: 'variationGroupName', 399 | variationId: 'varId', 400 | variationName: 'variationName', 401 | slug: 'slug', 402 | value: 'test', 403 | isReference: false 404 | }) 405 | 406 | expect(result).toEqual(expectedResult) 407 | }) 408 | }) 409 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "noImplicitAny": true, 5 | "esModuleInterop": true, 6 | "strict": true, 7 | "module": "ESNext", 8 | "declaration": true, 9 | "lib": ["ESNext", "DOM"], 10 | "moduleResolution": "Node", 11 | "target": "ESNext", 12 | "jsx": "react-jsx", 13 | "types": ["node", "jest", "@testing-library/jest-dom"] 14 | }, 15 | "include": ["src/**/*", "setupTests.ts"], 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["test/**/*.ts", "src/**/*.ts"], 4 | "compilerOptions": { 5 | // You can override any compiler options specific for tests here 6 | "types": ["jest", "node"] 7 | } 8 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const nodeConfig = require('./webpack/webpack.node.js') 3 | 4 | module.exports = [nodeConfig] 5 | -------------------------------------------------------------------------------- /webpack/webpack.browser.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const TerserPlugin = require('terser-webpack-plugin') 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const webpack = require('webpack') 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const { merge } = require('webpack-merge') 7 | 8 | // eslint-disable-next-line @typescript-eslint/no-var-requires 9 | const nodeExternals = require('webpack-node-externals') 10 | 11 | const common = require('./webpack.common.js') 12 | // eslint-disable-next-line @typescript-eslint/no-var-requires 13 | // eslint-disable-next-line @typescript-eslint/no-var-requires 14 | 15 | module.exports = merge(common(), { 16 | target: 'web', 17 | resolve: {}, 18 | output: { 19 | filename: 'index.browser.js', 20 | library: { 21 | type: 'umd' 22 | } 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.(js|ts|tsx|jsx)$/, 28 | exclude: /node_modules/, 29 | use: [ 30 | { 31 | loader: 'babel-loader', 32 | options: { 33 | targets: '> 0.5%, last 2 versions, ie >= 10', 34 | presets: [ 35 | '@babel/preset-typescript', 36 | [ 37 | '@babel/preset-env', 38 | { 39 | useBuiltIns: 'usage', 40 | corejs: '3' 41 | } 42 | ], 43 | '@babel/preset-react' 44 | ], 45 | plugins: [ 46 | '@babel/proposal-class-properties', 47 | [ 48 | 'add-module-exports', 49 | { 50 | addDefaultProperty: true 51 | } 52 | ] 53 | ] 54 | } 55 | } 56 | ] 57 | } 58 | ] 59 | }, 60 | optimization: { 61 | minimize: process.env.NODE_ENV === 'production', 62 | minimizer: [ 63 | new TerserPlugin({ 64 | terserOptions: { 65 | keep_classnames: /AbortSignal/, 66 | keep_fnames: /AbortSignal/ 67 | } 68 | }) 69 | ] 70 | }, 71 | plugins: [ 72 | new webpack.ProvidePlugin({ 73 | AbortController: 'abort-controller' 74 | }) 75 | ], 76 | externals: [ 77 | nodeExternals({ 78 | allowlist: [/^core-js/, /^regenerator-runtime/, '@flagship.io/js-sdk', 'abort-controller', 'follow-redirects'] 79 | }) 80 | ] 81 | }) 82 | -------------------------------------------------------------------------------- /webpack/webpack.common.js: -------------------------------------------------------------------------------- 1 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const path = require('path') 5 | 6 | const isProduction = process.env.NODE_ENV === 'production' 7 | 8 | const config = { 9 | entry: './src/index.tsx', 10 | output: { 11 | path: path.resolve('./dist') 12 | }, 13 | devtool: 'source-map', 14 | resolve: { 15 | extensions: ['.ts', '.tsx', '.js'] 16 | }, 17 | optimization: { 18 | minimize: isProduction 19 | } 20 | } 21 | 22 | module.exports = () => { 23 | if (isProduction) { 24 | config.mode = 'production' 25 | } else { 26 | config.mode = 'development' 27 | } 28 | return config 29 | } 30 | -------------------------------------------------------------------------------- /webpack/webpack.node.js: -------------------------------------------------------------------------------- 1 | 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const { merge } = require('webpack-merge') 4 | // eslint-disable-next-line @typescript-eslint/no-var-requires 5 | const nodeExternals = require('webpack-node-externals') 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | const common = require('./webpack.common.js') 8 | 9 | module.exports = merge(common(), { 10 | target: 'node', 11 | output: { 12 | filename: 'index.node.js', 13 | library: { 14 | type: 'commonjs2' 15 | } 16 | }, 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.(js|ts|tsx|jsx)$/, 21 | exclude: /node_modules/, 22 | use: [ 23 | { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: [ 27 | '@babel/preset-typescript', 28 | [ 29 | '@babel/preset-env', 30 | { 31 | targets: { node: 12 }, 32 | modules: false, 33 | useBuiltIns: 'usage', 34 | corejs: 3 35 | } 36 | ], 37 | '@babel/preset-react' 38 | ], 39 | plugins: [ 40 | '@babel/proposal-class-properties', 41 | [ 42 | 'add-module-exports', 43 | { 44 | addDefaultProperty: true 45 | } 46 | ] 47 | ] 48 | } 49 | } 50 | ] 51 | } 52 | ] 53 | }, 54 | externals: [ 55 | nodeExternals({ 56 | allowlist: [ 57 | /core-js\/modules\/es/, 58 | /@babel\/runtime/ 59 | ] 60 | }) 61 | ] 62 | }) 63 | --------------------------------------------------------------------------------