This is page 6 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/browser-sdk/test/e2e/feedback-widget.browser.spec.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { randomUUID } from "crypto";
2 | import { expect, Locator, Page, test } from "@playwright/test";
3 |
4 | import { InitOptions } from "../../src/client";
5 | import { DEFAULT_TRANSLATIONS } from "../../src/feedback/ui/config/defaultTranslations";
6 | import { FeedbackTranslations } from "../../src/feedback/ui/types";
7 | import { feedbackContainerId, propagatedEvents } from "../../src/ui/constants";
8 |
9 | const KEY = randomUUID();
10 | const API_HOST = `https://front.reflag.com`;
11 |
12 | const WINDOW_WIDTH = 1280;
13 | const WINDOW_HEIGHT = 720;
14 |
15 | declare global {
16 | interface Window {
17 | eventsFired: Record<string, boolean>;
18 | }
19 | }
20 |
21 | function pick<T>(options: T[]): T {
22 | return options[Math.floor(Math.random() * options.length)];
23 | }
24 |
25 | async function getOpenedWidgetContainer(
26 | page: Page,
27 | initOptions: Omit<InitOptions, "publishableKey"> = {},
28 | ) {
29 | await page.goto("http://localhost:8001/test/e2e/empty.html");
30 |
31 | // Mock API calls
32 | await page.route(`${API_HOST}/user`, async (route) => {
33 | await route.fulfill({ status: 200 });
34 | });
35 |
36 | await page.route(`${API_HOST}/features/evaluated*`, async (route) => {
37 | await route.fulfill({
38 | status: 200,
39 | body: JSON.stringify({
40 | success: true,
41 | features: {},
42 | }),
43 | });
44 | });
45 |
46 | // Golden path requests
47 | await page.evaluate(`
48 | ;(async () => {
49 | const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
50 | const reflag = new ReflagClient({publishableKey: "${KEY}", user: {id: "foo"}, company: {id: "bar"}, ...${JSON.stringify(initOptions ?? {})}});
51 | await reflag.initialize();
52 | await reflag.requestFeedback({
53 | flagKey: "flag1",
54 | title: "baz",
55 | });
56 | })()
57 | `);
58 |
59 | return page.locator(`#${feedbackContainerId}`);
60 | }
61 |
62 | async function getGiveFeedbackPageContainer(
63 | page: Page,
64 | initOptions: Omit<InitOptions, "publishableKey"> = {},
65 | ) {
66 | await page.goto("http://localhost:8001/test/e2e/give-feedback-button.html");
67 |
68 | // Mock API calls
69 | await page.route(`${API_HOST}/user`, async (route) => {
70 | await route.fulfill({ status: 200 });
71 | });
72 |
73 | await page.route(`${API_HOST}/features/evaluated*`, async (route) => {
74 | await route.fulfill({
75 | status: 200,
76 | body: JSON.stringify({
77 | success: true,
78 | features: {},
79 | }),
80 | });
81 | });
82 |
83 | // Golden path requests
84 | await page.evaluate(`
85 | ;(async () => {
86 | const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
87 | const reflag = new ReflagClient({publishableKey: "${KEY}", user: {id: "foo"}, company: {id: "bar"}, ...${JSON.stringify(initOptions ?? {})}});
88 | await reflag.initialize();
89 | console.log("setup clicky", document.querySelector("#give-feedback-button"))
90 | document.querySelector("#give-feedback-button")?.addEventListener("click", () => {
91 | console.log("cliked!");
92 | reflag.requestFeedback({
93 | flagKey: "flag1",
94 | title: "baz",
95 | });
96 | });
97 | })()
98 | `);
99 |
100 | return page.locator(`#${feedbackContainerId}`);
101 | }
102 |
103 | async function setScore(container: Locator, score: number) {
104 | await new Promise((resolve) => setTimeout(resolve, 50)); // allow react to update its state
105 | await container
106 | .locator(`#reflag-feedback-score-${score}`)
107 | .dispatchEvent("click");
108 | }
109 |
110 | async function setComment(container: Locator, comment: string) {
111 | await container.locator("#reflag-feedback-comment-label").fill(comment);
112 | }
113 |
114 | async function submitForm(container: Locator) {
115 | await container.locator(".form-expanded-content").getByRole("button").click();
116 | }
117 |
118 | test.beforeEach(async ({ page, browserName }) => {
119 | // Log any calls to front.reflag.com which aren't mocked by subsequent
120 | // `page.route` calls. With page.route, the last matching mock takes
121 | // precedence, so this logs any which may have been missed, and responds
122 | // with a 200 to prevent an internet request.
123 | await page.route(/^https:\/\/front\.reflag\.com.*/, async (route) => {
124 | const meta = `${route.request().method()} ${route.request().url()}`;
125 |
126 | console.debug(`\n Unmocked request: [${browserName}] > ${meta}`);
127 | console.debug(`Sent stub mock response: [${browserName}] < ${meta} 200\n`);
128 |
129 | await route.fulfill({ status: 200, body: "{}" });
130 | });
131 |
132 | // Mock prompting-init as if prompting is `disabled` for all tests.
133 | await page.route(`${API_HOST}/feedback/prompting-init`, async (route) => {
134 | await route.fulfill({
135 | status: 200,
136 | body: JSON.stringify({ success: false }),
137 | });
138 | });
139 | });
140 |
141 | test("Opens a feedback widget", async ({ page }) => {
142 | const container = await getOpenedWidgetContainer(page);
143 |
144 | await expect(container).toBeAttached();
145 | await expect(container.locator("dialog")).toHaveAttribute("open", "");
146 | });
147 |
148 | test("Opens a feedback widget multiple times in same session", async ({
149 | page,
150 | }) => {
151 | const container = await getGiveFeedbackPageContainer(page);
152 |
153 | await page.getByTestId("give-feedback-button").click();
154 | await expect(container).toBeAttached();
155 | await expect(container.locator("dialog")).toHaveAttribute("open", "");
156 |
157 | await container.locator("dialog .close").click();
158 | await expect(container.locator("dialog")).not.toHaveAttribute("open", "");
159 |
160 | await page.getByTestId("give-feedback-button").click();
161 | await expect(container).toBeAttached();
162 | await expect(container.locator("dialog")).toHaveAttribute("open", "");
163 | });
164 |
165 | test("Opens a feedback widget in the bottom right by default", async ({
166 | page,
167 | }) => {
168 | const container = await getOpenedWidgetContainer(page);
169 |
170 | await expect(container).toBeAttached();
171 |
172 | const bbox = await container.locator("dialog").boundingBox();
173 | expect(bbox?.x).toEqual(WINDOW_WIDTH - bbox!.width - 16);
174 | expect(bbox?.y).toBeGreaterThan(WINDOW_HEIGHT - bbox!.height - 30); // Account for browser differences
175 | expect(bbox?.y).toBeLessThan(WINDOW_HEIGHT - bbox!.height);
176 | });
177 |
178 | test("Opens a feedback widget in the correct position when overridden", async ({
179 | page,
180 | }) => {
181 | const container = await getOpenedWidgetContainer(page, {
182 | feedback: {
183 | ui: {
184 | position: {
185 | type: "DIALOG",
186 | placement: "top-left",
187 | },
188 | },
189 | },
190 | });
191 |
192 | await expect(container).toBeAttached();
193 |
194 | const bbox = await container.locator("dialog").boundingBox();
195 | expect(bbox?.x).toEqual(16);
196 | expect(bbox?.y).toBeGreaterThan(0); // Account for browser differences
197 | expect(bbox?.y).toBeLessThanOrEqual(16);
198 | });
199 |
200 | test("Opens a feedback widget with the correct translations", async ({
201 | page,
202 | }) => {
203 | const translations: Partial<FeedbackTranslations> = {
204 | ScoreStatusDescription: "Choisissez une note et laissez un commentaire",
205 | ScoreVeryDissatisfiedLabel: "Très insatisfait",
206 | ScoreDissatisfiedLabel: "Insatisfait",
207 | ScoreNeutralLabel: "Neutre",
208 | ScoreSatisfiedLabel: "Satisfait",
209 | ScoreVerySatisfiedLabel: "Très satisfait",
210 | SendButton: "Envoyer",
211 | };
212 |
213 | const container = await getOpenedWidgetContainer(page, {
214 | feedback: {
215 | ui: {
216 | translations,
217 | },
218 | },
219 | });
220 |
221 | await expect(container).toBeAttached();
222 | await expect(container).toContainText(translations.ScoreStatusDescription!);
223 | await expect(container).toContainText(
224 | translations.ScoreVeryDissatisfiedLabel!,
225 | );
226 | await expect(container).toContainText(translations.ScoreDissatisfiedLabel!);
227 | await expect(container).toContainText(translations.ScoreNeutralLabel!);
228 | await expect(container).toContainText(translations.ScoreSatisfiedLabel!);
229 | await expect(container).toContainText(translations.ScoreVerySatisfiedLabel!);
230 | await expect(container).toContainText(translations.SendButton!);
231 | });
232 |
233 | test("Sends a request when choosing a score immediately", async ({ page }) => {
234 | const expectedScore = pick([1, 2, 3, 4, 5]);
235 | let sentJSON: object | null = null;
236 |
237 | await page.route(`${API_HOST}/feedback`, async (route) => {
238 | sentJSON = route.request().postDataJSON();
239 | await route.fulfill({
240 | status: 200,
241 | body: JSON.stringify({ feedbackId: "123" }),
242 | contentType: "application/json",
243 | });
244 | });
245 |
246 | const container = await getOpenedWidgetContainer(page);
247 | await setScore(container, expectedScore);
248 |
249 | await expect
250 | .poll(() => sentJSON)
251 | .toEqual({
252 | companyId: "bar",
253 | key: "flag1",
254 | score: expectedScore,
255 | question: "baz",
256 | userId: "foo",
257 | source: "widget",
258 | });
259 | });
260 |
261 | test("Shows a success message after submitting a score", async ({ page }) => {
262 | await page.route(`${API_HOST}/feedback`, async (route) => {
263 | await route.fulfill({
264 | status: 200,
265 | body: JSON.stringify({ feedbackId: "123" }),
266 | contentType: "application/json",
267 | });
268 | });
269 |
270 | const container = await getOpenedWidgetContainer(page);
271 |
272 | await expect(
273 | container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusDescription),
274 | ).toHaveCSS("opacity", "1");
275 | await expect(
276 | container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusReceived),
277 | ).toHaveCSS("opacity", "0");
278 |
279 | await setScore(container, 3);
280 |
281 | await expect(
282 | container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusDescription),
283 | ).toHaveCSS("opacity", "0");
284 | await expect(
285 | container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusReceived),
286 | ).toHaveCSS("opacity", "1");
287 | });
288 |
289 | test("Updates the score on every change", async ({ page }) => {
290 | let lastSentJSON: object | null = null;
291 |
292 | await page.route(`${API_HOST}/feedback`, async (route) => {
293 | lastSentJSON = route.request().postDataJSON();
294 | await route.fulfill({
295 | status: 200,
296 | body: JSON.stringify({ feedbackId: "123" }),
297 | contentType: "application/json",
298 | });
299 | });
300 |
301 | const container = await getOpenedWidgetContainer(page);
302 |
303 | await setScore(container, 1);
304 | await setScore(container, 5);
305 | await setScore(container, 3);
306 |
307 | await expect
308 | .poll(() => lastSentJSON)
309 | .toEqual({
310 | feedbackId: "123",
311 | companyId: "bar",
312 | key: "flag1",
313 | question: "baz",
314 | score: 3,
315 | userId: "foo",
316 | source: "widget",
317 | });
318 | });
319 |
320 | test("Shows the comment field after submitting a score", async ({ page }) => {
321 | await page.route(`${API_HOST}/feedback`, async (route) => {
322 | await route.fulfill({
323 | status: 200,
324 | body: JSON.stringify({ feedbackId: "123" }),
325 | contentType: "application/json",
326 | });
327 | });
328 |
329 | const container = await getOpenedWidgetContainer(page);
330 |
331 | await expect(container.locator(".form-expanded-content")).toHaveCSS(
332 | "opacity",
333 | "0",
334 | );
335 |
336 | await setScore(container, 1);
337 |
338 | await expect(container.locator(".form-expanded-content")).toHaveCSS(
339 | "opacity",
340 | "1",
341 | );
342 | });
343 |
344 | test("Sends a request with both the score and comment when submitting", async ({
345 | page,
346 | }) => {
347 | const expectedComment = `This is my comment: ${Math.random()}`;
348 | const expectedScore = pick([1, 2, 3, 4, 5]);
349 |
350 | let sentJSON: object | null = null;
351 |
352 | await page.route(`${API_HOST}/feedback`, async (route) => {
353 | sentJSON = route.request().postDataJSON();
354 | await route.fulfill({
355 | status: 200,
356 | body: JSON.stringify({ feedbackId: "123" }),
357 | contentType: "application/json",
358 | });
359 | });
360 |
361 | const container = await getOpenedWidgetContainer(page);
362 |
363 | await setScore(container, expectedScore);
364 | await setComment(container, expectedComment);
365 | await submitForm(container);
366 |
367 | expect(sentJSON).toEqual({
368 | comment: expectedComment,
369 | score: expectedScore,
370 | companyId: "bar",
371 | question: "baz",
372 | key: "flag1",
373 | feedbackId: "123",
374 | userId: "foo",
375 | source: "widget",
376 | });
377 | });
378 |
379 | test("Shows a success message after submitting", async ({ page }) => {
380 | await page.route(`${API_HOST}/feedback`, async (route) => {
381 | await route.fulfill({
382 | status: 200,
383 | body: JSON.stringify({ feedbackId: "123" }),
384 | contentType: "application/json",
385 | });
386 | });
387 |
388 | const container = await getOpenedWidgetContainer(page);
389 |
390 | await setScore(container, 3);
391 | await setComment(container, "Test comment!");
392 | await submitForm(container);
393 |
394 | await expect(
395 | container.getByText(DEFAULT_TRANSLATIONS.SuccessMessage),
396 | ).toBeVisible();
397 | });
398 |
399 | test("Closes the dialog shortly after submitting", async ({ page }) => {
400 | await page.route(`${API_HOST}/feedback`, async (route) => {
401 | await route.fulfill({
402 | status: 200,
403 | body: JSON.stringify({ feedbackId: "123" }),
404 | contentType: "application/json",
405 | });
406 | });
407 |
408 | const container = await getOpenedWidgetContainer(page);
409 |
410 | await setScore(container, 3);
411 | await setComment(container, "Test comment!");
412 | await submitForm(container);
413 |
414 | await expect(container.locator("dialog")).not.toHaveAttribute("open", "");
415 | });
416 |
417 | test("Blocks event propagation to the containing document", async ({
418 | page,
419 | }) => {
420 | const container = await getOpenedWidgetContainer(page);
421 | const textarea = container.locator('textarea[name="comment"]');
422 |
423 | await page.evaluate(
424 | ({ trackedEvents }) => {
425 | window.eventsFired = {};
426 |
427 | for (const event of trackedEvents) {
428 | document.addEventListener(event, () => {
429 | window.eventsFired[event] = true;
430 | });
431 | }
432 | },
433 | { trackedEvents: propagatedEvents },
434 | );
435 |
436 | await textarea.focus();
437 | // Fires 'keydown', 'keyup' and 'keypress' events
438 | await page.keyboard.type("Hello World");
439 |
440 | const firedEvents = await page.evaluate(() => {
441 | return window.eventsFired;
442 | });
443 |
444 | // No events are allowed to fire, object should be empty
445 | expect(firedEvents).toEqual({});
446 | });
447 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/sse.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { http, HttpResponse } from "msw";
2 | import { cleanAll, isDone } from "nock";
3 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4 |
5 | import {
6 | forgetAuthToken,
7 | getAuthToken,
8 | rememberAuthToken,
9 | } from "../src/feedback/promptStorage";
10 | import { HttpClient } from "../src/httpClient";
11 | import { AblySSEChannel } from "../src/sse";
12 |
13 | import { server } from "./mocks/server";
14 | import { testLogger } from "./testLogger";
15 |
16 | const KEY = "123";
17 | const sseHost = "https://ssehost.com/path";
18 | const tokenRequest = {
19 | keyName: "key-name",
20 | other: "other",
21 | };
22 | const tokenDetails = {
23 | token: "token",
24 | expires: new Date("2023-01-01T00:00:00.000Z").getTime(),
25 | };
26 |
27 | const userId = "foo";
28 | const channel = "channel";
29 |
30 | function createSSEChannel(callback: (message: any) => void = vi.fn()) {
31 | const httpClient = new HttpClient(KEY);
32 | return new AblySSEChannel(
33 | userId,
34 | channel,
35 | sseHost,
36 | callback,
37 | httpClient,
38 | testLogger,
39 | );
40 | }
41 |
42 | Object.defineProperty(window, "EventSource", {
43 | value: vi.fn().mockImplementation(() => {
44 | // ignore
45 | }),
46 | });
47 |
48 | vi.mock("../src/feedback/promptStorage", () => {
49 | return {
50 | rememberAuthToken: vi.fn(),
51 | forgetAuthToken: vi.fn(),
52 | getAuthToken: vi.fn(),
53 | };
54 | });
55 |
56 | function setupAuthNock(success: boolean | number) {
57 | server.use(
58 | http.get("https://front.reflag.com/feedback/prompting-auth", async () => {
59 | if (success === true) {
60 | return HttpResponse.json({ success: true, ...tokenRequest });
61 | } else if (success === false) {
62 | return HttpResponse.json({ success: false });
63 | } else {
64 | return new HttpResponse(null, {
65 | status: success,
66 | });
67 | }
68 | }),
69 | );
70 | }
71 |
72 | function setupTokenNock(success: boolean) {
73 | server.use(
74 | http.post(
75 | `${sseHost}/keys/${tokenRequest.keyName}/requestToken`,
76 | async () => {
77 | if (success) {
78 | return HttpResponse.json(tokenDetails);
79 | } else {
80 | return new HttpResponse(null, {
81 | status: 401,
82 | });
83 | }
84 | },
85 | ),
86 | );
87 | }
88 |
89 | describe("connection handling", () => {
90 | afterEach(() => {
91 | vi.clearAllMocks();
92 | vi.mocked(getAuthToken).mockReturnValue(undefined);
93 | });
94 |
95 | test("appends /sse to the sseHost", async () => {
96 | const sse = createSSEChannel();
97 | const addEventListener = vi.fn();
98 |
99 | vi.mocked(window.EventSource).mockReturnValue({
100 | addEventListener,
101 | } as any);
102 |
103 | setupAuthNock(true);
104 | setupTokenNock(true);
105 |
106 | await sse.connect();
107 |
108 | const lastCall = vi.mocked(window.EventSource).mock.calls[0][0];
109 |
110 | expect(lastCall.toString()).toMatch(`${sseHost}/sse`);
111 | });
112 |
113 | test("rejects if auth endpoint is not success", async () => {
114 | const sse = createSSEChannel();
115 |
116 | setupAuthNock(false);
117 | const res = await sse.connect();
118 | expect(res).toBeUndefined();
119 |
120 | expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
121 | });
122 |
123 | test("rejects if auth endpoint is not 200", async () => {
124 | const sse = createSSEChannel();
125 |
126 | setupAuthNock(403);
127 |
128 | const res = await sse.connect();
129 | expect(res).toBeUndefined();
130 |
131 | expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
132 | });
133 |
134 | test("rejects if token endpoint rejects", async () => {
135 | const sse = createSSEChannel();
136 |
137 | setupAuthNock(true);
138 | setupTokenNock(false);
139 |
140 | const res = await sse.connect();
141 | expect(res).toBeUndefined();
142 |
143 | expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
144 | });
145 |
146 | test("obtains token, connects and subscribes, then closes", async () => {
147 | const addEventListener = vi.fn();
148 | const close = vi.fn();
149 |
150 | vi.mocked(window.EventSource).mockReturnValue({
151 | addEventListener,
152 | close,
153 | } as any);
154 |
155 | const sse = createSSEChannel();
156 |
157 | setupAuthNock(true);
158 | setupTokenNock(true);
159 |
160 | await sse.connect();
161 |
162 | expect(getAuthToken).toHaveBeenCalledWith(userId);
163 | expect(rememberAuthToken).toHaveBeenCalledWith(
164 | userId,
165 | channel,
166 | "token",
167 | new Date("2023-01-01T00:00:00.000Z"),
168 | );
169 | expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
170 | expect(addEventListener).toHaveBeenCalledTimes(3);
171 | expect(addEventListener).toHaveBeenCalledWith(
172 | "error",
173 | expect.any(Function),
174 | );
175 | expect(addEventListener).toHaveBeenCalledWith(
176 | "message",
177 | expect.any(Function),
178 | );
179 | expect(addEventListener).toHaveBeenCalledWith("open", expect.any(Function));
180 |
181 | expect(sse.isConnected()).toBe(true);
182 |
183 | sse.disconnect();
184 |
185 | expect(close).toHaveBeenCalledTimes(1);
186 | expect(sse.isConnected()).toBe(false);
187 | });
188 |
189 | test("reuses cached token", async () => {
190 | const sse = createSSEChannel();
191 | vi.mocked(getAuthToken).mockReturnValue({
192 | channel: channel,
193 | token: "cached_token",
194 | });
195 |
196 | const addEventListener = vi.fn();
197 | const close = vi.fn();
198 |
199 | vi.mocked(window.EventSource).mockReturnValue({
200 | addEventListener,
201 | close,
202 | } as any);
203 |
204 | await sse.connect();
205 |
206 | expect(getAuthToken).toHaveBeenCalledWith(userId);
207 | expect(rememberAuthToken).not.toHaveBeenCalled();
208 |
209 | expect(sse.isConnected()).toBe(true);
210 | });
211 |
212 | test("does not reuse cached token with wrong channel", async () => {
213 | const sse = createSSEChannel();
214 |
215 | vi.mocked(getAuthToken).mockReturnValue({
216 | channel: "haha",
217 | token: "cached_token",
218 | });
219 |
220 | const addEventListener = vi.fn();
221 | const close = vi.fn();
222 |
223 | vi.mocked(window.EventSource).mockReturnValue({
224 | addEventListener,
225 | close,
226 | } as any);
227 |
228 | setupAuthNock(true);
229 | setupTokenNock(true);
230 |
231 | await sse.connect();
232 |
233 | expect(rememberAuthToken).toHaveBeenCalledWith(
234 | userId,
235 | channel,
236 | "token",
237 | new Date("2023-01-01T00:00:00.000Z"),
238 | );
239 | });
240 |
241 | test("does not try to re-connect if already connecting", async () => {
242 | const sse = createSSEChannel();
243 |
244 | const close = vi.fn();
245 | vi.mocked(window.EventSource).mockReturnValue({
246 | addEventListener: vi.fn(),
247 | close,
248 | } as any);
249 |
250 | setupAuthNock(true);
251 | setupTokenNock(true);
252 |
253 | const c1 = sse.connect();
254 | const c2 = sse.connect();
255 |
256 | await c1;
257 | await c2;
258 |
259 | expect(close).toHaveBeenCalledTimes(0);
260 | expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
261 | });
262 |
263 | test("does not re-connect if already connected", async () => {
264 | const sse = createSSEChannel();
265 |
266 | const close = vi.fn();
267 | vi.mocked(window.EventSource).mockReturnValue({
268 | addEventListener: vi.fn(),
269 | close,
270 | } as any);
271 |
272 | setupAuthNock(true);
273 | setupTokenNock(true);
274 |
275 | await sse.connect();
276 | await sse.connect();
277 |
278 | expect(close).toHaveBeenCalledTimes(0);
279 | expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
280 | });
281 |
282 | test("disconnects only if connected", async () => {
283 | const sse = createSSEChannel();
284 |
285 | const close = vi.fn();
286 | vi.mocked(window.EventSource).mockReturnValue({
287 | close,
288 | } as any);
289 |
290 | sse.disconnect();
291 |
292 | expect(close).not.toHaveBeenCalled();
293 | });
294 | });
295 |
296 | describe("message handling", () => {
297 | beforeEach(() => {
298 | setupAuthNock(true);
299 | setupTokenNock(true);
300 | });
301 |
302 | afterEach(() => {
303 | expect(isDone()).toBe(true);
304 |
305 | vi.clearAllMocks();
306 | cleanAll();
307 | });
308 |
309 | test("passes message to callback", async () => {
310 | const callback = vi.fn();
311 | const sse = createSSEChannel(callback);
312 |
313 | let messageCallback: ((e: Event) => void) | undefined = undefined;
314 | const addEventListener = (event: string, cb: (e: Event) => void) => {
315 | if (event === "message") {
316 | messageCallback = cb;
317 | }
318 | };
319 |
320 | vi.mocked(window.EventSource).mockReturnValue({
321 | addEventListener,
322 | } as any);
323 |
324 | await sse.connect();
325 |
326 | expect(messageCallback).toBeDefined();
327 |
328 | messageCallback!({
329 | data: JSON.stringify({ data: JSON.stringify(userId) }),
330 | } as any);
331 | expect(callback).toHaveBeenCalledWith(userId);
332 |
333 | messageCallback!({
334 | data: null,
335 | } as any);
336 |
337 | messageCallback!({
338 | data: JSON.stringify({}),
339 | } as any);
340 |
341 | expect(callback).toHaveBeenCalledTimes(1);
342 | });
343 |
344 | test("disconnects on unknown event source errors without data", async () => {
345 | const sse = createSSEChannel();
346 |
347 | let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
348 | const addEventListener = (event: string, cb: (e: Event) => void) => {
349 | if (event === "error") {
350 | errorCallback = cb as typeof errorCallback;
351 | }
352 | };
353 |
354 | const close = vi.fn();
355 | vi.mocked(window.EventSource).mockReturnValue({
356 | addEventListener,
357 | close,
358 | } as any);
359 |
360 | await sse.connect();
361 |
362 | expect(errorCallback).toBeDefined();
363 |
364 | await errorCallback!({} as any);
365 |
366 | expect(forgetAuthToken).not.toHaveBeenCalled();
367 | expect(close).toHaveBeenCalledTimes(1);
368 | });
369 |
370 | test("disconnects on unknown event source errors with data", async () => {
371 | const sse = createSSEChannel();
372 | let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
373 | const addEventListener = (event: string, cb: (e: Event) => void) => {
374 | if (event === "error") {
375 | errorCallback = cb as typeof errorCallback;
376 | }
377 | };
378 |
379 | const close = vi.fn();
380 | vi.mocked(window.EventSource).mockReturnValue({
381 | addEventListener,
382 | close,
383 | } as any);
384 |
385 | await sse.connect();
386 |
387 | expect(errorCallback).toBeDefined();
388 |
389 | await errorCallback!(
390 | new MessageEvent("error", {
391 | data: JSON.stringify({ code: 400 }),
392 | }),
393 | );
394 |
395 | expect(close).toHaveBeenCalledTimes(1);
396 | });
397 |
398 | test("disconnects when ably reports token errors", async () => {
399 | const sse = createSSEChannel();
400 |
401 | let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
402 | const addEventListener = (event: string, cb: (e: Event) => void) => {
403 | if (event === "error") {
404 | errorCallback = cb as typeof errorCallback;
405 | }
406 | };
407 |
408 | const close = vi.fn();
409 | vi.mocked(window.EventSource).mockReturnValue({
410 | addEventListener,
411 | close,
412 | } as any);
413 |
414 | await sse.connect();
415 |
416 | await errorCallback!(
417 | new MessageEvent("error", {
418 | data: JSON.stringify({ code: 40110 }),
419 | }),
420 | );
421 |
422 | expect(forgetAuthToken).toHaveBeenCalledTimes(1);
423 | expect(close).toHaveBeenCalled();
424 | });
425 | });
426 |
427 | describe("automatic retries", () => {
428 | // const nockWait = (n: nock.Scope) => {
429 | // return new Promise((resolve) => {
430 | // n.on("replied", () => {
431 | // resolve(undefined);
432 | // });
433 | // });
434 | // };
435 |
436 | beforeEach(() => {
437 | vi.clearAllMocks();
438 | cleanAll();
439 | });
440 |
441 | afterEach(() => {
442 | expect(isDone()).toBe(true);
443 | });
444 |
445 | test("opens and connects to a channel", async () => {
446 | const sse = createSSEChannel();
447 |
448 | setupAuthNock(true);
449 | setupTokenNock(true);
450 |
451 | sse.open();
452 |
453 | await vi.waitFor(() =>
454 | sse.isConnected() ? Promise.resolve() : Promise.reject(),
455 | );
456 |
457 | expect(sse.isConnected()).toBe(true);
458 | });
459 |
460 | test("opens and connects later to a failed channel", async () => {
461 | const sse = createSSEChannel();
462 |
463 | setupAuthNock(false);
464 |
465 | sse.open({ retryInterval: 10 });
466 |
467 | await vi.waitUntil(() => !sse.isConnected());
468 | setupAuthNock(true);
469 | setupTokenNock(true);
470 |
471 | await vi.waitUntil(() => sse.isConnected());
472 |
473 | expect(sse.isConnected()).toBe(true);
474 | expect(sse.isActive()).toBe(true);
475 | });
476 |
477 | test("resets retry count on successful connect", async () => {
478 | const sse = createSSEChannel();
479 |
480 | // mock event source
481 | let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
482 | const addEventListener = (event: string, cb: (e: Event) => void) => {
483 | if (event === "error") {
484 | errorCallback = cb as typeof errorCallback;
485 | }
486 | };
487 |
488 | const close = vi.fn();
489 | vi.mocked(window.EventSource).mockReturnValue({
490 | addEventListener,
491 | close,
492 | } as any);
493 |
494 | // make initial failed attempt
495 | setupAuthNock(false);
496 |
497 | sse.open({ retryInterval: 100, retryCount: 1 });
498 |
499 | const attempt = async () => {
500 | setupAuthNock(true);
501 | setupTokenNock(true);
502 |
503 | await vi.waitUntil(() => sse.isConnected());
504 |
505 | expect(sse.isConnected()).toBe(true);
506 |
507 | // simulate an error
508 | await errorCallback!({} as any);
509 |
510 | expect(sse.isConnected()).toBe(false);
511 | };
512 |
513 | await attempt();
514 | await attempt();
515 | await attempt();
516 | });
517 |
518 | test("reconnects if manually disconnected", async () => {
519 | const sse = createSSEChannel();
520 |
521 | vi.mocked(window.EventSource).mockReturnValue({
522 | addEventListener: vi.fn(),
523 | close: vi.fn(),
524 | } as any);
525 |
526 | setupAuthNock(true);
527 | setupTokenNock(true);
528 |
529 | vi.useFakeTimers();
530 | await sse.open({ retryInterval: 100 });
531 |
532 | sse.disconnect();
533 |
534 | setupAuthNock(true);
535 | setupTokenNock(true);
536 |
537 | vi.advanceTimersByTime(100);
538 |
539 | vi.useRealTimers();
540 |
541 | await vi.waitUntil(() => sse.isConnected());
542 |
543 | expect(sse.isConnected()).toBe(true);
544 | expect(sse.isActive()).toBe(true);
545 | });
546 |
547 | test("opens and does not connect later to a failed channel if no retries", async () => {
548 | const sse = createSSEChannel();
549 |
550 | setupAuthNock(false);
551 |
552 | vi.useFakeTimers();
553 | sse.open({
554 | retryCount: 0,
555 | retryInterval: 100,
556 | });
557 |
558 | vi.advanceTimersByTime(100);
559 | vi.useRealTimers();
560 |
561 | await vi.waitUntil(() => !sse.isActive());
562 |
563 | expect(sse.isActive()).toBe(false);
564 | });
565 |
566 | test("closes an open channel", async () => {
567 | const sse = createSSEChannel();
568 |
569 | setupAuthNock(true);
570 | setupTokenNock(true);
571 |
572 | const close = vi.fn();
573 | vi.mocked(window.EventSource).mockReturnValue({
574 | addEventListener: vi.fn(),
575 | close,
576 | } as any);
577 |
578 | sse.open();
579 |
580 | await vi.waitUntil(() => sse.isConnected());
581 |
582 | sse.close();
583 |
584 | expect(sse.isConnected()).toBe(false);
585 | expect(close).toHaveBeenCalledTimes(1);
586 | expect(sse.isActive()).toBe(false);
587 | });
588 | });
589 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/flag/flags.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { deepEqual } from "fast-equals";
2 |
3 | import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS, IS_SERVER } from "../config";
4 | import { ReflagContext } from "../context";
5 | import { HttpClient } from "../httpClient";
6 | import { Logger, loggerWithPrefix } from "../logger";
7 | import RateLimiter from "../rateLimiter";
8 |
9 | import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache";
10 |
11 | /**
12 | * A flag fetched from the server.
13 | */
14 | export type RawFlag = {
15 | /**
16 | * Flag key.
17 | */
18 | key: string;
19 |
20 | /**
21 | * Result of flag evaluation.
22 | * Note: does not take local overrides into account.
23 | */
24 | isEnabled: boolean;
25 |
26 | /**
27 | * If not null or undefined, the result is being overridden locally
28 | */
29 | isEnabledOverride?: boolean | null;
30 |
31 | /**
32 | * Version of targeting rules.
33 | */
34 | targetingVersion?: number;
35 |
36 | /**
37 | * Rule evaluation results.
38 | */
39 | ruleEvaluationResults?: boolean[];
40 |
41 | /**
42 | * Missing context fields.
43 | */
44 | missingContextFields?: string[];
45 |
46 | /**
47 | * Optional user-defined dynamic configuration.
48 | */
49 | config?: {
50 | /**
51 | * The key of the matched configuration value.
52 | */
53 | key: string;
54 |
55 | /**
56 | * The version of the matched configuration value.
57 | */
58 | version?: number;
59 |
60 | /**
61 | * The optional user-supplied payload data.
62 | */
63 | payload?: any;
64 |
65 | /**
66 | * The rule evaluation results.
67 | */
68 | ruleEvaluationResults?: boolean[];
69 |
70 | /**
71 | * The missing context fields.
72 | */
73 | missingContextFields?: string[];
74 | };
75 | };
76 |
77 | export type RawFlags = Record<string, RawFlag>;
78 |
79 | export type FallbackFlagOverride =
80 | | {
81 | key: string;
82 | payload: any;
83 | }
84 | | true;
85 |
86 | type FallbackFlags = Record<string, FallbackFlagOverride>;
87 |
88 | type Config = {
89 | timeoutMs: number;
90 | staleTimeMs: number;
91 | staleWhileRevalidate: boolean;
92 | expireTimeMs: number;
93 | offline: boolean;
94 | };
95 |
96 | export const DEFAULT_FLAGS_CONFIG: Config = {
97 | timeoutMs: 5000,
98 | staleTimeMs: 0,
99 | staleWhileRevalidate: false,
100 | expireTimeMs: FLAGS_EXPIRE_MS,
101 | offline: false,
102 | };
103 |
104 | export function validateFlagsResponse(response: any) {
105 | if (!isObject(response)) {
106 | return;
107 | }
108 |
109 | if (typeof response.success !== "boolean" || !isObject(response.features)) {
110 | return;
111 | }
112 |
113 | const flags = parseAPIFlagsResponse(response.features);
114 |
115 | if (!flags) {
116 | return;
117 | }
118 |
119 | return {
120 | success: response.success,
121 | flags,
122 | };
123 | }
124 |
125 | export function flattenJSON(obj: Record<string, any>): Record<string, any> {
126 | const result: Record<string, any> = {};
127 | for (const key in obj) {
128 | if (typeof obj[key] === "object") {
129 | const flat = flattenJSON(obj[key]);
130 | for (const flatKey in flat) {
131 | result[`${key}.${flatKey}`] = flat[flatKey];
132 | }
133 | } else if (typeof obj[key] !== "undefined") {
134 | result[key] = obj[key];
135 | }
136 | }
137 | return result;
138 | }
139 |
140 | /**
141 | * Event representing checking the flag evaluation result
142 | */
143 | export interface CheckEvent {
144 | /**
145 | * `check-is-enabled` means `isEnabled` was checked, `check-config` means `config` was checked.
146 | */
147 | action: "check-is-enabled" | "check-config";
148 |
149 | /**
150 | * Flag key.
151 | */
152 | key: string;
153 |
154 | /**
155 | * Result of flag or configuration evaluation.
156 | * If `action` is `check-is-enabled`, this is the result of the flag evaluation and `value` is a boolean.
157 | * If `action` is `check-config`, this is the result of the configuration evaluation.
158 | */
159 | value?: boolean | { key: string; payload: any };
160 |
161 | /**
162 | * Version of targeting rules.
163 | */
164 | version?: number;
165 |
166 | /**
167 | * Rule evaluation results.
168 | */
169 | ruleEvaluationResults?: boolean[];
170 |
171 | /**
172 | * Missing context fields.
173 | */
174 | missingContextFields?: string[];
175 | }
176 |
177 | const localStorageFetchedFlagsKey = `__reflag_fetched_flags`;
178 | const storageOverridesKey = `__reflag_overrides`;
179 |
180 | export type FlagOverrides = Record<string, boolean | undefined>;
181 |
182 | type FlagsClientOptions = Partial<Config> & {
183 | bootstrappedFlags?: RawFlags;
184 | fallbackFlags?: Record<string, FallbackFlagOverride> | string[];
185 | cache?: FlagCache;
186 | rateLimiter?: RateLimiter;
187 | };
188 |
189 | /**
190 | * @internal
191 | */
192 | export class FlagsClient {
193 | private initialized = false;
194 | private bootstrapped = false;
195 |
196 | private rateLimiter: RateLimiter;
197 | private readonly logger: Logger;
198 |
199 | private cache: FlagCache;
200 | private fetchedFlags: RawFlags = {};
201 | private flagOverrides: FlagOverrides = {};
202 | private flags: RawFlags = {};
203 | private fallbackFlags: FallbackFlags = {};
204 |
205 | private config: Config = DEFAULT_FLAGS_CONFIG;
206 |
207 | private eventTarget = new EventTarget();
208 | private abortController: AbortController = new AbortController();
209 |
210 | constructor(
211 | private httpClient: HttpClient,
212 | private context: ReflagContext,
213 | logger: Logger,
214 | {
215 | bootstrappedFlags,
216 | cache,
217 | rateLimiter,
218 | fallbackFlags,
219 | ...config
220 | }: FlagsClientOptions = {},
221 | ) {
222 | this.config = {
223 | ...this.config,
224 | ...config,
225 | };
226 |
227 | this.logger = loggerWithPrefix(logger, "[Flags]");
228 | this.rateLimiter =
229 | rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger);
230 | this.cache =
231 | cache ??
232 | this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs);
233 | this.fallbackFlags = this.setupFallbackFlags(fallbackFlags);
234 |
235 | if (bootstrappedFlags) {
236 | this.bootstrapped = true;
237 | this.setFetchedFlags(bootstrappedFlags, false);
238 | }
239 |
240 | this.flagOverrides = this.getOverridesCache();
241 | }
242 |
243 | async initialize() {
244 | if (this.initialized) {
245 | this.logger.warn("flags client already initialized");
246 | return;
247 | }
248 | this.initialized = true;
249 |
250 | if (!this.bootstrapped) {
251 | this.setFetchedFlags((await this.maybeFetchFlags()) || {});
252 | }
253 |
254 | // Apply overrides and trigger update if flags have changed
255 | this.updateFlags();
256 | }
257 |
258 | /**
259 | * Stop the client.
260 | */
261 | public stop() {
262 | this.abortController.abort();
263 | }
264 |
265 | getFlags(): RawFlags {
266 | return this.flags;
267 | }
268 |
269 | getFetchedFlags(): RawFlags {
270 | return this.fetchedFlags;
271 | }
272 |
273 | setFetchedFlags(fetchedFlags: RawFlags, triggerEvent = true) {
274 | // Create a new fetched flags object making sure to clone the flags
275 | this.fetchedFlags = { ...fetchedFlags };
276 | this.warnMissingFlagContextFields(fetchedFlags);
277 | this.updateFlags(triggerEvent);
278 | }
279 |
280 | async setContext(context: ReflagContext) {
281 | this.context = context;
282 | this.setFetchedFlags((await this.maybeFetchFlags()) || {});
283 | }
284 |
285 | updateFlags(triggerEvent = true) {
286 | const updatedFlags = this.mergeFlags(this.fetchedFlags, this.flagOverrides);
287 | // Nothing has changed, skipping update
288 | if (deepEqual(this.flags, updatedFlags)) return;
289 | this.flags = updatedFlags;
290 | if (triggerEvent) this.triggerFlagsUpdated();
291 | }
292 |
293 | setFlagOverride(key: string, isEnabled: boolean | null) {
294 | if (!(typeof isEnabled === "boolean" || isEnabled === null)) {
295 | throw new Error("setFlagOverride: isEnabled must be boolean or null");
296 | }
297 |
298 | if (isEnabled === null) {
299 | delete this.flagOverrides[key];
300 | } else {
301 | this.flagOverrides[key] = isEnabled;
302 | }
303 | this.setOverridesCache(this.flagOverrides);
304 |
305 | this.updateFlags();
306 | }
307 |
308 | getFlagOverride(key: string): boolean | null {
309 | return this.flagOverrides[key] ?? null;
310 | }
311 |
312 | /**
313 | * Register a callback to be called when the flags are updated.
314 | * Flags are not guaranteed to have actually changed when the callback is called.
315 | *
316 | * @param callback this will be called when the flags are updated.
317 | * @returns a function that can be called to remove the listener
318 | */
319 | onUpdated(callback: () => void) {
320 | this.eventTarget.addEventListener("flagsUpdated", callback, {
321 | signal: this.abortController.signal,
322 | });
323 | }
324 |
325 | /**
326 | * Send a flag "check" event.
327 | *
328 | *
329 | * @param checkEvent - The flag to send the event for.
330 | * @param cb - Callback to call after the event is sent. Might be skipped if the event was rate limited.
331 | */
332 | async sendCheckEvent(checkEvent: CheckEvent, cb: () => void) {
333 | if (this.config.offline) {
334 | return;
335 | }
336 |
337 | const rateLimitKey = `check-event:${this.fetchParams().toString()}:${checkEvent.key}:${checkEvent.version}:${checkEvent.value}`;
338 | await this.rateLimiter.rateLimited(rateLimitKey, async () => {
339 | const payload = {
340 | action: checkEvent.action,
341 | key: checkEvent.key,
342 | targetingVersion: checkEvent.version,
343 | evalContext: this.context,
344 | evalResult: checkEvent.value,
345 | evalRuleResults: checkEvent.ruleEvaluationResults,
346 | evalMissingFields: checkEvent.missingContextFields,
347 | };
348 |
349 | this.httpClient
350 | .post({
351 | path: "features/events",
352 | body: payload,
353 | })
354 | .catch((e: any) => {
355 | this.logger.warn(`failed to send flag check event`, e);
356 | });
357 |
358 | this.logger.debug(`sent flag event`, payload);
359 | cb();
360 | });
361 |
362 | return checkEvent.value;
363 | }
364 |
365 | async fetchFlags(): Promise<RawFlags | undefined> {
366 | const params = this.fetchParams();
367 | try {
368 | const res = await this.httpClient.get({
369 | path: "/features/evaluated",
370 | timeoutMs: this.config.timeoutMs,
371 | params,
372 | });
373 |
374 | if (!res.ok) {
375 | let errorBody = null;
376 | try {
377 | errorBody = await res.json();
378 | } catch {
379 | // ignore
380 | }
381 |
382 | throw new Error(
383 | "unexpected response code: " +
384 | res.status +
385 | " - " +
386 | JSON.stringify(errorBody),
387 | );
388 | }
389 |
390 | const typeRes = validateFlagsResponse(await res.json());
391 | if (!typeRes || !typeRes.success) {
392 | throw new Error("unable to validate response");
393 | }
394 |
395 | return typeRes.flags;
396 | } catch (e) {
397 | this.logger.error("error fetching flags: ", e);
398 | return;
399 | }
400 | }
401 |
402 | private setOverridesCache(overrides: FlagOverrides) {
403 | if (IS_SERVER) return;
404 | try {
405 | localStorage.setItem(storageOverridesKey, JSON.stringify(overrides));
406 | } catch (error) {
407 | this.logger.warn(
408 | "storing flag overrides in localStorage failed, overrides won't persist",
409 | error,
410 | );
411 | }
412 | }
413 |
414 | private getOverridesCache(): FlagOverrides {
415 | if (IS_SERVER) return {};
416 | try {
417 | const overridesStored = localStorage.getItem(storageOverridesKey);
418 | const overrides = JSON.parse(overridesStored || "{}");
419 | if (!isObject(overrides)) throw new Error("invalid overrides");
420 | return overrides;
421 | } catch (error) {
422 | this.logger.warn(
423 | "getting flag overrides from localStorage failed",
424 | error,
425 | );
426 | return {};
427 | }
428 | }
429 |
430 | private async maybeFetchFlags(): Promise<RawFlags | undefined> {
431 | if (this.config.offline) {
432 | return;
433 | }
434 |
435 | const cacheKey = this.fetchParams().toString();
436 | const cachedItem = this.cache.get(cacheKey);
437 |
438 | if (cachedItem) {
439 | if (!cachedItem.stale) return cachedItem.flags;
440 |
441 | // serve successful stale cache if `staleWhileRevalidate` is enabled
442 | if (this.config.staleWhileRevalidate) {
443 | // re-fetch in the background, but immediately return last successful value
444 | this.fetchFlags()
445 | .then((flags) => {
446 | if (!flags) return;
447 |
448 | this.cache.set(cacheKey, {
449 | flags,
450 | });
451 | this.setFetchedFlags(flags);
452 | })
453 | .catch(() => {
454 | // we don't care about the result, we just want to re-fetch
455 | });
456 | return cachedItem.flags;
457 | }
458 | }
459 |
460 | // if there's no cached item or there is a stale one but `staleWhileRevalidate` is disabled
461 | // try fetching a new one
462 | const fetchedFlags = await this.fetchFlags();
463 |
464 | if (fetchedFlags) {
465 | this.cache.set(cacheKey, {
466 | flags: fetchedFlags,
467 | });
468 | return fetchedFlags;
469 | }
470 |
471 | if (cachedItem) {
472 | // fetch failed, return stale cache
473 | return cachedItem.flags;
474 | }
475 |
476 | // fetch failed, nothing cached => return fallbacks
477 | return Object.entries(this.fallbackFlags).reduce((acc, [key, override]) => {
478 | acc[key] = {
479 | key,
480 | isEnabled: !!override,
481 | config:
482 | typeof override === "object" && "key" in override
483 | ? {
484 | key: override.key,
485 | payload: override.payload,
486 | }
487 | : undefined,
488 | };
489 | return acc;
490 | }, {} as RawFlags);
491 | }
492 |
493 | private mergeFlags(fetchedFlags: RawFlags, overrides: FlagOverrides) {
494 | const mergedFlags: RawFlags = {};
495 | // merge fetched flags with overrides into `this.flags`
496 | for (const key in fetchedFlags) {
497 | const fetchedFlag = fetchedFlags[key];
498 | if (!fetchedFlag) continue;
499 | const isEnabledOverride = overrides[key] ?? null;
500 | mergedFlags[key] = { ...fetchedFlag, isEnabledOverride };
501 | }
502 | return mergedFlags;
503 | }
504 |
505 | private triggerFlagsUpdated() {
506 | this.eventTarget.dispatchEvent(new Event("flagsUpdated"));
507 | }
508 |
509 | private setupCache(staleTimeMs = 0, expireTimeMs = FLAGS_EXPIRE_MS) {
510 | return new FlagCache({
511 | storage: !IS_SERVER
512 | ? {
513 | get: () => localStorage.getItem(localStorageFetchedFlagsKey),
514 | set: (value) =>
515 | localStorage.setItem(localStorageFetchedFlagsKey, value),
516 | }
517 | : {
518 | get: () => null,
519 | set: () => void 0,
520 | },
521 | staleTimeMs,
522 | expireTimeMs,
523 | });
524 | }
525 |
526 | private setupFallbackFlags(
527 | fallbackFlags?: Record<string, FallbackFlagOverride> | string[],
528 | ) {
529 | if (Array.isArray(fallbackFlags)) {
530 | return fallbackFlags.reduce(
531 | (acc, key) => {
532 | acc[key] = true;
533 | return acc;
534 | },
535 | {} as Record<string, FallbackFlagOverride>,
536 | );
537 | } else {
538 | return fallbackFlags ?? {};
539 | }
540 | }
541 |
542 | private fetchParams() {
543 | const flattenedContext = flattenJSON({ context: this.context });
544 | const params = new URLSearchParams(flattenedContext);
545 | // publishableKey should be part of the cache key
546 | params.append("publishableKey", this.httpClient.publishableKey);
547 |
548 | // sort the params to ensure that the URL is the same for the same context
549 | params.sort();
550 |
551 | return params;
552 | }
553 |
554 | private warnMissingFlagContextFields(flags: RawFlags) {
555 | const report: Record<string, string[]> = {};
556 | for (const flagKey in flags) {
557 | const flag = flags[flagKey];
558 | if (flag?.missingContextFields?.length) {
559 | report[flag.key] = flag.missingContextFields;
560 | }
561 |
562 | if (flag?.config?.missingContextFields?.length) {
563 | report[`${flag.key}.config`] = flag.config.missingContextFields;
564 | }
565 | }
566 |
567 | if (Object.keys(report).length > 0) {
568 | this.rateLimiter.rateLimited(
569 | `flag-missing-context-fields:${this.fetchParams().toString()}`,
570 | () => {
571 | this.logger.warn(
572 | `flag targeting rules might not be correctly evaluated due to missing context fields.`,
573 | report,
574 | );
575 | },
576 | );
577 | }
578 | }
579 | }
580 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/flags.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
2 |
3 | import { version } from "../package.json";
4 | import { FLAGS_EXPIRE_MS } from "../src/config";
5 | import { FlagsClient, RawFlag } from "../src/flag/flags";
6 | import { HttpClient } from "../src/httpClient";
7 |
8 | import { flagsResult } from "./mocks/handlers";
9 | import { newCache, TEST_STALE_MS } from "./flagCache.test";
10 | import { testLogger } from "./testLogger";
11 |
12 | beforeEach(() => {
13 | vi.useFakeTimers();
14 | vi.resetAllMocks();
15 | });
16 |
17 | afterAll(() => {
18 | vi.useRealTimers();
19 | });
20 |
21 | function flagsClientFactory() {
22 | const { cache } = newCache();
23 | const httpClient = new HttpClient("pk", {
24 | baseUrl: "https://front.reflag.com",
25 | });
26 |
27 | vi.spyOn(httpClient, "get");
28 | vi.spyOn(httpClient, "post");
29 |
30 | return {
31 | cache,
32 | httpClient,
33 | newFlagsClient: function newFlagsClient(
34 | context?: Record<string, any>,
35 | options?: { staleWhileRevalidate?: boolean; fallbackFlags?: any },
36 | ) {
37 | return new FlagsClient(
38 | httpClient,
39 | {
40 | user: { id: "123" },
41 | company: { id: "456" },
42 | other: { eventId: "big-conference1" },
43 | ...context,
44 | },
45 | testLogger,
46 | {
47 | cache,
48 | ...options,
49 | },
50 | );
51 | },
52 | };
53 | }
54 |
55 | describe("FlagsClient", () => {
56 | beforeEach(() => {
57 | vi.clearAllMocks();
58 | });
59 |
60 | test("fetches flags", async () => {
61 | const { newFlagsClient, httpClient } = flagsClientFactory();
62 | const flagsClient = newFlagsClient();
63 |
64 | let updated = false;
65 | flagsClient.onUpdated(() => {
66 | updated = true;
67 | });
68 |
69 | await flagsClient.initialize();
70 | expect(flagsClient.getFlags()).toEqual(flagsResult);
71 |
72 | expect(updated).toBe(true);
73 | expect(httpClient.get).toBeCalledTimes(1);
74 |
75 | const calls = vi.mocked(httpClient.get).mock.calls.at(0)!;
76 | const { params, path, timeoutMs } = calls[0];
77 |
78 | const paramsObj = Object.fromEntries(new URLSearchParams(params));
79 | expect(paramsObj).toEqual({
80 | "reflag-sdk-version": "browser-sdk/" + version,
81 | "context.user.id": "123",
82 | "context.company.id": "456",
83 | "context.other.eventId": "big-conference1",
84 | publishableKey: "pk",
85 | });
86 |
87 | expect(path).toEqual("/features/evaluated");
88 | expect(timeoutMs).toEqual(5000);
89 | });
90 |
91 | test("warns about missing context fields", async () => {
92 | const { newFlagsClient } = flagsClientFactory();
93 | const flagsClient = newFlagsClient();
94 |
95 | await flagsClient.initialize();
96 |
97 | expect(testLogger.warn).toHaveBeenCalledTimes(1);
98 | expect(testLogger.warn).toHaveBeenCalledWith(
99 | "[Flags] flag targeting rules might not be correctly evaluated due to missing context fields.",
100 | {
101 | flagA: ["field1", "field2"],
102 | "flagB.config": ["field3"],
103 | },
104 | );
105 |
106 | vi.advanceTimersByTime(TEST_STALE_MS + 1);
107 |
108 | expect(testLogger.warn).toHaveBeenCalledTimes(1);
109 | vi.advanceTimersByTime(60 * 1000);
110 | await flagsClient.initialize();
111 | expect(testLogger.warn).toHaveBeenCalledTimes(2);
112 | });
113 |
114 | test("ignores undefined context", async () => {
115 | const { newFlagsClient, httpClient } = flagsClientFactory();
116 | const flagsClient = newFlagsClient({
117 | user: undefined,
118 | company: undefined,
119 | other: undefined,
120 | });
121 | await flagsClient.initialize();
122 | expect(flagsClient.getFlags()).toEqual(flagsResult);
123 |
124 | expect(httpClient.get).toBeCalledTimes(1);
125 | const calls = vi.mocked(httpClient.get).mock.calls.at(0);
126 | const { params, path, timeoutMs } = calls![0];
127 |
128 | const paramsObj = Object.fromEntries(new URLSearchParams(params));
129 | expect(paramsObj).toEqual({
130 | "reflag-sdk-version": "browser-sdk/" + version,
131 | publishableKey: "pk",
132 | });
133 |
134 | expect(path).toEqual("/features/evaluated");
135 | expect(timeoutMs).toEqual(5000);
136 | });
137 |
138 | test("return fallback flags on failure (string list)", async () => {
139 | const { newFlagsClient, httpClient } = flagsClientFactory();
140 |
141 | vi.mocked(httpClient.get).mockRejectedValue(
142 | new Error("Failed to fetch flags"),
143 | );
144 |
145 | const flagsClient = newFlagsClient(undefined, {
146 | fallbackFlags: ["huddle"],
147 | });
148 |
149 | await flagsClient.initialize();
150 | expect(flagsClient.getFlags()).toStrictEqual({
151 | huddle: {
152 | isEnabled: true,
153 | config: undefined,
154 | key: "huddle",
155 | isEnabledOverride: null,
156 | },
157 | });
158 | });
159 |
160 | test("return fallback flags on failure (record)", async () => {
161 | const { newFlagsClient, httpClient } = flagsClientFactory();
162 |
163 | vi.mocked(httpClient.get).mockRejectedValue(
164 | new Error("Failed to fetch flags"),
165 | );
166 | const flagsClient = newFlagsClient(undefined, {
167 | fallbackFlags: {
168 | huddle: {
169 | key: "john",
170 | payload: { something: "else" },
171 | },
172 | zoom: true,
173 | },
174 | });
175 |
176 | await flagsClient.initialize();
177 | expect(flagsClient.getFlags()).toStrictEqual({
178 | huddle: {
179 | isEnabled: true,
180 | config: { key: "john", payload: { something: "else" } },
181 | key: "huddle",
182 | isEnabledOverride: null,
183 | },
184 | zoom: {
185 | isEnabled: true,
186 | config: undefined,
187 | key: "zoom",
188 | isEnabledOverride: null,
189 | },
190 | });
191 | });
192 |
193 | test("caches response", async () => {
194 | const { newFlagsClient, httpClient } = flagsClientFactory();
195 |
196 | const flagsClient1 = newFlagsClient();
197 | await flagsClient1.initialize();
198 |
199 | expect(httpClient.get).toBeCalledTimes(1);
200 |
201 | const flagsClient2 = newFlagsClient();
202 | await flagsClient2.initialize();
203 |
204 | const flags = flagsClient2.getFlags();
205 |
206 | expect(flags).toEqual(flagsResult);
207 | expect(httpClient.get).toBeCalledTimes(1);
208 | });
209 |
210 | test("use cache when unable to fetch flags", async () => {
211 | const { newFlagsClient, httpClient } = flagsClientFactory();
212 | const flagsClient = newFlagsClient({ staleWhileRevalidate: false });
213 | await flagsClient.initialize(); // cache them initially
214 |
215 | vi.mocked(httpClient.get).mockRejectedValue(
216 | new Error("Failed to fetch flags"),
217 | );
218 | expect(httpClient.get).toBeCalledTimes(1);
219 |
220 | vi.advanceTimersByTime(TEST_STALE_MS + 1);
221 |
222 | // fail this time
223 | await flagsClient.fetchFlags();
224 | expect(httpClient.get).toBeCalledTimes(2);
225 |
226 | const staleFlags = flagsClient.getFlags();
227 | expect(staleFlags).toEqual(flagsResult);
228 | });
229 |
230 | test("stale-while-revalidate should cache but start new fetch", async () => {
231 | const response = {
232 | success: true,
233 | features: {
234 | flagB: {
235 | isEnabled: true,
236 | key: "flagB",
237 | targetingVersion: 1,
238 | } satisfies RawFlag,
239 | },
240 | };
241 |
242 | const { newFlagsClient, httpClient } = flagsClientFactory();
243 |
244 | vi.mocked(httpClient.get).mockResolvedValue({
245 | status: 200,
246 | ok: true,
247 | json: function () {
248 | return Promise.resolve(response);
249 | },
250 | } as Response);
251 |
252 | const client = newFlagsClient({
253 | staleWhileRevalidate: true,
254 | });
255 | expect(httpClient.get).toHaveBeenCalledTimes(0);
256 |
257 | await client.initialize();
258 | expect(client.getFlags()).toEqual({
259 | flagB: {
260 | isEnabled: true,
261 | key: "flagB",
262 | targetingVersion: 1,
263 | isEnabledOverride: null,
264 | } satisfies RawFlag,
265 | });
266 |
267 | expect(httpClient.get).toHaveBeenCalledTimes(1);
268 | const client2 = newFlagsClient({
269 | staleWhileRevalidate: true,
270 | });
271 |
272 | // change the response so we can validate that we'll serve the stale cache
273 | vi.mocked(httpClient.get).mockResolvedValue({
274 | status: 200,
275 | ok: true,
276 | json: () =>
277 | Promise.resolve({
278 | success: true,
279 | features: {
280 | flagA: {
281 | isEnabled: true,
282 | key: "flagA",
283 | targetingVersion: 1,
284 | },
285 | },
286 | }),
287 | } as Response);
288 |
289 | vi.advanceTimersByTime(TEST_STALE_MS + 1);
290 |
291 | await client2.initialize();
292 |
293 | // new fetch was fired in the background
294 | expect(httpClient.get).toHaveBeenCalledTimes(2);
295 |
296 | await vi.waitFor(() =>
297 | expect(client2.getFlags()).toEqual({
298 | flagA: {
299 | isEnabled: true,
300 | targetingVersion: 1,
301 | key: "flagA",
302 | isEnabledOverride: null,
303 | } satisfies RawFlag,
304 | }),
305 | );
306 | });
307 |
308 | test("expires cache eventually", async () => {
309 | // change the response so we can validate that we'll serve the stale cache
310 | const { newFlagsClient, httpClient } = flagsClientFactory();
311 | const client = newFlagsClient();
312 | await client.initialize();
313 | const a = client.getFlags();
314 |
315 | vi.advanceTimersByTime(FLAGS_EXPIRE_MS + 1);
316 | vi.mocked(httpClient.get).mockResolvedValue({
317 | status: 200,
318 | ok: true,
319 | json: () =>
320 | Promise.resolve({
321 | success: true,
322 | features: {
323 | flagB: { isEnabled: true, key: "flagB" },
324 | },
325 | }),
326 | } as Response);
327 | const client2 = newFlagsClient();
328 | await client2.initialize();
329 |
330 | const b = client2.getFlags();
331 |
332 | expect(httpClient.get).toHaveBeenCalledTimes(2);
333 | expect(a).not.toEqual(b);
334 | });
335 |
336 | test("handled overrides", async () => {
337 | // change the response so we can validate that we'll serve the stale cache
338 | const { newFlagsClient } = flagsClientFactory();
339 | // localStorage.clear();
340 | const client = newFlagsClient();
341 | await client.initialize();
342 |
343 | let updated = false;
344 | client.onUpdated(() => {
345 | updated = true;
346 | });
347 |
348 | expect(client.getFlags().flagA.isEnabled).toBe(true);
349 | expect(client.getFlags().flagA.isEnabledOverride).toBe(null);
350 |
351 | expect(updated).toBe(false);
352 |
353 | client.setFlagOverride("flagA", false);
354 |
355 | expect(updated).toBe(true);
356 | expect(client.getFlags().flagA.isEnabled).toBe(true);
357 | expect(client.getFlags().flagA.isEnabledOverride).toBe(false);
358 | });
359 |
360 | test("ignores overrides for flags not returned by API", async () => {
361 | // change the response so we can validate that we'll serve the stale cache
362 | const { newFlagsClient } = flagsClientFactory();
363 |
364 | // localStorage.clear();
365 | const client = newFlagsClient(undefined);
366 | await client.initialize();
367 |
368 | let updated = false;
369 | client.onUpdated(() => {
370 | updated = true;
371 | });
372 |
373 | expect(client.getFlags().flagB.isEnabled).toBe(true);
374 | expect(client.getFlags().flagB.isEnabledOverride).toBe(null);
375 |
376 | // Setting an override for a flag that doesn't exist in fetched flags
377 | // should not trigger an update since the merged flags don't change
378 | client.setFlagOverride("flagC", true);
379 |
380 | expect(updated).toBe(false);
381 | expect(client.getFlags().flagC).toBeUndefined();
382 | });
383 |
384 | describe("pre-fetched flags", () => {
385 | test("should have flags available when bootstrapped flags are provided in constructor", () => {
386 | const { httpClient } = flagsClientFactory();
387 | const preFetchedFlags = {
388 | testFlag: {
389 | key: "testFlag",
390 | isEnabled: true,
391 | targetingVersion: 1,
392 | },
393 | configFlag: {
394 | key: "configFlag",
395 | isEnabled: false,
396 | targetingVersion: 2,
397 | config: {
398 | key: "config1",
399 | version: 1,
400 | payload: { value: "test" },
401 | },
402 | },
403 | };
404 |
405 | const flagsClient = new FlagsClient(
406 | httpClient,
407 | {
408 | user: { id: "123" },
409 | company: { id: "456" },
410 | other: { eventId: "big-conference1" },
411 | },
412 | testLogger,
413 | {
414 | bootstrappedFlags: preFetchedFlags,
415 | },
416 | );
417 |
418 | // Should be bootstrapped but not initialized until initialize() is called
419 | expect(flagsClient["bootstrapped"]).toBe(true);
420 | expect(flagsClient["initialized"]).toBe(false);
421 |
422 | // Should have the flags available even before initialize()
423 | expect(flagsClient.getFlags()).toEqual({
424 | testFlag: {
425 | key: "testFlag",
426 | isEnabled: true,
427 | targetingVersion: 1,
428 | isEnabledOverride: null,
429 | },
430 | configFlag: {
431 | key: "configFlag",
432 | isEnabled: false,
433 | targetingVersion: 2,
434 | config: {
435 | key: "config1",
436 | version: 1,
437 | payload: { value: "test" },
438 | },
439 | isEnabledOverride: null,
440 | },
441 | });
442 | });
443 |
444 | test("should skip fetching when already initialized with pre-fetched flags", async () => {
445 | const { httpClient } = flagsClientFactory();
446 | vi.spyOn(httpClient, "get");
447 |
448 | const preFetchedFlags = {
449 | testFlag: {
450 | key: "testFlag",
451 | isEnabled: true,
452 | targetingVersion: 1,
453 | },
454 | };
455 |
456 | const flagsClient = new FlagsClient(
457 | httpClient,
458 | {
459 | user: { id: "123" },
460 | company: { id: "456" },
461 | other: { eventId: "big-conference1" },
462 | },
463 | testLogger,
464 | {
465 | bootstrappedFlags: preFetchedFlags,
466 | },
467 | );
468 |
469 | // Call initialize() after flags are already provided
470 | await flagsClient.initialize();
471 |
472 | // Should not have made any HTTP requests since already initialized
473 | expect(httpClient.get).not.toHaveBeenCalled();
474 |
475 | // Should still have the flags available
476 | expect(flagsClient.getFlags()).toEqual({
477 | testFlag: {
478 | key: "testFlag",
479 | isEnabled: true,
480 | targetingVersion: 1,
481 | isEnabledOverride: null,
482 | },
483 | });
484 | });
485 |
486 | test("should trigger onUpdated when pre-fetched flags are set", async () => {
487 | const { httpClient } = flagsClientFactory();
488 | const preFetchedFlags = {
489 | testFlag: {
490 | key: "testFlag",
491 | isEnabled: true,
492 | targetingVersion: 1,
493 | },
494 | };
495 |
496 | const flagsClient = new FlagsClient(
497 | httpClient,
498 | {
499 | user: { id: "123" },
500 | company: { id: "456" },
501 | other: { eventId: "big-conference1" },
502 | },
503 | testLogger,
504 | {
505 | bootstrappedFlags: preFetchedFlags,
506 | },
507 | );
508 |
509 | let updateTriggered = false;
510 | flagsClient.onUpdated(() => {
511 | updateTriggered = true;
512 | });
513 |
514 | // Trigger the flags updated event by setting context (which should still fetch)
515 | await flagsClient.setContext({
516 | user: { id: "456" },
517 | company: { id: "789" },
518 | other: { eventId: "other-conference" },
519 | });
520 |
521 | expect(updateTriggered).toBe(true);
522 | });
523 |
524 | test("should work with fallback flags when initialization fails", async () => {
525 | const { httpClient } = flagsClientFactory();
526 | vi.spyOn(httpClient, "get").mockRejectedValue(
527 | new Error("Failed to fetch flags"),
528 | );
529 |
530 | const preFetchedFlags = {
531 | testFlag: {
532 | key: "testFlag",
533 | isEnabled: true,
534 | targetingVersion: 1,
535 | },
536 | };
537 |
538 | const flagsClient = new FlagsClient(
539 | httpClient,
540 | {
541 | user: { id: "123" },
542 | company: { id: "456" },
543 | other: { eventId: "big-conference1" },
544 | },
545 | testLogger,
546 | {
547 | bootstrappedFlags: preFetchedFlags,
548 | fallbackFlags: ["fallbackFlag"],
549 | },
550 | );
551 |
552 | // Should be bootstrapped but not initialized until initialize() is called
553 | expect(flagsClient["bootstrapped"]).toBe(true);
554 | expect(flagsClient["initialized"]).toBe(false);
555 | expect(flagsClient.getFlags()).toEqual({
556 | testFlag: {
557 | key: "testFlag",
558 | isEnabled: true,
559 | targetingVersion: 1,
560 | isEnabledOverride: null,
561 | },
562 | });
563 |
564 | // Calling initialize should not fetch since already bootstrapped
565 | await flagsClient.initialize();
566 | expect(httpClient.get).not.toHaveBeenCalled();
567 | expect(flagsClient["initialized"]).toBe(true);
568 | });
569 | });
570 | });
571 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/FEEDBACK.md:
--------------------------------------------------------------------------------
```markdown
1 | # Reflag Feedback UI
2 |
3 | The Reflag Browser SDK includes a UI you can use to collect feedback from user
4 | about particular flags.
5 |
6 | 
7 |
8 | ## Global feedback configuration
9 |
10 | The Reflag Browser SDK feedback UI is configured with reasonable defaults,
11 | positioning itself as a [dialog](#dialog) in the lower right-hand corner of
12 | the viewport, displayed in English, and with a [light-mode theme](#custom-styling).
13 |
14 | These settings can be overwritten when initializing the Reflag Browser SDK:
15 |
16 | ```typescript
17 | const reflag = new ReflagClient({
18 | publishableKey: "reflag-publishable-key",
19 | user: { id: "42" },
20 | feedback: {
21 | ui: {
22 | position: POSITION_CONFIG, // See positioning section
23 | translations: TRANSLATION_KEYS, // See internationalization section
24 |
25 | // Enable automated feedback surveys. Default: `true`
26 | enableAutoFeedback: boolean,
27 |
28 | /**
29 | * Do your own feedback prompt handling or override
30 | * default settings at runtime.
31 | */
32 | autoFeedbackHandler: (promptMessage, handlers) => {
33 | // See Automated Feedback Surveys section
34 | },
35 | },
36 | },
37 | });
38 | ```
39 |
40 | See also:
41 |
42 | - [Positioning and behavior](#positioning-and-behavior) for the position option,
43 | - [Static language configuration](#static-language-configuration) if you want to translate the feedback UI,
44 | - [Automated feedback surveys](#automated-feedback-surveys) to override default configuration.
45 |
46 | ## Automated feedback surveys
47 |
48 | Automated feedback surveys are enabled by default.
49 |
50 | When automated feedback surveys are enabled, the Reflag Browser SDK
51 | will open and maintain a connection to the Reflag service. When a user
52 | triggers an event tracked by a flag and is eligible to be prompted
53 | for feedback, the Reflag service will send a request to the SDK instance.
54 | By default, this request will open up the Reflag feedback UI in the user's
55 | browser, but you can intercept the request and override this behavior.
56 |
57 | The live connection for automated feedback is established when the
58 | `ReflagClient` is initialized.
59 |
60 | ### Disabling automated feedback surveys
61 |
62 | You can disable automated collection in the `ReflagClient` constructor:
63 |
64 | ```typescript
65 | const reflag = new ReflagClient({
66 | publishableKey: "reflag-publishable-key",
67 | user: { id: "42" },
68 | feedback: {
69 | enableAutoFeedback: false,
70 | },
71 | });
72 | ```
73 |
74 | ### Overriding prompt event defaults
75 |
76 | If you are not satisfied with the default UI behavior when an automated prompt
77 | event arrives, you can can [override the global defaults](#global-feedback-configuration)
78 | or intercept and override settings at runtime like this:
79 |
80 | ```javascript
81 | const reflag = new ReflagClient({
82 | publishableKey: "reflag-publishable-key",
83 | user: { id: "42" },
84 | feedback: {
85 | autoFeedbackHandler: (promptMessage, handlers) => {
86 | // Pass your overrides here. Everything is optional
87 | handlers.openFeedbackForm({
88 | title: promptMessage.question,
89 |
90 | position: POSITION_CONFIG, // See positioning section
91 | translations: TRANSLATION_KEYS, // See internationalization section
92 |
93 | // Trigger side effects with the collected data,
94 | // for example posting it back into your own CRM
95 | onAfterSubmit: (feedback) => {
96 | storeFeedbackInCRM({
97 | score: feedback.score,
98 | comment: feedback.comment,
99 | });
100 | },
101 | });
102 | },
103 | },
104 | });
105 | ```
106 |
107 | See also:
108 |
109 | - [Positioning and behavior](#positioning-and-behavior) for the position option.
110 | - [Runtime language configuration](#runtime-language-configuration) if you want
111 | to translate the feedback UI.
112 | - [Use your own UI to collect feedback](#using-your-own-ui-to-collect-feedback) if
113 | the feedback UI doesn't match your design.
114 |
115 | ## Manual feedback collection
116 |
117 | To open up the feedback collection UI, call `reflagClient.requestFeedback(options)`
118 | with the appropriate options. This approach is particularly beneficial if you wish
119 | to retain manual control over feedback collection from your users while leveraging
120 | the convenience of the Reflag feedback UI to reduce the amount of code you need
121 | to maintain.
122 |
123 | Examples of this could be if you want the click of a `give us feedback`-button
124 | or the end of a specific user flow, to trigger a pop-up displaying the feedback
125 | user interface.
126 |
127 | ### reflagClient.requestFeedback() options
128 |
129 | Minimal usage with defaults:
130 |
131 | ```javascript
132 | reflagClient.requestFeedback({
133 | flagKey: "reflag-flag-key",
134 | title: "How satisfied are you with file uploads?",
135 | });
136 | ```
137 |
138 | All options:
139 |
140 | ```javascript
141 | reflagClient.requestFeedback({
142 | flagKey: "reflag-flag-key", // [Required]
143 | userId: "your-user-id", // [Optional] if user persistence is
144 | // enabled (default in browsers),
145 | companyId: "users-company-or-account-id", // [Optional]
146 | title: "How satisfied are you with file uploads?" // [Optional]
147 |
148 | position: POSITION_CONFIG, // [Optional] see the positioning section
149 | translations: TRANSLATION_KEYS // [Optional] see the internationalization section
150 |
151 | // [Optional] trigger side effects with the collected data,
152 | // for example sending the feedback to your own CRM
153 | onAfterSubmit: (feedback) => {
154 | storeFeedbackInCRM({
155 | score: feedback.score,
156 | comment: feedback.comment
157 | })
158 | }
159 | })
160 | ```
161 |
162 | See also:
163 |
164 | - [Positioning and behavior](#positioning-and-behavior) for the position option.
165 | - [Runtime language configuration](#runtime-language-configuration) if
166 | you want to translate the feedback UI.
167 |
168 | ## Positioning and behavior
169 |
170 | The feedback UI can be configured to be placed and behave in 3 different ways:
171 |
172 | ### Positioning configuration
173 |
174 | #### Modal
175 |
176 | A modal overlay with a backdrop that blocks interaction with the underlying
177 | page. It can be dismissed with the keyboard shortcut `<ESC>` or the dedicated
178 | close button in the top right corner. It is always centered on the page, capturing
179 | focus, and making it the primary interface the user needs to interact with.
180 |
181 | 
182 |
183 | Using a modal is the strongest possible push for feedback. You are interrupting the
184 | user's normal flow, which can cause annoyance. A good use-case for the modal is
185 | when the user finishes a linear flow that they don't perform often, for example
186 | setting up a new account.
187 |
188 | ```javascript
189 | position: {
190 | type: "MODAL";
191 | }
192 | ```
193 |
194 | #### Dialog
195 |
196 | A dialog that appears in a specified corner of the viewport, without limiting the
197 | user's interaction with the rest of the page. It can be dismissed with the dedicated
198 | close button, but will automatically disappear after a short time period if the user
199 | does not interact with it.
200 |
201 | 
202 |
203 | Using a dialog is a soft push for feedback. It lets the user continue their work
204 | with a minimal amount of intrusion. The user can opt-in to respond but is not
205 | required to. A good use case for this behavior is when a user uses a flag where
206 | the expected outcome is predictable, possibly because they have used it multiple
207 | times before. For example: Uploading a file, switching to a different view of a
208 | visualization, visiting a specific page, or manipulating some data.
209 |
210 | The default feedback UI behavior is a dialog placed in the bottom right corner of
211 | the viewport.
212 |
213 | ```typescript
214 | position: {
215 | type: "DIALOG";
216 | placement: "top-left" | "top-right" | "bottom-left" | "bottom-right";
217 | offset?: {
218 | x?: string | number; // e.g. "-5rem", "10px" or 10 (pixels)
219 | y?: string | number;
220 | }
221 | }
222 | ```
223 |
224 | #### Popover
225 |
226 | A popover that is anchored relative to a DOM-element (typically a button). It can
227 | be dismissed by clicking outside the popover or by pressing the dedicated close button.
228 |
229 | 
230 |
231 | You can use the popover mode to implement your own button to collect feedback manually.
232 |
233 | ```typescript
234 | type Position = {
235 | type: "POPOVER";
236 | anchor: DOMElement;
237 | };
238 | ```
239 |
240 | Popover feedback button example:
241 |
242 | ```html
243 | <button id="feedbackButton">Tell us what you think</button>
244 | <script>
245 | const button = document.getElementById("feedbackButton");
246 | button.addEventListener("click", (e) => {
247 | reflagClient.requestFeedback({
248 | flagKey: "reflag-flag-key",
249 | userId: "your-user-id",
250 | title: "How do you like the popover?",
251 | position: {
252 | type: "POPOVER",
253 | anchor: e.currentTarget,
254 | },
255 | });
256 | });
257 | </script>
258 | ```
259 |
260 | ## Internationalization (i18n)
261 |
262 | By default, the feedback UI is written in English. However, you can supply your own
263 | translations by passing an object in the options to either or both of the
264 | `new ReflagClient(options)` or `reflagClient.requestFeedback(options)` calls.
265 | These translations will replace the English ones used by the feedback interface.
266 | See examples below.
267 |
268 | 
269 |
270 | See [default English localization keys](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx)
271 | for a reference of what translation keys can be supplied.
272 |
273 | ### Static language configuration
274 |
275 | If you know the language at page load, you can configure your translation keys while
276 | initializing the Reflag Browser SDK:
277 |
278 | ```typescript
279 | new ReflagClient({
280 | publishableKey: "my-publishable-key",
281 | feedback: {
282 | ui: {
283 | translations: {
284 | DefaultQuestionLabel:
285 | "Dans quelle mesure êtes-vous satisfait de cette fonctionnalité ?",
286 | QuestionPlaceholder:
287 | "Comment pouvons-nous améliorer cette fonctionnalité ?",
288 | ScoreStatusDescription: "Choisissez une note et laissez un commentaire",
289 | ScoreStatusLoading: "Chargement...",
290 | ScoreStatusReceived: "La note a été reçue !",
291 | ScoreVeryDissatisfiedLabel: "Très insatisfait",
292 | ScoreDissatisfiedLabel: "Insatisfait",
293 | ScoreNeutralLabel: "Neutre",
294 | ScoreSatisfiedLabel: "Satisfait",
295 | ScoreVerySatisfiedLabel: "Très satisfait",
296 | SuccessMessage: "Merci d'avoir envoyé vos commentaires!",
297 | SendButton: "Envoyer",
298 | },
299 | },
300 | },
301 | });
302 | ```
303 |
304 | ### Runtime language configuration
305 |
306 | If you only know the user's language after the page has loaded, you can provide
307 | translations to either the `reflagClient.requestFeedback(options)` call or
308 | the `autoFeedbackHandler` option before the feedback interface opens.
309 | See examples below.
310 |
311 | ```typescript
312 | reflagClient.requestFeedback({
313 | ... // Other options
314 | translations: {
315 | // your translation keys
316 | }
317 | })
318 | ```
319 |
320 | ### Translations
321 |
322 | When you are collecting feedback through the Reflag automation, you can intercept
323 | the default prompt handling and override the defaults.
324 |
325 | If you set the prompt question in the Reflag app to be one of your own translation
326 | keys, you can even get a translated version of the question you want to ask your
327 | customer in the feedback UI.
328 |
329 | ```javascript
330 | new ReflagClient({
331 | publishableKey: "reflag-publishable-key",
332 | feedback: {
333 | autoFeedbackHandler: (message, handlers) => {
334 | const translatedQuestion =
335 | i18nLookup[message.question] ?? message.question;
336 | handlers.openFeedbackForm({
337 | title: translatedQuestion,
338 | translations: {
339 | // your static translation keys
340 | },
341 | });
342 | },
343 | },
344 | });
345 | ```
346 |
347 | ## Custom styling
348 |
349 | You can adapt parts of the look of the Reflag feedback UI by applying CSS custom
350 | properties to your page in your CSS `:root`-scope.
351 |
352 | For example, a dark mode theme might look like this:
353 |
354 | 
355 |
356 | ```css
357 | :root {
358 | --reflag-feedback-dialog-background-color: #1e1f24;
359 | --reflag-feedback-dialog-color: rgba(255, 255, 255, 0.92);
360 | --reflag-feedback-dialog-secondary-color: rgba(255, 255, 255, 0.3);
361 | --reflag-feedback-dialog-border: rgba(255, 255, 255, 0.16);
362 | --reflag-feedback-dialog-primary-button-background-color: #655bfa;
363 | --reflag-feedback-dialog-primary-button-color: white;
364 | --reflag-feedback-dialog-input-border-color: rgba(255, 255, 255, 0.16);
365 | --reflag-feedback-dialog-input-focus-border-color: rgba(255, 255, 255, 0.3);
366 | --reflag-feedback-dialog-error-color: #f56565;
367 |
368 | --reflag-feedback-dialog-rating-1-color: #ed8936;
369 | --reflag-feedback-dialog-rating-1-background-color: #7b341e;
370 | --reflag-feedback-dialog-rating-2-color: #dd6b20;
371 | --reflag-feedback-dialog-rating-2-background-color: #652b19;
372 | --reflag-feedback-dialog-rating-3-color: #787c91;
373 | --reflag-feedback-dialog-rating-3-background-color: #3e404c;
374 | --reflag-feedback-dialog-rating-4-color: #38a169;
375 | --reflag-feedback-dialog-rating-4-background-color: #1c4532;
376 | --reflag-feedback-dialog-rating-5-color: #48bb78;
377 | --reflag-feedback-dialog-rating-5-background-color: #22543d;
378 |
379 | --reflag-feedback-dialog-submitted-check-background-color: #38a169;
380 | --reflag-feedback-dialog-submitted-check-color: #ffffff;
381 | }
382 | ```
383 |
384 | Other examples of custom styling can be found in our [development example style-sheet](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/src/feedback/ui/index.css).
385 |
386 | ## Using your own UI to collect feedback
387 |
388 | You may have very strict design guidelines for your app and maybe the Reflag feedback
389 | UI doesn't quite work for you. In this case, you can implement your own feedback
390 | collection mechanism, which follows your own design guidelines. This is the data
391 | type you need to collect:
392 |
393 | ```typescript
394 | type DataToCollect = {
395 | // Customer satisfaction score
396 | score?: 1 | 2 | 3 | 4 | 5;
397 |
398 | // The comment.
399 | comment?: string;
400 | };
401 | ```
402 |
403 | Either `score` or `comment` must be defined in order to pass validation in the
404 | Reflag API.
405 |
406 | ### Manual feedback collection with custom UI
407 |
408 | Examples of a HTML-form that collects the relevant data can be found
409 | in [feedback.html](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/example/feedback/feedback.html) and [feedback.jsx](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/example/feedback/Feedback.jsx).
410 |
411 | Once you have collected the feedback data, pass it along to `reflagClient.feedback()`:
412 |
413 | ```javascript
414 | reflagClient.feedback({
415 | flagKey: "reflag-flag-key",
416 | userId: "your-user-id",
417 | score: 5,
418 | comment: "Best thing I've ever tried!",
419 | });
420 | ```
421 |
422 | ### Intercepting automated feedback survey events
423 |
424 | When using automated feedback surveys, the Reflag service will, when specified,
425 | send a feedback prompt message to your user's instance of the Reflag Browser SDK.
426 | This will result in the feedback UI being opened.
427 |
428 | You can intercept this behavior and open your own custom feedback collection form:
429 |
430 | ```typescript
431 | new ReflagClient({
432 | publishableKey: "reflag-publishable-key",
433 | feedback: {
434 | autoFeedbackHandler: async (promptMessage, handlers) => {
435 | // This opens your custom UI
436 | customFeedbackCollection({
437 | // The question configured in the Reflag UI for the flag
438 | question: promptMessage.question,
439 | // When the user successfully submits feedback data.
440 | // Use this instead of `reflagClient.feedback()`, otherwise
441 | // the feedback prompt handler will keep being called
442 | // with the same prompt message
443 | onFeedbackSubmitted: (feedback) => {
444 | handlers.reply(feedback);
445 | },
446 | // When the user closes the custom feedback form
447 | // without leaving any response.
448 | // It is important to feed this back, otherwise
449 | // the feedback prompt handler will keep being called
450 | // with the same prompt message
451 | onFeedbackDismissed: () => {
452 | handlers.reply(null);
453 | },
454 | });
455 | },
456 | },
457 | });
458 | ```
459 |
```
--------------------------------------------------------------------------------
/packages/react-sdk/src/index.tsx:
--------------------------------------------------------------------------------
```typescript
1 | "use client";
2 |
3 | import React, {
4 | createContext,
5 | ReactNode,
6 | useContext,
7 | useEffect,
8 | useMemo,
9 | useState,
10 | } from "react";
11 |
12 | import {
13 | CheckEvent,
14 | CompanyContext,
15 | HookArgs,
16 | InitOptions,
17 | RawFlag,
18 | ReflagClient,
19 | ReflagContext,
20 | RequestFeedbackData,
21 | TrackEvent,
22 | UnassignedFeedback,
23 | UserContext,
24 | } from "@reflag/browser-sdk";
25 |
26 | import { version } from "../package.json";
27 |
28 | export type { CheckEvent, CompanyContext, TrackEvent, UserContext };
29 |
30 | export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
31 |
32 | export type FlagType = {
33 | config?: {
34 | payload: any;
35 | };
36 | };
37 |
38 | /**
39 | * A remotely managed configuration value for a feature.
40 | */
41 | export type FlagRemoteConfig =
42 | | {
43 | /**
44 | * The key of the matched configuration value.
45 | */
46 | key: string;
47 |
48 | /**
49 | * The optional user-supplied payload data.
50 | */
51 | payload: any;
52 | }
53 | | EmptyFlagRemoteConfig;
54 |
55 | /**
56 | * Describes a feature
57 | */
58 | export interface Flag<
59 | TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
60 | > {
61 | /**
62 | * The key of the feature.
63 | */
64 | key: string;
65 |
66 | /**
67 | * If the feature is enabled.
68 | */
69 | isEnabled: boolean;
70 |
71 | /**
72 | * If the feature is loading.
73 | */
74 | isLoading: boolean;
75 |
76 | /*
77 | * Optional user-defined configuration.
78 | */
79 | config:
80 | | ({
81 | key: string;
82 | } & TConfig)
83 | | EmptyFlagRemoteConfig;
84 |
85 | /**
86 | * Track feature usage in Reflag.
87 | */
88 | track(): Promise<Response | undefined> | undefined;
89 | /**
90 | * Request feedback from the user.
91 | */
92 | requestFeedback: (opts: RequestFeedbackOptions) => void;
93 | }
94 |
95 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
96 | export interface Flags {}
97 |
98 | /**
99 | * Describes a collection of evaluated feature.
100 | *
101 | * @remarks
102 | * This types falls back to a generic Record<string, Flag> if the Flags interface
103 | * has not been extended.
104 | *
105 | */
106 | export type TypedFlags = keyof Flags extends never
107 | ? Record<string, Flag>
108 | : {
109 | [TypedFlagKey in keyof Flags]: Flags[TypedFlagKey] extends FlagType
110 | ? Flag<Flags[TypedFlagKey]["config"]>
111 | : Flag;
112 | };
113 |
114 | export type FlagKey = keyof TypedFlags;
115 |
116 | /**
117 | * Describes a collection of evaluated raw flags.
118 | */
119 | export type RawFlags = Record<FlagKey, RawFlag>;
120 |
121 | export type BootstrappedFlags = {
122 | context: ReflagContext;
123 | flags: RawFlags;
124 | };
125 |
126 | const SDK_VERSION = `react-sdk/${version}`;
127 |
128 | /**
129 | * Base props for the ReflagProvider and ReflagBootstrappedProvider.
130 | * @internal
131 | */
132 | export type ReflagPropsBase = {
133 | /**
134 | * The children to render after the client is initialized.
135 | */
136 | children?: ReactNode;
137 |
138 | /**
139 | * A React component to show while the client is initializing.
140 | */
141 | loadingComponent?: ReactNode;
142 |
143 | /**
144 | * Set to `true` to show the loading component while the client is initializing.
145 | */
146 | initialLoading?: boolean;
147 |
148 | /**
149 | * Set to `true` to enable debug logging to the console,
150 | */
151 | debug?: boolean;
152 | };
153 |
154 | /**
155 | * Base init options for the ReflagProvider and ReflagBootstrappedProvider.
156 | * @internal
157 | */
158 | export type ReflagInitOptionsBase = Omit<
159 | InitOptions,
160 | "user" | "company" | "other" | "otherContext" | "bootstrappedFlags"
161 | >;
162 |
163 | /**
164 | * Map of clients by context key. Used to deduplicate initialization of the client.
165 | * @internal
166 | */
167 | const reflagClients = new Map<string, ReflagClient>();
168 |
169 | /**
170 | * Returns the ReflagClient for a given publishable key.
171 | * Only creates a new ReflagClient is not already created or if it hook is run on the server.
172 | * @internal
173 | */
174 | function useReflagClient(initOptions: InitOptions, debug = false) {
175 | const isServer = typeof window === "undefined";
176 | if (isServer || !reflagClients.has(initOptions.publishableKey)) {
177 | const client = new ReflagClient({
178 | ...initOptions,
179 | logger: debug ? console : undefined,
180 | sdkVersion: SDK_VERSION,
181 | });
182 | if (!isServer) {
183 | reflagClients.set(initOptions.publishableKey, client);
184 | }
185 | return client;
186 | }
187 | return reflagClients.get(initOptions.publishableKey)!;
188 | }
189 |
190 | type ProviderContextType = {
191 | isLoading: boolean;
192 | client: ReflagClient;
193 | };
194 |
195 | const ProviderContext = createContext<ProviderContextType | null>(null);
196 |
197 | /**
198 | * Props for the ReflagClientProvider.
199 | */
200 | export type ReflagClientProviderProps = Omit<ReflagPropsBase, "debug"> & {
201 | client: ReflagClient;
202 | };
203 |
204 | export function ReflagClientProvider({
205 | client,
206 | loadingComponent,
207 | initialLoading = true,
208 | children,
209 | }: ReflagClientProviderProps) {
210 | const [isLoading, setIsLoading] = useState(
211 | client.getState() !== "initialized" ? initialLoading : false,
212 | );
213 |
214 | useOnEvent(
215 | "stateUpdated",
216 | (state) => {
217 | setIsLoading(state === "initializing");
218 | },
219 | client,
220 | );
221 |
222 | return (
223 | <ProviderContext.Provider
224 | value={{
225 | isLoading,
226 | client,
227 | }}
228 | >
229 | {isLoading && typeof loadingComponent !== "undefined"
230 | ? loadingComponent
231 | : children}
232 | </ProviderContext.Provider>
233 | );
234 | }
235 |
236 | /**
237 | * Props for the ReflagProvider.
238 | */
239 | export type ReflagProps = ReflagPropsBase &
240 | ReflagInitOptionsBase & {
241 | /**
242 | * The context to use for the ReflagClient containing user, company, and other context.
243 | */
244 | context?: ReflagContext;
245 |
246 | /**
247 | * Company related context. If you provide `id` Reflag will enrich the evaluation context with
248 | * company attributes on Reflag servers.
249 | * @deprecated Use `context` instead, this property will be removed in the next major version
250 | */
251 | company?: CompanyContext;
252 |
253 | /**
254 | * User related context. If you provide `id` Reflag will enrich the evaluation context with
255 | * user attributes on Reflag servers.
256 | * @deprecated Use `context` instead, this property will be removed in the next major version
257 | */
258 | user?: UserContext;
259 |
260 | /**
261 | * Context which is not related to a user or a company.
262 | * @deprecated Use `context` instead, this property will be removed in the next major version
263 | */
264 | otherContext?: Record<string, string | number | undefined>;
265 | };
266 |
267 | /**
268 | * Provider for the ReflagClient.
269 | */
270 | export function ReflagProvider({
271 | children,
272 | context,
273 | user,
274 | company,
275 | otherContext,
276 | loadingComponent,
277 | initialLoading = true,
278 | debug,
279 | ...config
280 | }: ReflagProps) {
281 | const resolvedContext = useMemo(
282 | () => ({ user, company, other: otherContext, ...context }),
283 | [user, company, otherContext, context],
284 | );
285 | const client = useReflagClient(
286 | {
287 | ...config,
288 | ...resolvedContext,
289 | },
290 | debug,
291 | );
292 |
293 | // Initialize the client if it is not already initialized
294 | useEffect(() => {
295 | if (client.getState() !== "idle") return;
296 | void client.initialize().catch((e) => {
297 | client.logger.error("failed to initialize client", e);
298 | });
299 | }, [client]);
300 |
301 | // Update the context if it changes
302 | useEffect(() => {
303 | void client.setContext(resolvedContext);
304 | }, [client, resolvedContext]);
305 |
306 | return (
307 | <ReflagClientProvider
308 | client={client}
309 | initialLoading={initialLoading}
310 | loadingComponent={loadingComponent}
311 | >
312 | {children}
313 | </ReflagClientProvider>
314 | );
315 | }
316 |
317 | /**
318 | * Props for the ReflagBootstrappedProvider.
319 | */
320 | export type ReflagBootstrappedProps = ReflagPropsBase &
321 | ReflagInitOptionsBase & {
322 | /**
323 | * Pre-fetched flags to be used instead of fetching them from the server.
324 | */
325 | flags: BootstrappedFlags;
326 | };
327 |
328 | /**
329 | * Bootstrapped Provider for the ReflagClient using pre-fetched flags.
330 | */
331 | export function ReflagBootstrappedProvider({
332 | flags,
333 | children,
334 | loadingComponent,
335 | initialLoading = false,
336 | debug,
337 | ...config
338 | }: ReflagBootstrappedProps) {
339 | const client = useReflagClient(
340 | {
341 | ...config,
342 | ...flags.context,
343 | bootstrappedFlags: flags.flags,
344 | },
345 | debug,
346 | );
347 |
348 | // Initialize the client if it is not already initialized
349 | useEffect(() => {
350 | if (client.getState() !== "idle") return;
351 | void client.initialize().catch((e) => {
352 | client.logger.error("failed to initialize client", e);
353 | });
354 | }, [client]);
355 |
356 | // Update the context if it changes on the client side
357 | useEffect(() => {
358 | void client.setContext(flags.context);
359 | }, [client, flags.context]);
360 |
361 | // Update the bootstrappedFlags if they change on the client side
362 | useEffect(() => {
363 | client.updateFlags(flags.flags);
364 | }, [client, flags.flags]);
365 |
366 | return (
367 | <ReflagClientProvider
368 | client={client}
369 | initialLoading={initialLoading}
370 | loadingComponent={loadingComponent}
371 | >
372 | {children}
373 | </ReflagClientProvider>
374 | );
375 | }
376 |
377 | export type RequestFeedbackOptions = Omit<
378 | RequestFeedbackData,
379 | "flagKey" | "featureId"
380 | >;
381 |
382 | /**
383 | * @deprecated use `useFlag` instead
384 | */
385 | export function useFeature<TKey extends FlagKey>(key: TKey) {
386 | return useFlag(key);
387 | }
388 |
389 | /**
390 | * Returns the state of a given feature for the current context, e.g.
391 | *
392 | * ```ts
393 | * function HuddleButton() {
394 | * const {isEnabled, config: { payload }, track} = useFlag("huddle");
395 | * if (isEnabled) {
396 | * return <button onClick={() => track()}>{payload?.buttonTitle ?? "Start Huddle"}</button>;
397 | * }
398 | * ```
399 | */
400 | export function useFlag<TKey extends FlagKey>(key: TKey): TypedFlags[TKey] {
401 | const client = useClient();
402 | const isLoading = useIsLoading();
403 | const [flag, setFlag] = useState(client.getFlag(key));
404 |
405 | const track = () => client.track(key);
406 | const requestFeedback = (opts: RequestFeedbackOptions) =>
407 | client.requestFeedback({ ...opts, flagKey: key });
408 |
409 | useOnEvent(
410 | "flagsUpdated",
411 | () => {
412 | setFlag(client.getFlag(key));
413 | },
414 | client,
415 | );
416 |
417 | if (isLoading || !flag) {
418 | return {
419 | key,
420 | isLoading,
421 | isEnabled: false,
422 | config: {
423 | key: undefined,
424 | payload: undefined,
425 | } as TypedFlags[TKey]["config"],
426 | track,
427 | requestFeedback,
428 | };
429 | }
430 |
431 | return {
432 | key,
433 | isLoading,
434 | track,
435 | requestFeedback,
436 | get isEnabled() {
437 | return flag.isEnabled ?? false;
438 | },
439 | get config() {
440 | return flag.config as TypedFlags[TKey]["config"];
441 | },
442 | };
443 | }
444 |
445 | /**
446 | * Returns a function to send an event when a user performs an action
447 | * Note: When calling `useTrack`, user/company must already be set.
448 | *
449 | * ```ts
450 | * const track = useTrack();
451 | * track("Started Huddle", { button: "cta" });
452 | * ```
453 | */
454 | export function useTrack() {
455 | const client = useClient();
456 | return (eventName: string, attributes?: Record<string, any> | null) =>
457 | client.track(eventName, attributes);
458 | }
459 |
460 | /**
461 | * Returns a function to open up the feedback form
462 | * Note: When calling `useRequestFeedback`, user/company must already be set.
463 | *
464 | * See [link](../../browser-sdk/FEEDBACK.md#reflagclientrequestfeedback-options) for more information
465 | *
466 | * ```ts
467 | * const requestFeedback = useRequestFeedback();
468 | * reflag.requestFeedback({
469 | * flagKey: "file-uploads",
470 | * title: "How satisfied are you with file uploads?",
471 | * });
472 | * ```
473 | */
474 | export function useRequestFeedback() {
475 | const client = useClient();
476 | return (options: RequestFeedbackData) => client.requestFeedback(options);
477 | }
478 |
479 | /**
480 | * Returns a function to manually send feedback collected from a user.
481 | * Note: When calling `useSendFeedback`, user/company must already be set.
482 | *
483 | * See [link](./../../browser-sdk/FEEDBACK.md#using-your-own-ui-to-collect-feedback) for more information
484 | *
485 | * ```ts
486 | * const sendFeedback = useSendFeedback();
487 | * sendFeedback({
488 | * flagKey: "huddle";
489 | * question: "How did you like the new huddle feature?";
490 | * score: 5;
491 | * comment: "I loved it!";
492 | * });
493 | * ```
494 | */
495 | export function useSendFeedback() {
496 | const client = useClient();
497 | return (opts: UnassignedFeedback) => client.feedback(opts);
498 | }
499 |
500 | /**
501 | * Returns a function to update the current user's information.
502 | * For example, if the user changed role or opted into a beta-feature.
503 | *
504 | * The method returned is a function which returns a promise that
505 | * resolves when after the features have been updated as a result
506 | * of the user update.
507 | *
508 | * ```ts
509 | * const updateUser = useUpdateUser();
510 | * updateUser({ optInHuddles: "true" }).then(() => console.log("Flags updated"));
511 | * ```
512 | */
513 | export function useUpdateUser() {
514 | const client = useClient();
515 | return (opts: { [key: string]: string | number | undefined }) =>
516 | client.updateUser(opts);
517 | }
518 |
519 | /**
520 | * Returns a function to update the current company's information.
521 | * For example, if the company changed plan or opted into a beta-feature.
522 | *
523 | * The method returned is a function which returns a promise that
524 | * resolves when after the features have been updated as a result
525 | * of the company update.
526 | *
527 | * ```ts
528 | * const updateCompany = useUpdateCompany();
529 | * updateCompany({ plan: "enterprise" }).then(() => console.log("Flags updated"));
530 | * ```
531 | */
532 | export function useUpdateCompany() {
533 | const client = useClient();
534 |
535 | return (opts: { [key: string]: string | number | undefined }) =>
536 | client.updateCompany(opts);
537 | }
538 |
539 | /**
540 | * Returns a function to update the "other" context information.
541 | * For example, if the user changed workspace, you can set the workspace id here.
542 | *
543 | * The method returned is a function which returns a promise that
544 | * resolves when after the features have been updated as a result
545 | * of the update to the "other" context.
546 | *
547 | * ```ts
548 | * const updateOtherContext = useUpdateOtherContext();
549 | * updateOtherContext({ workspaceId: newWorkspaceId })
550 | * .then(() => console.log("Flags updated"));
551 | * ```
552 | */
553 | export function useUpdateOtherContext() {
554 | const client = useClient();
555 | return (opts: { [key: string]: string | number | undefined }) =>
556 | client.updateOtherContext(opts);
557 | }
558 |
559 | /**
560 | * Returns the current `ReflagProvider` context.
561 | * @internal
562 | */
563 | function useSafeContext() {
564 | const ctx = useContext(ProviderContext);
565 | if (!ctx) {
566 | throw new Error(
567 | `ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`,
568 | );
569 | }
570 | return ctx;
571 | }
572 |
573 | /**
574 | * Returns a boolean indicating if the Reflag client is loading.
575 | * You can use this to check if the Reflag client is loading at any point in your application.
576 | * Initially, the value will be true until the client is initialized.
577 | *
578 | * @example
579 | * ```ts
580 | * import { useIsLoading } from '@reflag/react-sdk';
581 | *
582 | * const isLoading = useIsLoading();
583 | *
584 | * console.log(isLoading);
585 | * ```
586 | *
587 | * @returns A boolean indicating if the Reflag client is loading.
588 | */
589 | export function useIsLoading() {
590 | const context = useSafeContext();
591 | return context.isLoading;
592 | }
593 |
594 | /**
595 | * Returns the current `ReflagClient` used by the `ReflagProvider`.
596 | *
597 | * This is useful if you need to access the `ReflagClient` outside of the `ReflagProvider`.
598 | *
599 | * @example
600 | * ```ts
601 | * import { useClient } from '@reflag/react-sdk';
602 | *
603 | * function App() {
604 | * const client = useClient();
605 | * console.log(client.getContext());
606 | * }
607 | * ```
608 | *
609 | * @returns The `ReflagClient`.
610 | */
611 | export function useClient() {
612 | const context = useSafeContext();
613 | return context.client;
614 | }
615 |
616 | /**
617 | * Attach a callback handler to client events to act on changes. It automatically disposes itself on unmount.
618 | *
619 | * @example
620 | * ```ts
621 | * import { useOnEvent } from '@reflag/react-sdk';
622 | *
623 | * useOnEvent("flagsUpdated", () => {
624 | * console.log("flags updated");
625 | * });
626 | * ```
627 | *
628 | * @param event - The event to listen to.
629 | * @param handler - The function to call when the event is triggered.
630 | * @param client - The Reflag client to listen to. If not provided, the client will be retrieved from the context.
631 | */
632 | export function useOnEvent<THookType extends keyof HookArgs>(
633 | event: THookType,
634 | handler: (arg0: HookArgs[THookType]) => void,
635 | client?: ReflagClient,
636 | ) {
637 | const contextClient = useContext(ProviderContext);
638 | const resolvedClient = client ?? contextClient?.client;
639 | if (!resolvedClient) {
640 | throw new Error(
641 | `ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.`,
642 | );
643 | }
644 | useEffect(() => {
645 | return resolvedClient.on(event, handler);
646 | }, [resolvedClient, event, handler]);
647 | }
648 |
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /* eslint-disable @typescript-eslint/no-empty-object-type */
2 |
3 | import { newEvaluator, RuleFilter } from "@reflag/flag-evaluation";
4 |
5 | /**
6 | * Describes the meta context associated with tracking.
7 | **/
8 | export type TrackingMeta = {
9 | /**
10 | * Whether the user or company is active.
11 | **/
12 | active?: boolean;
13 | };
14 |
15 | /**
16 | * Describes the attributes of a user, company or event.
17 | **/
18 | export type Attributes = Record<string, any>;
19 |
20 | /**
21 | * Describes a flag event. Can be "check" or "check-config event".
22 | **/
23 | export type FlagEvent = {
24 | /**
25 | * The action that was performed.
26 | **/
27 | action: "check" | "check-config";
28 |
29 | /**
30 | * The flag key.
31 | **/
32 | key: string;
33 |
34 | /**
35 | * The feature targeting version (optional).
36 | **/
37 | targetingVersion: number | undefined;
38 |
39 | /**
40 | * The result of targeting evaluation.
41 | **/
42 | evalResult:
43 | | boolean
44 | | { key: string; payload: any }
45 | | { key: undefined; payload: undefined };
46 |
47 | /**
48 | * The context that was used for evaluation.
49 | **/
50 | evalContext?: Record<string, any>;
51 |
52 | /**
53 | * The result of evaluation of each rule (optional).
54 | **/
55 | evalRuleResults?: boolean[];
56 |
57 | /**
58 | * The missing fields in the evaluation context (optional).
59 | **/
60 | evalMissingFields?: string[];
61 | };
62 |
63 | /**
64 | * A remotely managed configuration value for a feature.
65 | */
66 | export type RawFlagRemoteConfig = {
67 | /**
68 | * The key of the matched configuration value.
69 | */
70 | key: string;
71 |
72 | /**
73 | * The version of the targeting rules used to select the config value.
74 | */
75 | targetingVersion?: number;
76 |
77 | /**
78 | * The optional user-supplied payload data.
79 | */
80 | payload: any;
81 |
82 | /**
83 | * The rule results of the evaluation (optional).
84 | */
85 | ruleEvaluationResults?: boolean[];
86 |
87 | /**
88 | * The missing fields in the evaluation context (optional).
89 | */
90 | missingContextFields?: string[];
91 | };
92 |
93 | /**
94 | * Describes a feature.
95 | */
96 | export interface RawFlag {
97 | /**
98 | * The key of the feature.
99 | */
100 | key: string;
101 |
102 | /**
103 | * If the feature is enabled.
104 | */
105 | isEnabled: boolean;
106 |
107 | /**
108 | * The version of the targeting used to evaluate if the feature is enabled (optional).
109 | */
110 | targetingVersion?: number;
111 |
112 | /**
113 | * The remote configuration value for the feature.
114 | */
115 | config?: RawFlagRemoteConfig;
116 |
117 | /**
118 | * The rule results of the evaluation (optional).
119 | */
120 | ruleEvaluationResults?: boolean[];
121 |
122 | /**
123 | * The missing fields in the evaluation context (optional).
124 | */
125 | missingContextFields?: string[];
126 | }
127 |
128 | /**
129 | * Describes a collection of evaluated raw flags.
130 | */
131 | export type RawFlags = Record<TypedFlagKey, RawFlag>;
132 |
133 | /**
134 | * Describes a collection of evaluated raw flags and the context for bootstrapping.
135 | */
136 | export type BootstrappedFlags = {
137 | context: Context;
138 | flags: RawFlags;
139 | };
140 |
141 | export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
142 |
143 | /**
144 | * A remotely managed configuration value for a feature.
145 | */
146 | export type FlagRemoteConfig =
147 | | {
148 | /**
149 | * The key of the matched configuration value.
150 | */
151 | key: string;
152 |
153 | /**
154 | * The optional user-supplied payload data.
155 | */
156 | payload: any;
157 | }
158 | | EmptyFlagRemoteConfig;
159 |
160 | /**
161 | * Describes a feature
162 | */
163 | export interface Flag<
164 | TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
165 | > {
166 | /**
167 | * The key of the feature.
168 | */
169 | key: string;
170 |
171 | /**
172 | * If the feature is enabled.
173 | */
174 | isEnabled: boolean;
175 |
176 | /*
177 | * Optional user-defined configuration.
178 | */
179 | config:
180 | | ({
181 | key: string;
182 | } & TConfig)
183 | | EmptyFlagRemoteConfig;
184 |
185 | /**
186 | * Track feature usage in Reflag.
187 | */
188 | track(): Promise<void>;
189 | }
190 |
191 | export type FlagType = {
192 | config?: {
193 | payload: any;
194 | };
195 | };
196 |
197 | export type FlagOverride =
198 | | (FlagType & {
199 | isEnabled: boolean;
200 | config?: {
201 | key: string;
202 | };
203 | })
204 | | boolean;
205 |
206 | /**
207 | * Describes a feature definition.
208 | */
209 | export type FlagDefinition = {
210 | /**
211 | * The key of the feature.
212 | */
213 | key: string;
214 |
215 | /**
216 | * Description of the feature.
217 | */
218 | description: string | null;
219 |
220 | /**
221 | * The targeting rules for the feature.
222 | */
223 | flag: {
224 | /**
225 | * The version of the targeting rules.
226 | */
227 | version: number;
228 |
229 | /**
230 | * The targeting rules.
231 | */
232 | rules: {
233 | /**
234 | * The filter for the rule.
235 | */
236 | filter: RuleFilter;
237 | }[];
238 | };
239 |
240 | /**
241 | * The remote configuration for the feature.
242 | */
243 | config?: {
244 | /**
245 | * The version of the remote configuration.
246 | */
247 | version: number;
248 |
249 | /**
250 | * The variants of the remote configuration.
251 | */
252 | variants: FlagConfigVariant[];
253 | };
254 | };
255 |
256 | /**
257 | * Describes a collection of evaluated features.
258 | *
259 | * @remarks
260 | * You should extend the Flags interface to define the available features.
261 | */
262 | export interface Flags {}
263 |
264 | /**
265 | * Describes a collection of evaluated feature.
266 | *
267 | * @remarks
268 | * This types falls back to a generic Record<string, Flag> if the Flags interface
269 | * has not been extended.
270 | *
271 | */
272 | export type TypedFlags = keyof Flags extends never
273 | ? Record<string, Flag>
274 | : {
275 | [FlagKey in keyof Flags]: Flags[FlagKey] extends FlagType
276 | ? Flag<Flags[FlagKey]["config"]>
277 | : Flag;
278 | };
279 |
280 | export type TypedFlagKey = keyof TypedFlags;
281 |
282 | /**
283 | * Describes the feature overrides.
284 | */
285 | export type FlagOverrides = Partial<
286 | keyof Flags extends never
287 | ? Record<string, FlagOverride>
288 | : {
289 | [FlagKey in keyof Flags]: Flags[FlagKey] extends FlagOverride
290 | ? Flags[FlagKey]
291 | : Exclude<FlagOverride, "config">;
292 | }
293 | >;
294 |
295 | export type FlagOverridesFn = (context: Context) => FlagOverrides;
296 |
297 | /**
298 | * Describes a remote feature config variant.
299 | */
300 | export type FlagConfigVariant = {
301 | /**
302 | * The filter for the variant.
303 | */
304 | filter: RuleFilter;
305 |
306 | /**
307 | * The optional user-supplied payload data.
308 | */
309 | payload: any;
310 |
311 | /**
312 | * The key of the variant.
313 | */
314 | key: string;
315 | };
316 |
317 | /**
318 | * (Internal) Describes a specific feature in the API response.
319 | *
320 | * @internal
321 | */
322 | export type FlagAPIResponse = {
323 | /**
324 | * The key of the feature.
325 | */
326 | key: string;
327 |
328 | /**
329 | * Description of the feature.
330 | */
331 | description: string | null;
332 |
333 | /**
334 | * The targeting rules for the feature.
335 | */
336 | targeting: {
337 | /**
338 | * The version of the targeting rules.
339 | */
340 | version: number;
341 |
342 | /**
343 | * The targeting rules.
344 | */
345 | rules: {
346 | /**
347 | * The filter for the rule.
348 | */
349 | filter: RuleFilter;
350 | }[];
351 | };
352 |
353 | /**
354 | * The remote configuration for the feature.
355 | */
356 | config?: {
357 | /**
358 | * The version of the remote configuration.
359 | */
360 | version: number;
361 |
362 | /**
363 | * The variants of the remote configuration.
364 | */
365 | variants: FlagConfigVariant[];
366 | };
367 | };
368 |
369 | /**
370 | * (Internal) Describes the response of the features endpoint.
371 | *
372 | * @internal
373 | */
374 | export type FlagsAPIResponse = {
375 | /**
376 | * The feature definitions.
377 | */
378 | features: FlagAPIResponse[];
379 | };
380 |
381 | /**
382 | * (Internal) Flag definitions with the addition of a pre-prepared
383 | * evaluators functions for the rules.
384 | *
385 | * @internal
386 | */
387 | export type CachedFlagDefinition = FlagAPIResponse & {
388 | enabledEvaluator: ReturnType<typeof newEvaluator<boolean>>;
389 | configEvaluator: ReturnType<typeof newEvaluator<any>> | undefined;
390 | };
391 |
392 | /**
393 | * (Internal) Describes the response of the evaluated features endpoint.
394 | *
395 | * @internal
396 | */
397 | export type EvaluatedFlagsAPIResponse = {
398 | /**
399 | * True if request successful.
400 | */
401 | success: true;
402 |
403 | /**
404 | * True if additional context for user or company was found and used for evaluation on the remote server.
405 | */
406 | remoteContextUsed: boolean;
407 |
408 | /**
409 | * The feature definitions.
410 | */
411 | features: RawFlags;
412 | };
413 |
414 | /**
415 | * Describes the response of a HTTP client.
416 | *
417 | * @typeParam TResponse - The type of the response body.
418 | */
419 | export type HttpClientResponse<TResponse> = {
420 | /**
421 | * The status code of the response.
422 | **/
423 | status: number;
424 |
425 | /**
426 | * Indicates that the request succeeded.
427 | **/
428 | ok: boolean;
429 |
430 | /**
431 | * The body of the response if available.
432 | **/
433 | body: TResponse | undefined;
434 | };
435 |
436 | /**
437 | * Defines the interface for an HTTP client.
438 | *
439 | * @remarks
440 | * This interface is used to abstract the HTTP client implementation from the SDK.
441 | * Define your own implementation of this interface to use a different HTTP client.
442 | **/
443 | export interface HttpClient {
444 | /**
445 | * Sends a POST request to the specified URL.
446 | *
447 | * @param url - The URL to send the request to.
448 | * @param headers - The headers to include in the request.
449 | * @param body - The body of the request.
450 | * @returns The response from the server.
451 | **/
452 | post<TBody, TResponse>(
453 | url: string,
454 | headers: Record<string, string>,
455 | body: TBody,
456 | ): Promise<HttpClientResponse<TResponse>>;
457 |
458 | /**
459 | * Sends a GET request to the specified URL.
460 | *
461 | * @param url - The URL to send the request to.
462 | * @param headers - The headers to include in the request.
463 | * @returns The response from the server.
464 | **/
465 | get<TResponse>(
466 | url: string,
467 | headers: Record<string, string>,
468 | timeoutMs: number,
469 | ): Promise<HttpClientResponse<TResponse>>;
470 | }
471 |
472 | /**
473 | * Logger interface for logging messages
474 | */
475 | export interface Logger {
476 | /**
477 | * Log a debug messages
478 | *
479 | * @param message - The message to log
480 | * @param data - Optional data to log
481 | */
482 | debug: (message: string, data?: any) => void;
483 |
484 | /**
485 | * Log an info messages
486 | *
487 | * @param message - The message to log
488 | * @param data - Optional data to log
489 | */
490 | info: (message: string, data?: any) => void;
491 |
492 | /**
493 | * Log a warning messages
494 | *
495 | * @param message - The message to log
496 | * @param data - Optional data to log
497 | */
498 | warn: (message: string, data?: any) => void;
499 |
500 | /**
501 | * Log an error messages
502 | *
503 | * @param message - The message to log
504 | * @param data - Optional data to log
505 | */
506 | error: (message: string, data?: any) => void;
507 | }
508 |
509 | /**
510 | * A cache for storing values.
511 | *
512 | * @typeParam T - The type of the value.
513 | **/
514 | export type Cache<T> = {
515 | /**
516 | * Get the value.
517 | * @returns The value or `undefined` if the value is not available.
518 | **/
519 | get: () => T | undefined;
520 |
521 | /**
522 | * Refresh the value immediately and return it, or `undefined` if the value is not available.
523 | *
524 | * @returns The value or `undefined` if the value is not available.
525 | **/
526 | refresh: () => Promise<T | undefined>;
527 |
528 | /**
529 | * If a refresh is in progress, wait for it to complete.
530 | *
531 | * @returns A promise that resolves when the refresh is complete.
532 | **/
533 | waitRefresh: () => Promise<void> | undefined;
534 |
535 | /**
536 | * Cleanup and destroy the cache, stopping any background processes.
537 | **/
538 | destroy: () => void;
539 | };
540 |
541 | /**
542 | * Options for configuring the BatchBuffer.
543 | *
544 | * @template T - The type of items in the buffer.
545 | */
546 | export type BatchBufferOptions<T> = {
547 | /**
548 | * A function that handles flushing the items in the buffer.
549 | **/
550 | flushHandler: (items: T[]) => Promise<void>;
551 |
552 | /**
553 | * The logger to use for logging (optional).
554 | **/
555 | logger?: Logger;
556 |
557 | /**
558 | * The maximum size of the buffer before it is flushed.
559 | *
560 | * @defaultValue `100`
561 | **/
562 | maxSize?: number;
563 |
564 | /**
565 | * The interval in milliseconds at which the buffer is flushed.
566 | * @remarks
567 | * If `0`, the buffer is flushed only when `maxSize` is reached.
568 | * @defaultValue `1000`
569 | **/
570 | intervalMs?: number;
571 |
572 | /**
573 | * Whether to flush the buffer on exit.
574 | *
575 | * @defaultValue `true`
576 | */
577 | flushOnExit?: boolean;
578 | };
579 |
580 | export type CacheStrategy = "periodically-update" | "in-request";
581 |
582 | /**
583 | * Defines the options for the SDK client.
584 | *
585 | **/
586 | export type ClientOptions = {
587 | /**
588 | * The secret key used to authenticate with the Reflag API.
589 | **/
590 | secretKey?: string;
591 |
592 | /**
593 | * @deprecated
594 | * Use `apiBaseUrl` instead.
595 | **/
596 | host?: string;
597 |
598 | /**
599 | * The host to send requests to (optional).
600 | **/
601 | apiBaseUrl?: string;
602 |
603 | /**
604 | * The logger to use for logging (optional). Default is info level logging to console.
605 | **/
606 | logger?: Logger;
607 |
608 | /**
609 | * Use the console logger, but set a log level. Ineffective if a custom logger is provided.
610 | **/
611 | logLevel?: LogLevel;
612 |
613 | /**
614 | * The features to "enable" as fallbacks when the API is unavailable (optional).
615 | * Can be an array of feature keys, or a record of feature keys and boolean or object values.
616 | *
617 | * If a record is supplied instead of array, the values of each key are either the
618 | * configuration values or the boolean value `true`.
619 | **/
620 | fallbackFlags?:
621 | | TypedFlagKey[]
622 | | Record<TypedFlagKey, Exclude<FlagOverride, false>>;
623 |
624 | /**
625 | * The HTTP client to use for sending requests (optional). Default is the built-in fetch client.
626 | **/
627 | httpClient?: HttpClient;
628 |
629 | /**
630 | * The timeout in milliseconds for fetching feature targeting data (optional).
631 | * Default is 10000 ms.
632 | **/
633 | fetchTimeoutMs?: number;
634 |
635 | /**
636 | * Number of times to retry fetching feature definitions (optional).
637 | * Default is 3 times.
638 | **/
639 | flagsFetchRetries?: number;
640 |
641 | /**
642 | * The options for the batch buffer (optional).
643 | * If not provided, the default options are used.
644 | **/
645 | batchOptions?: Omit<BatchBufferOptions<any>, "flushHandler" | "logger">;
646 |
647 | /**
648 | * If a filename is specified, feature targeting results be overridden with
649 | * the values from this file. The file should be a JSON object with flag
650 | * keys as keys, and boolean or object as values.
651 | *
652 | * If a function is specified, the function will be called with the context
653 | * and should return a record of flag keys and boolean or object values.
654 | *
655 | * Defaults to "reflagFlags.json".
656 | **/
657 | flagOverrides?: string | ((context: Context) => FlagOverrides);
658 |
659 | /**
660 | * In offline mode, no data is sent or fetched from the the Reflag API.
661 | * This is useful for testing or development.
662 | */
663 | offline?: boolean;
664 |
665 | /**
666 | * If set to `false`, no evaluation events will be emitted.
667 | */
668 | emitEvaluationEvents?: boolean;
669 |
670 | /**
671 | * The path to the config file. If supplied, the config file will be loaded.
672 | * Defaults to `reflag.config.json` when NODE_ENV is not production. Can also be
673 | * set through the environment variable REFLAG_CONFIG_FILE.
674 | */
675 | configFile?: string;
676 |
677 | /**
678 | * The cache strategy to use for the client (optional, defaults to "periodically-update").
679 | **/
680 | cacheStrategy?: CacheStrategy;
681 | };
682 |
683 | /**
684 | * Defines the options for tracking of entities.
685 | *
686 | **/
687 | export type TrackOptions = {
688 | /**
689 | * The attributes associated with the event.
690 | **/
691 | attributes?: Attributes;
692 |
693 | /**
694 | * The meta context associated with the event.
695 | **/
696 | meta?: TrackingMeta;
697 | };
698 |
699 | /**
700 | * Describes the current user context, company context, and other context.
701 | * This is used to determine if feature targeting matches and to track events.
702 | **/
703 | export type Context = {
704 | /**
705 | * The user context. If no `id` key is set, the whole object is ignored.
706 | */
707 | user?: {
708 | /**
709 | * The identifier of the user.
710 | */
711 | id: string | number | undefined;
712 |
713 | /**
714 | * The name of the user.
715 | */
716 | name?: string | undefined;
717 |
718 | /**
719 | * The email of the user.
720 | */
721 | email?: string | undefined;
722 |
723 | /**
724 | * The avatar URL of the user.
725 | */
726 | avatar?: string | undefined;
727 |
728 | /**
729 | * Custom attributes of the user.
730 | */
731 | [k: string]: any;
732 | };
733 | /**
734 | * The company context. If no `id` key is set, the whole object is ignored.
735 | */
736 | company?: {
737 | /**
738 | * The identifier of the company.
739 | */
740 | id: string | number | undefined;
741 |
742 | /**
743 | * The name of the company.
744 | */
745 | name?: string | undefined;
746 |
747 | /**
748 | * The avatar URL of the company.
749 | */
750 | avatar?: string | undefined;
751 |
752 | /**
753 | * Custom attributes of the company.
754 | */
755 | [k: string]: any;
756 | };
757 |
758 | /**
759 | * The other context. This is used for any additional context that is not related to user or company.
760 | */
761 | other?: Record<string, any>;
762 | };
763 |
764 | /**
765 | * A context with tracking option.
766 | **/
767 | export interface ContextWithTracking extends Context {
768 | /**
769 | * Enable tracking for the context.
770 | * If set to `false`, tracking will be disabled for the context. Default is `true`.
771 | */
772 | enableTracking?: boolean;
773 |
774 | /**
775 | * The meta context used to update the user or company when syncing is required during
776 | * feature retrieval.
777 | */
778 | meta?: TrackingMeta;
779 | }
780 |
781 | export const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
782 | export type LogLevel = (typeof LOG_LEVELS)[number];
783 |
784 | export type IdType = string | number;
785 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/usage.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { http, HttpResponse } from "msw";
2 | import {
3 | afterEach,
4 | beforeAll,
5 | beforeEach,
6 | describe,
7 | expect,
8 | it,
9 | test,
10 | vi,
11 | } from "vitest";
12 |
13 | import { ReflagClient } from "../src";
14 | import { API_BASE_URL } from "../src/config";
15 | import { FeedbackPromptHandler } from "../src/feedback/feedback";
16 | import {
17 | checkPromptMessageCompleted,
18 | getAuthToken,
19 | markPromptMessageCompleted,
20 | } from "../src/feedback/promptStorage";
21 | import { FlagsClient } from "../src/flag/flags";
22 | import { HttpClient } from "../src/httpClient";
23 | import {
24 | AblySSEChannel,
25 | closeAblySSEChannel,
26 | openAblySSEChannel,
27 | } from "../src/sse";
28 |
29 | import { flagsResult } from "./mocks/handlers";
30 | import { server } from "./mocks/server";
31 |
32 | const KEY = "123";
33 |
34 | vi.mock("../src/sse");
35 | vi.mock("../src/feedback/promptStorage", () => {
36 | return {
37 | markPromptMessageCompleted: vi.fn(),
38 | checkPromptMessageCompleted: vi.fn(),
39 | rememberAuthToken: vi.fn(),
40 | getAuthToken: vi.fn(),
41 | };
42 | });
43 |
44 | // Treat test environment as desktop
45 | window.innerWidth = 1024;
46 |
47 | afterEach(() => {
48 | server.resetHandlers();
49 | });
50 |
51 | describe("usage", () => {
52 | afterEach(() => {
53 | vi.clearAllMocks();
54 | });
55 |
56 | test("golden path - register `user`, `company`, send `event`, send `feedback`, get `flags`", async () => {
57 | const reflagInstance = new ReflagClient({
58 | publishableKey: KEY,
59 | user: { id: "foo " },
60 | company: { id: "bar", name: "bar corp" },
61 | });
62 | await reflagInstance.initialize();
63 |
64 | await reflagInstance.track("baz", { baz: true });
65 |
66 | await reflagInstance.feedback({
67 | flagKey: "huddles",
68 | score: 5,
69 | comment: "Sunt bine!",
70 | question: "Cum esti?",
71 | promptedQuestion: "How are you?",
72 | });
73 |
74 | const flags = reflagInstance.getFlags();
75 | expect(flags).toEqual(flagsResult);
76 |
77 | const flag = reflagInstance.getFlag("flag-1");
78 | expect(flag).toStrictEqual({
79 | isEnabled: false,
80 | track: expect.any(Function),
81 | requestFeedback: expect.any(Function),
82 | config: { key: undefined, payload: undefined },
83 | isEnabledOverride: null,
84 | setIsEnabledOverride: expect.any(Function),
85 | });
86 | });
87 |
88 | test("accepts `flagKey` instead of `featureId` for manual feedback", async () => {
89 | const reflagInstance = new ReflagClient({
90 | publishableKey: KEY,
91 | user: { id: "foo" },
92 | company: { id: "bar" },
93 | });
94 |
95 | await reflagInstance.initialize();
96 |
97 | await reflagInstance.feedback({
98 | flagKey: "flag-key",
99 | score: 5,
100 | question: "What's up?",
101 | promptedQuestion: "How are you?",
102 | });
103 | });
104 | });
105 |
106 | // TODO:
107 | // Since we now have AutoFeedback as it's own class, we should rewrite these tests
108 | // to test that class instead of the ReflagClient class.
109 | // Same for feedback state management below
110 |
111 | describe("feedback prompting", () => {
112 | const closeChannel = vi.fn();
113 | beforeAll(() => {
114 | vi.mocked(openAblySSEChannel).mockReturnValue({
115 | close: closeChannel,
116 | } as unknown as AblySSEChannel);
117 | vi.mocked(closeAblySSEChannel).mockResolvedValue(undefined);
118 | });
119 |
120 | afterEach(() => {
121 | vi.clearAllMocks();
122 | vi.mocked(getAuthToken).mockReturnValue(undefined);
123 | });
124 |
125 | test("initiates and stops feedback prompting", async () => {
126 | const reflagInstance = new ReflagClient({
127 | publishableKey: KEY,
128 | user: { id: "foo" },
129 | });
130 | await reflagInstance.initialize();
131 |
132 | expect(openAblySSEChannel).toBeCalledTimes(1);
133 |
134 | // call twice, expect only one reset to go through
135 | await reflagInstance.stop();
136 | await reflagInstance.stop();
137 |
138 | expect(closeChannel).toBeCalledTimes(1);
139 | });
140 |
141 | test("does not call tracking endpoints if token cached", async () => {
142 | const specialChannel = "special-channel";
143 | vi.mocked(getAuthToken).mockReturnValue({
144 | channel: specialChannel,
145 | token: "something",
146 | });
147 |
148 | server.use(
149 | http.post(`${API_BASE_URL}/feedback/prompting-init`, () => {
150 | throw new Error("should not be called");
151 | }),
152 | );
153 |
154 | const reflagInstance = new ReflagClient({
155 | publishableKey: KEY,
156 | user: { id: "foo" },
157 | });
158 | await reflagInstance.initialize();
159 |
160 | expect(openAblySSEChannel).toBeCalledTimes(1);
161 | const args = vi.mocked(openAblySSEChannel).mock.calls[0][0];
162 | expect(args.channel).toBe(specialChannel);
163 | expect(args.userId).toBe("foo");
164 | });
165 |
166 | test("does not initiate feedback prompting if server does not agree", async () => {
167 | server.use(
168 | http.post(`${API_BASE_URL}/feedback/prompting-init`, () => {
169 | return HttpResponse.json({ success: false });
170 | }),
171 | );
172 |
173 | const reflagInstance = new ReflagClient({
174 | publishableKey: KEY,
175 | user: { id: "foo" },
176 | });
177 | await reflagInstance.initialize();
178 |
179 | expect(openAblySSEChannel).toBeCalledTimes(0);
180 | });
181 |
182 | test("skip feedback prompting if no user id configured", async () => {
183 | const reflagInstance = new ReflagClient({ publishableKey: KEY });
184 | await reflagInstance.initialize();
185 |
186 | expect(openAblySSEChannel).toBeCalledTimes(0);
187 | });
188 |
189 | test("skip feedback prompting if automated feedback surveys are disabled", async () => {
190 | const reflagInstance = new ReflagClient({
191 | publishableKey: KEY,
192 | user: { id: "foo" },
193 | feedback: { enableAutoFeedback: false },
194 | });
195 | await reflagInstance.initialize();
196 |
197 | expect(openAblySSEChannel).toBeCalledTimes(0);
198 | });
199 | });
200 |
201 | describe("feedback state management", () => {
202 | const message = {
203 | question: "How are you?",
204 | showAfter: new Date(Date.now() - 10000).valueOf(),
205 | showBefore: new Date(Date.now() + 10000).valueOf(),
206 | promptId: "123",
207 | featureId: "456",
208 | };
209 |
210 | let events: string[] = [];
211 | let reflagInstance: ReflagClient | null = null;
212 | beforeEach(() => {
213 | vi.mocked(openAblySSEChannel).mockImplementation(({ callback }) => {
214 | callback(message);
215 | // eslint-disable-next-line @typescript-eslint/no-empty-function
216 | return { close: () => {} } as AblySSEChannel;
217 | });
218 | events = [];
219 | server.use(
220 | http.post(
221 | `${API_BASE_URL}/feedback/prompt-events`,
222 | async ({ request }) => {
223 | const body = await request.json();
224 | if (!(body && typeof body === "object" && "action" in body)) {
225 | throw new Error("invalid request");
226 | }
227 | events.push(String(body["action"]));
228 | return HttpResponse.json({ success: true });
229 | },
230 | ),
231 | );
232 | });
233 |
234 | afterEach(async () => {
235 | if (reflagInstance) await reflagInstance.stop();
236 |
237 | vi.resetAllMocks();
238 | });
239 |
240 | const createReflagInstance = async (callback: FeedbackPromptHandler) => {
241 | reflagInstance = new ReflagClient({
242 | publishableKey: KEY,
243 | user: { id: "foo" },
244 | feedback: {
245 | autoFeedbackHandler: callback,
246 | },
247 | });
248 | await reflagInstance.initialize();
249 | return reflagInstance;
250 | };
251 |
252 | test("ignores prompt if expired", async () => {
253 | vi.useFakeTimers();
254 | vi.setSystemTime(message.showAfter - 10000);
255 |
256 | const callback = vi.fn();
257 |
258 | await createReflagInstance(callback);
259 |
260 | expect(callback).not.toHaveBeenCalled();
261 |
262 | expect(markPromptMessageCompleted).not.toHaveBeenCalledOnce();
263 |
264 | vi.clearAllTimers();
265 | vi.useRealTimers();
266 | });
267 |
268 | test("ignores prompt if already seen", async () => {
269 | vi.mocked(checkPromptMessageCompleted).mockReturnValue(true);
270 | expect(checkPromptMessageCompleted).not.toHaveBeenCalled();
271 |
272 | const callback = vi.fn();
273 |
274 | await createReflagInstance(callback);
275 |
276 | expect(callback).not.toHaveBeenCalled();
277 | await vi.waitFor(() =>
278 | expect(checkPromptMessageCompleted).toHaveBeenCalledOnce(),
279 | );
280 |
281 | expect(checkPromptMessageCompleted).toHaveBeenCalledWith("foo", "123");
282 | });
283 |
284 | test("propagates prompt to the callback", async () => {
285 | const callback = vi.fn();
286 |
287 | await createReflagInstance(callback);
288 | await vi.waitUntil(() => callback.mock.calls.length > 0);
289 |
290 | await vi.waitUntil(() => events.length > 1);
291 |
292 | expect(events).toEqual(["received", "shown"]);
293 |
294 | expect(callback).toBeCalledTimes(1);
295 | expect(callback).toBeCalledWith(
296 | {
297 | question: "How are you?",
298 | showAfter: new Date(message.showAfter),
299 | showBefore: new Date(message.showBefore),
300 | promptId: "123",
301 | featureId: "456",
302 | },
303 | expect.anything(),
304 | );
305 |
306 | expect(markPromptMessageCompleted).not.toHaveBeenCalled();
307 | });
308 |
309 | test("propagates timed prompt to the callback", async () => {
310 | const callback = vi.fn();
311 |
312 | vi.useFakeTimers();
313 | vi.setSystemTime(message.showAfter - 500);
314 |
315 | await createReflagInstance(callback);
316 |
317 | expect(callback).not.toBeCalled();
318 |
319 | vi.runAllTimers();
320 | await vi.waitUntil(() => callback.mock.calls.length > 0);
321 |
322 | await vi.waitUntil(() => events.length > 1);
323 |
324 | expect(events).toEqual(["received", "shown"]);
325 |
326 | expect(callback).toBeCalledTimes(1);
327 |
328 | expect(markPromptMessageCompleted).not.toHaveBeenCalled();
329 |
330 | vi.clearAllTimers();
331 | vi.useRealTimers();
332 | });
333 |
334 | test("propagates prompt to the callback and reacts to dismissal", async () => {
335 | const callback: FeedbackPromptHandler = async (_, handlers) => {
336 | await handlers.reply(null);
337 | };
338 |
339 | await createReflagInstance(callback);
340 |
341 | await vi.waitUntil(() => events.length > 2);
342 |
343 | expect(events).toEqual(["received", "shown", "dismissed"]);
344 |
345 | expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
346 | expect(markPromptMessageCompleted).toHaveBeenCalledWith(
347 | "foo",
348 | "123",
349 | new Date(message.showBefore),
350 | );
351 | });
352 |
353 | test("propagates prompt to the callback and reacts to feedback", async () => {
354 | const callback: FeedbackPromptHandler = async (_, handlers) => {
355 | await handlers.reply({
356 | companyId: "bar",
357 | score: 5,
358 | comment: "hello",
359 | question: "Cum esti?",
360 | });
361 | };
362 |
363 | await createReflagInstance(callback);
364 |
365 | await vi.waitUntil(() => events.length > 1);
366 |
367 | expect(events).toEqual(["received", "shown"]);
368 | expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
369 | expect(markPromptMessageCompleted).toHaveBeenCalledWith(
370 | "foo",
371 | "123",
372 | new Date(message.showBefore),
373 | );
374 | });
375 | });
376 |
377 | describe(`sends "check" events `, () => {
378 | test("getFlags() does not send `check` events", async () => {
379 | vi.spyOn(FlagsClient.prototype, "sendCheckEvent");
380 |
381 | const client = new ReflagClient({
382 | publishableKey: KEY,
383 | user: { id: "123" },
384 | });
385 | await client.initialize();
386 |
387 | expect(
388 | vi.mocked(FlagsClient.prototype.sendCheckEvent),
389 | ).toHaveBeenCalledTimes(0);
390 |
391 | const flagA = client.getFlags()?.flagA;
392 |
393 | expect(flagA?.isEnabled).toBe(true);
394 | expect(
395 | vi.mocked(FlagsClient.prototype.sendCheckEvent),
396 | ).toHaveBeenCalledTimes(0);
397 | });
398 |
399 | describe("getFlag", async () => {
400 | afterEach(() => {
401 | vi.clearAllMocks();
402 | });
403 |
404 | it(`returns get the expected flag details`, async () => {
405 | const client = new ReflagClient({
406 | publishableKey: KEY,
407 | user: { id: "uid" },
408 | company: { id: "cid" },
409 | });
410 |
411 | await client.initialize();
412 |
413 | expect(client.getFlag("flagA")).toStrictEqual({
414 | isEnabled: true,
415 | config: { key: undefined, payload: undefined },
416 | track: expect.any(Function),
417 | requestFeedback: expect.any(Function),
418 | isEnabledOverride: null,
419 | setIsEnabledOverride: expect.any(Function),
420 | });
421 |
422 | expect(client.getFlag("flagB")).toStrictEqual({
423 | isEnabled: true,
424 | config: {
425 | key: "gpt3",
426 | payload: {
427 | model: "gpt-something",
428 | temperature: 0.5,
429 | },
430 | },
431 | track: expect.any(Function),
432 | requestFeedback: expect.any(Function),
433 | isEnabledOverride: null,
434 | setIsEnabledOverride: expect.any(Function),
435 | });
436 |
437 | expect(client.getFlag("flagC")).toStrictEqual({
438 | isEnabled: false,
439 | config: { key: undefined, payload: undefined },
440 | track: expect.any(Function),
441 | requestFeedback: expect.any(Function),
442 | isEnabledOverride: null,
443 | setIsEnabledOverride: expect.any(Function),
444 | });
445 | });
446 |
447 | it(`does not send check events when offline`, async () => {
448 | const postSpy = vi.spyOn(HttpClient.prototype, "post");
449 |
450 | const client = new ReflagClient({
451 | publishableKey: KEY,
452 | user: { id: "uid" },
453 | company: { id: "cid" },
454 | offline: true,
455 | });
456 | await client.initialize();
457 |
458 | const flagA = client.getFlag("flagA");
459 | expect(flagA.isEnabled).toBe(false);
460 |
461 | expect(postSpy).not.toHaveBeenCalled();
462 | });
463 |
464 | it(`sends check event when accessing "isEnabled"`, async () => {
465 | const sendCheckEventSpy = vi.spyOn(
466 | FlagsClient.prototype,
467 | "sendCheckEvent",
468 | );
469 |
470 | const postSpy = vi.spyOn(HttpClient.prototype, "post");
471 |
472 | const client = new ReflagClient({
473 | publishableKey: KEY,
474 | user: { id: "uid" },
475 | company: { id: "cid" },
476 | });
477 | await client.initialize();
478 |
479 | const flagA = client.getFlag("flagA");
480 |
481 | expect(sendCheckEventSpy).toHaveBeenCalledTimes(0);
482 | expect(flagA.isEnabled).toBe(true);
483 |
484 | expect(sendCheckEventSpy).toHaveBeenCalledTimes(1);
485 | expect(sendCheckEventSpy).toHaveBeenCalledWith(
486 | {
487 | action: "check-is-enabled",
488 | key: "flagA",
489 | value: true,
490 | version: 1,
491 | missingContextFields: ["field1", "field2"],
492 | ruleEvaluationResults: [false, true],
493 | },
494 | expect.any(Function),
495 | );
496 |
497 | expect(postSpy).toHaveBeenCalledWith({
498 | body: {
499 | action: "check-is-enabled",
500 | evalContext: {
501 | company: {
502 | id: "cid",
503 | },
504 | other: {},
505 | user: {
506 | id: "uid",
507 | },
508 | },
509 | evalResult: true,
510 | evalRuleResults: [false, true],
511 | evalMissingFields: ["field1", "field2"],
512 | key: "flagA",
513 | targetingVersion: 1,
514 | },
515 | path: "features/events",
516 | });
517 | });
518 |
519 | it(`sends check event when accessing "config"`, async () => {
520 | const postSpy = vi.spyOn(HttpClient.prototype, "post");
521 |
522 | const client = new ReflagClient({
523 | publishableKey: KEY,
524 | user: { id: "uid" },
525 | });
526 |
527 | await client.initialize();
528 | const flagB = client.getFlag("flagB");
529 | expect(flagB.config).toMatchObject({
530 | key: "gpt3",
531 | });
532 |
533 | expect(postSpy).toHaveBeenCalledWith({
534 | body: {
535 | action: "check-config",
536 | evalContext: {
537 | company: undefined,
538 | other: {},
539 | user: {
540 | id: "uid",
541 | },
542 | },
543 | evalResult: {
544 | key: "gpt3",
545 | payload: { model: "gpt-something", temperature: 0.5 },
546 | },
547 | evalRuleResults: [true, false, false],
548 | evalMissingFields: ["field3"],
549 | key: "flagB",
550 | targetingVersion: 12,
551 | },
552 | path: "features/events",
553 | });
554 | });
555 |
556 | it("sends check event for not-enabled flags", async () => {
557 | // disabled flags don't appear in the API response
558 | vi.spyOn(FlagsClient.prototype, "sendCheckEvent");
559 |
560 | const client = new ReflagClient({ publishableKey: KEY });
561 | await client.initialize();
562 |
563 | const nonExistentFlag = client.getFlag("non-existent");
564 |
565 | expect(
566 | vi.mocked(FlagsClient.prototype.sendCheckEvent),
567 | ).toHaveBeenCalledTimes(0);
568 | expect(nonExistentFlag.isEnabled).toBe(false);
569 |
570 | expect(
571 | vi.mocked(FlagsClient.prototype.sendCheckEvent),
572 | ).toHaveBeenCalledTimes(1);
573 | expect(
574 | vi.mocked(FlagsClient.prototype.sendCheckEvent),
575 | ).toHaveBeenCalledWith(
576 | {
577 | action: "check-is-enabled",
578 | value: false,
579 | key: "non-existent",
580 | version: undefined,
581 | },
582 | expect.any(Function),
583 | );
584 | });
585 |
586 | it("calls client.track with the flagKey", async () => {
587 | const client = new ReflagClient({ publishableKey: KEY });
588 | await client.initialize();
589 |
590 | const flag = client.getFlag("flag-1");
591 | expect(flag).toStrictEqual({
592 | isEnabled: false,
593 | track: expect.any(Function),
594 | requestFeedback: expect.any(Function),
595 | config: { key: undefined, payload: undefined },
596 | isEnabledOverride: null,
597 | setIsEnabledOverride: expect.any(Function),
598 | });
599 |
600 | vi.spyOn(client, "track");
601 |
602 | await flag.track();
603 |
604 | expect(client.track).toHaveBeenCalledWith("flag-1");
605 | });
606 |
607 | it("calls client.requestFeedback with the flagKey", async () => {
608 | const client = new ReflagClient({ publishableKey: KEY });
609 | await client.initialize();
610 |
611 | const flag = client.getFlag("flag-1");
612 | expect(flag).toStrictEqual({
613 | isEnabled: false,
614 | track: expect.any(Function),
615 | requestFeedback: expect.any(Function),
616 | config: { key: undefined, payload: undefined },
617 | isEnabledOverride: null,
618 | setIsEnabledOverride: expect.any(Function),
619 | });
620 |
621 | vi.spyOn(client, "requestFeedback");
622 |
623 | flag.requestFeedback({
624 | title: "Feedback",
625 | });
626 |
627 | expect(client.requestFeedback).toHaveBeenCalledWith({
628 | flagKey: "flag-1",
629 | title: "Feedback",
630 | });
631 | });
632 | });
633 | });
634 |
```