From 75c35ccd706b80d148595cb552d538ab02a43cea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 10:40:09 +0000 Subject: [PATCH 01/24] chore(deps): bump nanoid from 3.3.7 to 3.3.8 in /packages/node-sdk/example (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
Changelog

Sourced from nanoid's changelog.

3.3.8

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nanoid&package-manager=npm_and_yarn&previous-version=3.3.7&new-version=3.3.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/bucketco/bucket-javascript-sdk/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/node-sdk/example/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/example/yarn.lock b/packages/node-sdk/example/yarn.lock index 1abedfe4..9832b00f 100644 --- a/packages/node-sdk/example/yarn.lock +++ b/packages/node-sdk/example/yarn.lock @@ -1762,11 +1762,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.7": - version: 3.3.7 - resolution: "nanoid@npm:3.3.7" + version: 3.3.8 + resolution: "nanoid@npm:3.3.8" bin: nanoid: bin/nanoid.cjs - checksum: 10c0/e3fb661aa083454f40500473bb69eedb85dc160e763150b9a2c567c7e9ff560ce028a9f833123b618a6ea742e311138b591910e795614a629029e86e180660f3 + checksum: 10c0/4b1bb29f6cfebf3be3bc4ad1f1296fb0a10a3043a79f34fbffe75d1621b4318319211cd420549459018ea3592f0d2f159247a6f874911d6d26eaaadda2478120 languageName: node linkType: hard From f7c3ea40c84a6534e0d5d7902d3f67cba58cafa4 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 20 Jan 2025 12:41:30 +0100 Subject: [PATCH 02/24] feat(browser-sdk): support overrides, propagate updates (#286) This is a piece of infrastructure needed for local overrides. We will likely need a few adjustments to this as we go about building the toolbar on top, but this is a good base. --- packages/browser-sdk/src/client.ts | 14 ++- .../browser-sdk/src/feature/featureCache.ts | 12 +- packages/browser-sdk/src/feature/features.ts | 117 +++++++++++++++--- packages/browser-sdk/test/client.test.ts | 12 ++ packages/browser-sdk/test/features.test.ts | 53 +++++++- packages/browser-sdk/test/mocks/handlers.ts | 5 +- 6 files changed, 185 insertions(+), 28 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 35024df4..9e16a076 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -498,6 +498,8 @@ export class BucketClient { /** * Returns a map of enabled features. * Accessing a feature will *not* send a check event + * and `isEnabled` does not take any feature overrides + * into account. * * @returns Map of features */ @@ -513,13 +515,13 @@ export class BucketClient { const f = this.getFeatures()[key]; const fClient = this.featuresClient; - const value = f?.isEnabled ?? false; + const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; return { get isEnabled() { fClient .sendCheckEvent({ - key: key, + key, version: f?.targetingVersion, value, }) @@ -540,6 +542,14 @@ export class BucketClient { }; } + setFeatureOverride(key: string, isEnabled: boolean | null) { + this.featuresClient.setFeatureOverride(key, isEnabled); + } + + getFeatureOverride(key: string): boolean | null { + return this.featuresClient.getFeatureOverride(key); + } + sendCheckEvent(checkEvent: CheckEvent) { return this.featuresClient.sendCheckEvent(checkEvent); } diff --git a/packages/browser-sdk/src/feature/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index 1a66c441..306aef97 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -1,4 +1,4 @@ -import { RawFeatures } from "./features"; +import { FetchedFeatures } from "./features"; interface StorageItem { get(): string | null; @@ -8,18 +8,18 @@ interface StorageItem { interface cacheEntry { expireAt: number; staleAt: number; - features: RawFeatures; + features: FetchedFeatures; } // Parse and validate an API feature response export function parseAPIFeaturesResponse( featuresInput: any, -): RawFeatures | undefined { +): FetchedFeatures | undefined { if (!isObject(featuresInput)) { return; } - const features: RawFeatures = {}; + const features: FetchedFeatures = {}; for (const key in featuresInput) { const feature = featuresInput[key]; if ( @@ -39,7 +39,7 @@ export function parseAPIFeaturesResponse( } export interface CacheResult { - features: RawFeatures; + features: FetchedFeatures; stale: boolean; } @@ -67,7 +67,7 @@ export class FeatureCache { { features, }: { - features: RawFeatures; + features: FetchedFeatures; }, ) { let cacheData: CacheData = {}; diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 46d02a61..c50c723d 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -9,7 +9,7 @@ import { parseAPIFeaturesResponse, } from "./featureCache"; -export type RawFeature = { +export type FetchedFeature = { /** * Feature key */ @@ -28,7 +28,15 @@ export type RawFeature = { const FEATURES_UPDATED_EVENT = "features-updated"; -export type RawFeatures = Record; +export type FetchedFeatures = Record; +// todo: on next major, come up with a better name for this type. Maybe `LocalFeature`. +export type RawFeature = FetchedFeature & { + /** + * If not null, the result is being overridden locally + */ + isEnabledOverride: boolean | null; +}; +export type RawFeatures = Record; export type FeaturesOptions = { /** @@ -38,7 +46,7 @@ export type FeaturesOptions = { fallbackFeatures?: string[]; /** - * Timeout in miliseconds + * Timeout in milliseconds */ timeoutMs?: number; @@ -73,7 +81,7 @@ export type FeaturesResponse = { /** * List of enabled features */ - features: RawFeatures; + features: FetchedFeatures; }; export function validateFeaturesResponse(response: any) { @@ -138,14 +146,40 @@ type context = { export const FEATURES_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days -const localStorageCacheKey = `__bucket_features`; +const localStorageFetchedFeaturesKey = `__bucket_fetched_features`; +const localStorageOverridesKey = `__bucket_overrides`; + +type OverridesFeatures = Record; + +function setOverridesCache(overrides: OverridesFeatures) { + localStorage.setItem(localStorageOverridesKey, JSON.stringify(overrides)); +} + +function getOverridesCache(): OverridesFeatures { + try { + const cachedOverrides = JSON.parse( + localStorage.getItem(localStorageOverridesKey) || "{}", + ); + + if (!isObject(cachedOverrides)) { + return {}; + } + return cachedOverrides; + } catch (e) { + return {}; + } +} /** * @internal */ export class FeaturesClient { private cache: FeatureCache; - private features: RawFeatures; + private fetchedFeatures: FetchedFeatures; + private featureOverrides: OverridesFeatures; + + private features: RawFeatures = {}; + private config: Config; private rateLimiter: RateLimiter; private readonly logger: Logger; @@ -162,14 +196,15 @@ export class FeaturesClient { rateLimiter?: RateLimiter; }, ) { - this.features = {}; + this.fetchedFeatures = {}; this.logger = loggerWithPrefix(logger, "[Features]"); this.cache = options?.cache ? options.cache : new FeatureCache({ storage: { - get: () => localStorage.getItem(localStorageCacheKey), - set: (value) => localStorage.setItem(localStorageCacheKey, value), + get: () => localStorage.getItem(localStorageFetchedFeaturesKey), + set: (value) => + localStorage.setItem(localStorageFetchedFeaturesKey, value), }, staleTimeMs: options?.staleTimeMs ?? 0, expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, @@ -178,11 +213,12 @@ export class FeaturesClient { this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); + this.featureOverrides = getOverridesCache(); } async initialize() { const features = (await this.maybeFetchFeatures()) || {}; - this.setFeatures(features); + this.setFetchedFeatures(features); } async setContext(context: context) { @@ -222,7 +258,7 @@ export class FeaturesClient { return this.features; } - public async fetchFeatures(): Promise { + public async fetchFeatures(): Promise { const params = this.fetchParams(); try { const res = await this.httpClient.get({ @@ -291,11 +327,41 @@ export class FeaturesClient { return checkEvent.value; } - private setFeatures(features: RawFeatures) { - this.features = features; + private triggerFeaturesChanged() { + const mergedFeatures: RawFeatures = {}; + + // merge fetched features with overrides into `this.features` + for (const key in this.fetchedFeatures) { + const fetchedFeature = this.fetchedFeatures[key]; + if (!fetchedFeature) continue; + const isEnabledOverride = this.featureOverrides[key] ?? null; + mergedFeatures[key] = { + ...fetchedFeature, + isEnabledOverride, + }; + } + + // add any overrides that aren't in the fetched features + for (const key in this.featureOverrides) { + if (!this.features[key]) { + mergedFeatures[key] = { + key, + isEnabled: false, + isEnabledOverride: this.featureOverrides[key], + }; + } + } + + this.features = mergedFeatures; + this.eventTarget.dispatchEvent(new Event(FEATURES_UPDATED_EVENT)); } + private setFetchedFeatures(features: FetchedFeatures) { + this.fetchedFeatures = features; + this.triggerFeaturesChanged(); + } + private fetchParams() { const flattenedContext = flattenJSON({ context: this.context }); const params = new URLSearchParams(flattenedContext); @@ -308,7 +374,7 @@ export class FeaturesClient { return params; } - private async maybeFetchFeatures(): Promise { + private async maybeFetchFeatures(): Promise { const cacheKey = this.fetchParams().toString(); const cachedItem = this.cache.get(cacheKey); @@ -325,7 +391,7 @@ export class FeaturesClient { this.cache.set(cacheKey, { features, }); - this.setFeatures(features); + this.setFetchedFeatures(features); }) .catch(() => { // we don't care about the result, we just want to re-fetch @@ -358,6 +424,25 @@ export class FeaturesClient { isEnabled: true, }; return acc; - }, {} as RawFeatures); + }, {} as FetchedFeatures); + } + + setFeatureOverride(key: string, isEnabled: boolean | null) { + if (!(typeof isEnabled === "boolean" || isEnabled === null)) { + throw new Error("setFeatureOverride: isEnabled must be boolean or null"); + } + + if (isEnabled === null) { + delete this.featureOverrides[key]; + } else { + this.featureOverrides[key] = isEnabled; + } + setOverridesCache(this.featureOverrides); + + this.triggerFeaturesChanged(); + } + + getFeatureOverride(key: string): boolean | null { + return this.featureOverrides[key] ?? null; } } diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index d475a0f9..b6bd728a 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -4,6 +4,8 @@ import { BucketClient } from "../src/client"; import { FeaturesClient } from "../src/feature/features"; import { HttpClient } from "../src/httpClient"; +import { featuresResult } from "./mocks/handlers"; + describe("BucketClient", () => { let client: BucketClient; const httpClientPost = vi.spyOn(HttpClient.prototype as any, "post"); @@ -62,4 +64,14 @@ describe("BucketClient", () => { expect(featureClientSetContext).toHaveBeenCalledWith(client["context"]); }); }); + + describe("getFeature", () => { + it("takes overrides into account", async () => { + await client.initialize(); + expect(featuresResult.featureA.isEnabled).toBe(true); + expect(client.getFeature("featureA").isEnabled).toBe(true); + client.setFeatureOverride("featureA", false); + expect(client.getFeature("featureA").isEnabled).toBe(false); + }); + }); }); diff --git a/packages/browser-sdk/test/features.test.ts b/packages/browser-sdk/test/features.test.ts index 6a8ae823..ccc1b1ab 100644 --- a/packages/browser-sdk/test/features.test.ts +++ b/packages/browser-sdk/test/features.test.ts @@ -5,6 +5,7 @@ import { FEATURES_EXPIRE_MS, FeaturesClient, FeaturesOptions, + FetchedFeature, RawFeature, } from "../src/feature/features"; import { HttpClient } from "../src/httpClient"; @@ -125,6 +126,7 @@ describe("FeaturesClient unit tests", () => { expect(featuresClient.getFeatures()).toEqual({ huddle: { isEnabled: true, + isEnabledOverride: null, key: "huddle", }, }); @@ -174,7 +176,7 @@ describe("FeaturesClient unit tests", () => { isEnabled: true, key: "featureB", targetingVersion: 1, - } satisfies RawFeature, + } satisfies FetchedFeature, }, }; @@ -199,6 +201,7 @@ describe("FeaturesClient unit tests", () => { isEnabled: true, key: "featureB", targetingVersion: 1, + isEnabledOverride: null, } satisfies RawFeature, }); @@ -237,7 +240,8 @@ describe("FeaturesClient unit tests", () => { isEnabled: true, targetingVersion: 1, key: "featureA", - }, + isEnabledOverride: null, + } satisfies RawFeature, }), ); }); @@ -269,4 +273,49 @@ describe("FeaturesClient unit tests", () => { expect(httpClient.get).toHaveBeenCalledTimes(2); expect(a).not.toEqual(b); }); + + test("handled overrides", async () => { + // change the response so we can validate that we'll serve the stale cache + const { newFeaturesClient } = featuresClientFactory(); + // localStorage.clear(); + const client = newFeaturesClient(); + await client.initialize(); + + let updated = false; + client.onUpdated(() => { + updated = true; + }); + + expect(client.getFeatures().featureA.isEnabled).toBe(true); + expect(client.getFeatures().featureA.isEnabledOverride).toBe(null); + + expect(updated).toBe(false); + + client.setFeatureOverride("featureA", false); + + expect(updated).toBe(true); + expect(client.getFeatures().featureA.isEnabled).toBe(true); + expect(client.getFeatures().featureA.isEnabledOverride).toBe(false); + }); + + test("handled overrides for features not returned by API", async () => { + // change the response so we can validate that we'll serve the stale cache + const { newFeaturesClient } = featuresClientFactory(); + // localStorage.clear(); + const client = newFeaturesClient(); + await client.initialize(); + + let updated = false; + client.onUpdated(() => { + updated = true; + }); + + expect(client.getFeatures().featureB).toBeUndefined(); + + client.setFeatureOverride("featureB", true); + + expect(updated).toBe(true); + expect(client.getFeatures().featureB.isEnabled).toBe(false); + expect(client.getFeatures().featureB.isEnabledOverride).toBe(true); + }); }); diff --git a/packages/browser-sdk/test/mocks/handlers.ts b/packages/browser-sdk/test/mocks/handlers.ts index 0c575745..21d6a3e8 100644 --- a/packages/browser-sdk/test/mocks/handlers.ts +++ b/packages/browser-sdk/test/mocks/handlers.ts @@ -12,13 +12,14 @@ export const featureResponse: FeaturesResponse = { }, }; -export const featuresResult: Features = { +export const featuresResult = { featureA: { isEnabled: true, key: "featureA", targetingVersion: 1, + isEnabledOverride: null, }, -}; +} satisfies Features; function checkRequest(request: StrictRequest) { const url = new URL(request.url); From fdd6c80c72a6cc85dcc60ac1c7e3dafb19960c83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 07:14:26 +0100 Subject: [PATCH 03/24] chore(deps): bump vite from 5.4.10 to 5.4.14 in /packages/node-sdk/example (#292) --- packages/node-sdk/example/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/example/yarn.lock b/packages/node-sdk/example/yarn.lock index 9832b00f..2a66e7af 100644 --- a/packages/node-sdk/example/yarn.lock +++ b/packages/node-sdk/example/yarn.lock @@ -2471,8 +2471,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0": - version: 5.4.10 - resolution: "vite@npm:5.4.10" + version: 5.4.14 + resolution: "vite@npm:5.4.14" dependencies: esbuild: "npm:^0.21.3" fsevents: "npm:~2.3.3" @@ -2509,7 +2509,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/4ef4807d2fd166a920de244dbcec791ba8a903b017a7d8e9f9b4ac40d23f8152c1100610583d08f542b47ca617a0505cfc5f8407377d610599d58296996691ed + checksum: 10c0/8842933bd70ca6a98489a0bb9c8464bec373de00f9a97c8c7a4e64b24d15c88bfaa8c1acb38a68c3e5eb49072ffbccb146842c2d4edcdd036a9802964cffe3d1 languageName: node linkType: hard From 888f9897b3edbfcbeda3fca431e46c0f021d5793 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 06:21:38 +0000 Subject: [PATCH 04/24] chore(deps-dev): bump vite from 5.4.6 to 5.4.12 (#291) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index da07e150..812c4c4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15693,8 +15693,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0, vite@npm:^5.0.13, vite@npm:^5.3.5": - version: 5.4.6 - resolution: "vite@npm:5.4.6" + version: 5.4.14 + resolution: "vite@npm:5.4.14" dependencies: esbuild: "npm:^0.21.3" fsevents: "npm:~2.3.3" @@ -15731,7 +15731,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/5f87be3a10e970eaf9ac52dfab39cf9fff583036685252fb64570b6d7bfa749f6d221fb78058f5ef4b5664c180d45a8e7a7ff68d7f3770e69e24c7c68b958bde + checksum: 10c0/8842933bd70ca6a98489a0bb9c8464bec373de00f9a97c8c7a4e64b24d15c88bfaa8c1acb38a68c3e5eb49072ffbccb146842c2d4edcdd036a9802964cffe3d1 languageName: node linkType: hard From 847da268d5ccf67b5935599e2b36ddd165bcee65 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 27 Jan 2025 14:25:01 +0100 Subject: [PATCH 05/24] Toolbar (alpha) (#290) The toolbar enables teams to quickly enable/disable features directly in their own app. It's a UI that renders on top of the page: image Features so far: - fetches features remotely through the SDK - also takes a list of features passed locally by prop - override feature enabled easily by flipping a switch, which is stored in localstorage - filter the list of features by search term - works for both Browser SDK and React SDK This started out as a way to see just how hard it would be to implement a toolbar for Bucket, so the code is now really in great shape. Known issues: - we use `featureList` to pass the string[] with feature keys into the BuckerProvider (and from there to BucketClient from the browser-sdk because `features` was already take for misc. feature fetching related options - positioning (bottom-left etc.) of the toolbar toggle doesn't really work the the toolbar dialog open up weirdly/wonkily in lots of situation (like narrow screen). --------- Co-authored-by: Lasse Boisen Andersen Co-authored-by: Lasse Boisen Andersen --- packages/browser-sdk/example/browser.html | 43 --- .../browser-sdk/example/typescript/app.ts | 51 ++++ .../browser-sdk/example/typescript/index.html | 23 ++ packages/browser-sdk/index.html | 71 +++++ packages/browser-sdk/src/client.ts | 62 ++++- packages/browser-sdk/src/config.ts | 1 + packages/browser-sdk/src/feature/features.ts | 60 ++-- packages/browser-sdk/src/feedback/feedback.ts | 11 +- .../src/feedback/ui/FeedbackDialog.css | 99 ------- .../src/feedback/ui/FeedbackDialog.tsx | 244 +++------------- .../src/feedback/ui/FeedbackForm.tsx | 5 +- packages/browser-sdk/src/feedback/ui/Plug.tsx | 4 +- .../src/feedback/ui/StarRating.tsx | 17 +- .../ui/config/defaultTranslations.tsx | 1 - packages/browser-sdk/src/feedback/ui/index.ts | 24 +- packages/browser-sdk/src/feedback/ui/types.ts | 27 +- packages/browser-sdk/src/index.ts | 10 +- packages/browser-sdk/src/toolbar/Features.css | 74 +++++ packages/browser-sdk/src/toolbar/Features.tsx | 113 ++++++++ packages/browser-sdk/src/toolbar/Switch.css | 22 ++ packages/browser-sdk/src/toolbar/Switch.tsx | 50 ++++ packages/browser-sdk/src/toolbar/Toolbar.css | 163 +++++++++++ packages/browser-sdk/src/toolbar/Toolbar.tsx | 146 ++++++++++ packages/browser-sdk/src/toolbar/index.css | 3 + packages/browser-sdk/src/toolbar/index.ts | 23 ++ packages/browser-sdk/src/ui/Dialog.css | 113 ++++++++ packages/browser-sdk/src/ui/Dialog.tsx | 263 ++++++++++++++++++ .../src/{feedback => }/ui/constants.ts | 1 + .../src/{feedback => }/ui/icons/Check.tsx | 0 .../{feedback => }/ui/icons/CheckCircle.tsx | 0 .../src/{feedback => }/ui/icons/Close.tsx | 0 .../{feedback => }/ui/icons/Dissatisfied.tsx | 0 .../src/{feedback => }/ui/icons/Logo.tsx | 4 +- .../src/{feedback => }/ui/icons/Neutral.tsx | 0 .../src/{feedback => }/ui/icons/Satisfied.tsx | 0 .../ui/icons/VeryDissatisfied.tsx | 0 .../{feedback => }/ui/icons/VerySatisfied.tsx | 0 .../packages/floating-ui-preact-dom/README.md | 0 .../packages/floating-ui-preact-dom/arrow.ts | 0 .../packages/floating-ui-preact-dom/index.ts | 0 .../packages/floating-ui-preact-dom/types.ts | 0 .../floating-ui-preact-dom/useFloating.ts | 0 .../floating-ui-preact-dom/utils/deepEqual.ts | 0 .../floating-ui-preact-dom/utils/getDPR.ts | 0 .../utils/roundByDPR.ts | 0 .../utils/useLatestRef.ts | 0 packages/browser-sdk/src/ui/types.ts | 33 +++ packages/browser-sdk/src/ui/utils.ts | 65 +++++ .../test/e2e/feedback-widget.browser.spec.ts | 63 ++++- .../test/e2e/give-feedback-button.html | 13 + packages/browser-sdk/test/features.test.ts | 11 +- packages/react-sdk/src/index.tsx | 13 +- packages/react-sdk/test/usage.test.tsx | 2 + 53 files changed, 1497 insertions(+), 431 deletions(-) delete mode 100644 packages/browser-sdk/example/browser.html create mode 100644 packages/browser-sdk/example/typescript/app.ts create mode 100644 packages/browser-sdk/example/typescript/index.html create mode 100644 packages/browser-sdk/index.html create mode 100644 packages/browser-sdk/src/toolbar/Features.css create mode 100644 packages/browser-sdk/src/toolbar/Features.tsx create mode 100644 packages/browser-sdk/src/toolbar/Switch.css create mode 100644 packages/browser-sdk/src/toolbar/Switch.tsx create mode 100644 packages/browser-sdk/src/toolbar/Toolbar.css create mode 100644 packages/browser-sdk/src/toolbar/Toolbar.tsx create mode 100644 packages/browser-sdk/src/toolbar/index.css create mode 100644 packages/browser-sdk/src/toolbar/index.ts create mode 100644 packages/browser-sdk/src/ui/Dialog.css create mode 100644 packages/browser-sdk/src/ui/Dialog.tsx rename packages/browser-sdk/src/{feedback => }/ui/constants.ts (95%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Check.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/CheckCircle.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Close.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Dissatisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Logo.tsx (98%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Neutral.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/Satisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/VeryDissatisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/icons/VerySatisfied.tsx (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/README.md (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/arrow.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/index.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/types.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/useFloating.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/deepEqual.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/getDPR.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/roundByDPR.ts (100%) rename packages/browser-sdk/src/{feedback => }/ui/packages/floating-ui-preact-dom/utils/useLatestRef.ts (100%) create mode 100644 packages/browser-sdk/src/ui/types.ts create mode 100644 packages/browser-sdk/src/ui/utils.ts create mode 100644 packages/browser-sdk/test/e2e/give-feedback-button.html diff --git a/packages/browser-sdk/example/browser.html b/packages/browser-sdk/example/browser.html deleted file mode 100644 index 175497d1..00000000 --- a/packages/browser-sdk/example/browser.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - Bucket feature management - - - Loading... - - - - - - - - diff --git a/packages/browser-sdk/example/typescript/app.ts b/packages/browser-sdk/example/typescript/app.ts new file mode 100644 index 00000000..72ee20cd --- /dev/null +++ b/packages/browser-sdk/example/typescript/app.ts @@ -0,0 +1,51 @@ +import { BucketClient } from "../../src"; + +const urlParams = new URLSearchParams(window.location.search); +const publishableKey = urlParams.get("publishableKey"); +const featureKey = urlParams.get("featureKey") ?? "huddles"; + +const featureList = ["huddles"]; + +if (!publishableKey) { + throw Error("publishableKey is missing"); +} + +const bucket = new BucketClient({ + publishableKey, + user: { id: "42" }, + company: { id: "1" }, + toolbar: { + show: true, + position: { placement: "bottom-right" }, + }, + featureList, +}); + +document + .getElementById("startHuddle") + ?.addEventListener("click", () => bucket.track(featureKey)); +document.getElementById("giveFeedback")?.addEventListener("click", (event) => + bucket.requestFeedback({ + featureKey, + position: { type: "POPOVER", anchor: event.currentTarget as HTMLElement }, + }), +); + +bucket.initialize().then(() => { + console.log("Bucket initialized"); + const loadingElem = document.getElementById("loading"); + if (loadingElem) loadingElem.style.display = "none"; +}); + +bucket.onFeaturesUpdated(() => { + const { isEnabled } = bucket.getFeature("huddles"); + + const startHuddleElem = document.getElementById("start-huddle"); + if (isEnabled) { + // show the start-huddle button + if (startHuddleElem) startHuddleElem.style.display = "block"; + } else { + // hide the start-huddle button + if (startHuddleElem) startHuddleElem.style.display = "none"; + } +}); diff --git a/packages/browser-sdk/example/typescript/index.html b/packages/browser-sdk/example/typescript/index.html new file mode 100644 index 00000000..cec72d21 --- /dev/null +++ b/packages/browser-sdk/example/typescript/index.html @@ -0,0 +1,23 @@ + + + + + + Bucket feature management + + + Loading... + + + + + diff --git a/packages/browser-sdk/index.html b/packages/browser-sdk/index.html new file mode 100644 index 00000000..597eadec --- /dev/null +++ b/packages/browser-sdk/index.html @@ -0,0 +1,71 @@ + + + + + + + + Bucket Browser SDK + + +
+ Loading... + + + + + + + + diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 9e16a076..90561c6b 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -14,10 +14,12 @@ import { RequestFeedbackOptions, } from "./feedback/feedback"; import * as feedbackLib from "./feedback/ui"; -import { API_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; +import { ToolbarPosition } from "./toolbar/Toolbar"; +import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; import { BucketContext, CompanyContext, UserContext } from "./context"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; +import { showToolbarToggle } from "./toolbar"; const isMobile = typeof window !== "undefined" && window.innerWidth < 768; const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas @@ -91,10 +93,20 @@ export type PayloadContext = { interface Config { apiBaseUrl: string; + appBaseUrl: string; sseBaseUrl: string; enableTracking: boolean; } +export type ToolbarOptions = + | boolean + | { + show?: boolean; + position?: ToolbarPosition; + }; + +export type FeatureDefinitions = Readonly>; + /** * BucketClient initialization options. */ @@ -140,6 +152,11 @@ export interface InitOptions { */ apiBaseUrl?: string; + /** + * Base URL of the Bucket web app. Links open ín this app by default. + */ + appBaseUrl?: string; + /** * @deprecated * Use `sseBaseUrl` instead. @@ -166,10 +183,22 @@ export interface InitOptions { */ sdkVersion?: string; enableTracking?: boolean; + + /** + * Toolbar configuration (alpha) + * @ignore + */ + toolbar?: ToolbarOptions; + /** + * Local-first definition of features (alpha) + * @ignore + */ + featureList?: FeatureDefinitions; } const defaultConfig: Config = { apiBaseUrl: API_BASE_URL, + appBaseUrl: APP_BASE_URL, sseBaseUrl: SSE_REALTIME_BASE_URL, enableTracking: true, }; @@ -189,6 +218,17 @@ export interface Feature { options: Omit, ) => void; } + +function shouldShowToolbar(opts: InitOptions) { + const toolbarOpts = opts.toolbar; + if (typeof toolbarOpts === "boolean") return toolbarOpts; + if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show; + + return ( + opts.featureList !== undefined && window?.location?.hostname === "localhost" + ); +} + /** * BucketClient lets you interact with the Bucket API. * @@ -220,9 +260,10 @@ export class BucketClient { this.config = { apiBaseUrl: opts?.apiBaseUrl ?? opts?.host ?? defaultConfig.apiBaseUrl, + appBaseUrl: opts?.appBaseUrl ?? opts?.host ?? defaultConfig.appBaseUrl, sseBaseUrl: opts?.sseBaseUrl ?? opts?.sseHost ?? defaultConfig.sseBaseUrl, enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking, - } satisfies Config; + }; const feedbackOpts = handleDeprecatedFeedbackOptions(opts?.feedback); @@ -244,6 +285,7 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, + opts?.featureList || [], this.logger, opts?.features, ); @@ -269,6 +311,15 @@ export class BucketClient { ); } } + + if (shouldShowToolbar(opts)) { + this.logger.info("opening toolbar toggler"); + showToolbarToggle({ + bucketClient: this as unknown as BucketClient, + position: + typeof opts.toolbar === "object" ? opts.toolbar.position : undefined, + }); + } } /** @@ -299,6 +350,13 @@ export class BucketClient { } } + /** + * Get the current configuration. + */ + getConfig() { + return this.config; + } + /** * Update the user context. * Performs a shallow merge with the existing user context. diff --git a/packages/browser-sdk/src/config.ts b/packages/browser-sdk/src/config.ts index e1baeec7..fd116c7f 100644 --- a/packages/browser-sdk/src/config.ts +++ b/packages/browser-sdk/src/config.ts @@ -1,6 +1,7 @@ import { version } from "../package.json"; export const API_BASE_URL = "https://front.bucket.co"; +export const APP_BASE_URL = "https://app.bucket.co"; export const SSE_REALTIME_BASE_URL = "https://livemessaging.bucket.co"; export const SDK_VERSION_HEADER_NAME = "bucket-sdk-version"; diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index c50c723d..32deebfd 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -46,17 +46,24 @@ export type FeaturesOptions = { fallbackFeatures?: string[]; /** - * Timeout in milliseconds + * Timeout in milliseconds when fetching features */ timeoutMs?: number; /** - * If set to true client will return cached value when its stale - * but refetching + * If set to true stale features will be returned while refetching features */ staleWhileRevalidate?: boolean; - staleTimeMs?: number; + + /** + * If set, features will be cached between page loads for this duration + */ expireTimeMs?: number; + + /** + * Stale features will be returned if staleWhileRevalidate is true if no new features can be fetched + */ + staleTimeMs?: number; }; type Config = { @@ -149,25 +156,22 @@ export const FEATURES_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely a const localStorageFetchedFeaturesKey = `__bucket_fetched_features`; const localStorageOverridesKey = `__bucket_overrides`; -type OverridesFeatures = Record; +type OverridesFeatures = Record; function setOverridesCache(overrides: OverridesFeatures) { localStorage.setItem(localStorageOverridesKey, JSON.stringify(overrides)); } function getOverridesCache(): OverridesFeatures { - try { - const cachedOverrides = JSON.parse( - localStorage.getItem(localStorageOverridesKey) || "{}", - ); + const cachedOverrides = JSON.parse( + localStorage.getItem(localStorageOverridesKey) || "{}", + ); - if (!isObject(cachedOverrides)) { - return {}; - } - return cachedOverrides; - } catch (e) { + if (!isObject(cachedOverrides)) { return {}; } + + return cachedOverrides; } /** @@ -176,7 +180,7 @@ function getOverridesCache(): OverridesFeatures { export class FeaturesClient { private cache: FeatureCache; private fetchedFeatures: FetchedFeatures; - private featureOverrides: OverridesFeatures; + private featureOverrides: OverridesFeatures = {}; private features: RawFeatures = {}; @@ -190,6 +194,7 @@ export class FeaturesClient { constructor( private httpClient: HttpClient, private context: context, + private featureDefinitions: Readonly, logger: Logger, options?: FeaturesOptions & { cache?: FeatureCache; @@ -213,7 +218,18 @@ export class FeaturesClient { this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); - this.featureOverrides = getOverridesCache(); + + try { + const storedFeatureOverrides = getOverridesCache(); + for (const key in storedFeatureOverrides) { + if (this.featureDefinitions.includes(key)) { + this.featureOverrides[key] = storedFeatureOverrides[key]; + } + } + } catch (e) { + this.logger.warn("error getting feature overrides from cache", e); + this.featureOverrides = {}; + } } async initialize() { @@ -258,6 +274,10 @@ export class FeaturesClient { return this.features; } + getFetchedFeatures(): FetchedFeatures { + return this.fetchedFeatures; + } + public async fetchFeatures(): Promise { const params = this.fetchParams(); try { @@ -341,13 +361,13 @@ export class FeaturesClient { }; } - // add any overrides that aren't in the fetched features - for (const key in this.featureOverrides) { - if (!this.features[key]) { + // add any features that aren't in the fetched features + for (const key of this.featureDefinitions) { + if (!mergedFeatures[key]) { mergedFeatures[key] = { key, isEnabled: false, - isEnabledOverride: this.featureOverrides[key], + isEnabledOverride: this.featureOverrides[key] ?? null, }; } } diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index b83428a9..c649b283 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -1,9 +1,9 @@ import { HttpClient } from "../httpClient"; import { Logger } from "../logger"; import { AblySSEChannel, openAblySSEChannel } from "../sse"; +import { Position } from "../ui/types"; import { - FeedbackPosition, FeedbackSubmission, FeedbackTranslations, OpenFeedbackFormOptions, @@ -37,7 +37,7 @@ export type FeedbackOptions = { /** * Control the placement and behavior of the feedback form. */ - position?: FeedbackPosition; + position?: Position; /** * Add your own custom translations for the feedback form. @@ -69,7 +69,7 @@ export function handleDeprecatedFeedbackOptions( }; } -export type FeatureIdentifier = +type FeatureIdentifier = | { /** * Bucket feature ID. @@ -100,6 +100,9 @@ export type RequestFeedbackData = Omit< * * This can be used for side effects, such as storing a * copy of the feedback in your own application or CRM. + * + * @param {Object} data + * @param data. */ onAfterSubmit?: (data: FeedbackSubmission) => void; } & FeatureIdentifier; @@ -294,7 +297,7 @@ export class AutoFeedback { private httpClient: HttpClient, private feedbackPromptHandler: FeedbackPromptHandler = createDefaultFeedbackPromptHandler(), private userId: string, - private position: FeedbackPosition = DEFAULT_POSITION, + private position: Position = DEFAULT_POSITION, private feedbackTranslations: Partial = {}, ) {} diff --git a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css index 91a99ec7..96cb6f02 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css +++ b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css @@ -1,39 +1,3 @@ -@keyframes scale { - from { - transform: scale(0.9); - } - to { - transform: scale(1); - } -} - -@keyframes floatUp { - from { - transform: translateY(15%); - } - to { - transform: translateY(0%); - } -} - -@keyframes floatDown { - from { - transform: translateY(-15%); - } - to { - transform: translateY(0%); - } -} - -@keyframes fade { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - .dialog { position: fixed; width: 210px; @@ -69,12 +33,8 @@ } .arrow { - position: absolute; - width: 8px; - height: 8px; background-color: var(--bucket-feedback-dialog-background-color, #fff); box-shadow: var(--bucket-feedback-dialog-border, #d8d9df) -1px -1px 1px 0px; - transform: rotate(45deg); &.bottom { box-shadow: var(--bucket-feedback-dialog-border, #d8d9df) -1px -1px 1px 0px; @@ -134,62 +94,3 @@ .plug a:hover { opacity: 0.7; } - -/* Modal */ - -.dialog.modal { - margin: auto; - margin-top: 4rem; - - &[open] { - animation: /* easeOutQuint */ - scale 200ms cubic-bezier(0.22, 1, 0.36, 1), - fade 200ms cubic-bezier(0.22, 1, 0.36, 1); - - &::backdrop { - animation: fade 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - } -} - -/* Anchored */ - -.dialog.anchored { - position: absolute; - - &[open] { - animation: /* easeOutQuint */ - scale 200ms cubic-bezier(0.22, 1, 0.36, 1), - fade 200ms cubic-bezier(0.22, 1, 0.36, 1); - } - &.bottom { - transform-origin: top center; - } - &.top { - transform-origin: bottom center; - } - &.left { - transform-origin: right center; - } - &.right { - transform-origin: left center; - } -} - -/* Unanchored */ - -.dialog[open].unanchored { - &.unanchored-bottom-left, - &.unanchored-bottom-right { - animation: /* easeOutQuint */ - floatUp 300ms cubic-bezier(0.22, 1, 0.36, 1), - fade 300ms cubic-bezier(0.22, 1, 0.36, 1); - } - - &.unanchored-top-left, - &.unanchored-top-right { - animation: /* easeOutQuint */ - floatDown 300ms cubic-bezier(0.22, 1, 0.36, 1), - fade 300ms cubic-bezier(0.22, 1, 0.36, 1); - } -} diff --git a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx index 8a0f771a..2893ebbe 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx @@ -1,33 +1,22 @@ import { Fragment, FunctionComponent, h } from "preact"; -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useState } from "preact/hooks"; + +import { feedbackContainerId } from "../../ui/constants"; +import { Dialog, useDialog } from "../../ui/Dialog"; +import { Close } from "../../ui/icons/Close"; import { DEFAULT_TRANSLATIONS } from "./config/defaultTranslations"; import { useTimer } from "./hooks/useTimer"; -import { Close } from "./icons/Close"; -import { - arrow, - autoUpdate, - flip, - offset, - shift, - useFloating, -} from "./packages/floating-ui-preact-dom"; -import { feedbackContainerId } from "./constants"; import { FeedbackForm } from "./FeedbackForm"; import styles from "./index.css?inline"; import { RadialProgress } from "./RadialProgress"; import { FeedbackScoreSubmission, FeedbackSubmission, - Offset, OpenFeedbackFormOptions, WithRequired, } from "./types"; -type Position = Partial< - Record<"top" | "left" | "right" | "bottom", number | string> ->; - export type FeedbackDialogProps = WithRequired< OpenFeedbackFormOptions, "onSubmit" | "position" @@ -47,97 +36,6 @@ export const FeedbackDialog: FunctionComponent = ({ onSubmit, onScoreSubmit, }) => { - const arrowRef = useRef(null); - const anchor = position.type === "POPOVER" ? position.anchor : null; - const { - refs, - floatingStyles, - middlewareData, - placement: actualPlacement, - } = useFloating({ - elements: { - reference: anchor, - }, - transform: false, - whileElementsMounted: autoUpdate, - middleware: [ - flip({ - padding: 10, - mainAxis: true, - crossAxis: true, - fallbackAxisSideDirection: "end", - }), - shift(), - offset(8), - arrow({ - element: arrowRef, - }), - ], - }); - - let unanchoredPosition: Position = {}; - if (position.type === "DIALOG") { - const offsetY = parseOffset(position.offset?.y); - const offsetX = parseOffset(position.offset?.x); - - switch (position.placement) { - case "top-left": - unanchoredPosition = { - top: offsetY, - left: offsetX, - }; - break; - case "top-right": - unanchoredPosition = { - top: offsetY, - right: offsetX, - }; - break; - case "bottom-left": - unanchoredPosition = { - bottom: offsetY, - left: offsetX, - }; - break; - case "bottom-right": - unanchoredPosition = { - bottom: offsetY, - right: offsetX, - }; - break; - } - } - - const { x: arrowX, y: arrowY } = middlewareData.arrow ?? {}; - - const staticSide = - { - top: "bottom", - right: "left", - bottom: "top", - left: "right", - }[actualPlacement.split("-")[0]] || "bottom"; - - const arrowStyles = { - left: arrowX != null ? `${arrowX}px` : "", - top: arrowY != null ? `${arrowY}px` : "", - right: "", - bottom: "", - [staticSide]: "-4px", - }; - - const close = useCallback(() => { - const dialog = refs.floating.current as HTMLDialogElement | null; - dialog?.close(); - autoClose.stop(); - onClose?.(); - }, [onClose]); - - const dismiss = useCallback(() => { - close(); - onDismiss?.(); - }, [close, onDismiss]); - const [feedbackId, setFeedbackId] = useState(undefined); const [scoreState, setScoreState] = useState< "idle" | "submitting" | "submitted" @@ -164,112 +62,54 @@ export const FeedbackDialog: FunctionComponent = ({ [feedbackId, onSubmit], ); + const { isOpen, close } = useDialog({ onClose, initialValue: true }); + const autoClose = useTimer({ enabled: position.type === "DIALOG", initialDuration: INACTIVE_DURATION_MS, onEnd: close, }); - useEffect(() => { - // Only enable 'quick dismiss' for popovers - if (position.type === "MODAL" || position.type === "DIALOG") return; - - const escapeHandler = (e: KeyboardEvent) => { - if (e.key == "Escape") { - dismiss(); - } - }; - - const clickOutsideHandler = (e: MouseEvent) => { - if ( - !(e.target instanceof Element) || - !e.target.closest(`#${feedbackContainerId}`) - ) { - dismiss(); - } - }; - - const observer = new MutationObserver((mutations) => { - if (position.anchor === null) return; - - mutations.forEach((mutation) => { - const removedNodes = Array.from(mutation.removedNodes); - const hasBeenRemoved = removedNodes.some((node) => { - return node === position.anchor || node.contains(position.anchor); - }); - - if (hasBeenRemoved) { - close(); - } - }); - }); - - window.addEventListener("mousedown", clickOutsideHandler); - window.addEventListener("keydown", escapeHandler); - observer.observe(document.body, { - subtree: true, - childList: true, - }); - - return () => { - window.removeEventListener("mousedown", clickOutsideHandler); - window.removeEventListener("keydown", escapeHandler); - observer.disconnect(); - }; - }, [position.type, close]); + const dismiss = useCallback(() => { + autoClose.stop(); + close(); + onDismiss?.(); + }, [autoClose, close, onDismiss]); return ( <> - - - - - - {anchor && ( -
- )} -
+ <> + + + + + ); }; - -function parseOffset(offsetInput?: Offset["x"] | Offset["y"]) { - if (offsetInput === undefined) return "1rem"; - if (typeof offsetInput === "number") return offsetInput + "px"; - - return offsetInput; -} diff --git a/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx b/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx index 949ce658..f1b7b446 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx @@ -1,8 +1,9 @@ import { FunctionComponent, h } from "preact"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; -import { Check } from "./icons/Check"; -import { CheckCircle } from "./icons/CheckCircle"; +import { Check } from "../../ui/icons/Check"; +import { CheckCircle } from "../../ui/icons/CheckCircle"; + import { Button } from "./Button"; import { Plug } from "./Plug"; import { StarRating } from "./StarRating"; diff --git a/packages/browser-sdk/src/feedback/ui/Plug.tsx b/packages/browser-sdk/src/feedback/ui/Plug.tsx index dc8add02..f315708f 100644 --- a/packages/browser-sdk/src/feedback/ui/Plug.tsx +++ b/packages/browser-sdk/src/feedback/ui/Plug.tsx @@ -1,12 +1,12 @@ import { FunctionComponent, h } from "preact"; -import { Logo } from "./icons/Logo"; +import { Logo } from "../../ui/icons/Logo"; export const Plug: FunctionComponent = () => { return ( ); diff --git a/packages/browser-sdk/src/feedback/ui/StarRating.tsx b/packages/browser-sdk/src/feedback/ui/StarRating.tsx index ffe6ec1a..e8e439a5 100644 --- a/packages/browser-sdk/src/feedback/ui/StarRating.tsx +++ b/packages/browser-sdk/src/feedback/ui/StarRating.tsx @@ -1,12 +1,17 @@ import { Fragment, FunctionComponent, h } from "preact"; import { useRef } from "preact/hooks"; -import { Dissatisfied } from "./icons/Dissatisfied"; -import { Neutral } from "./icons/Neutral"; -import { Satisfied } from "./icons/Satisfied"; -import { VeryDissatisfied } from "./icons/VeryDissatisfied"; -import { VerySatisfied } from "./icons/VerySatisfied"; -import { arrow, offset, useFloating } from "./packages/floating-ui-preact-dom"; +import { Dissatisfied } from "../../ui/icons/Dissatisfied"; +import { Neutral } from "../../ui/icons/Neutral"; +import { Satisfied } from "../../ui/icons/Satisfied"; +import { VeryDissatisfied } from "../../ui/icons/VeryDissatisfied"; +import { VerySatisfied } from "../../ui/icons/VerySatisfied"; +import { + arrow, + offset, + useFloating, +} from "../../ui/packages/floating-ui-preact-dom"; + import { FeedbackTranslations } from "./types"; const scores = [ diff --git a/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx b/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx index 9bf80413..c7edc9a2 100644 --- a/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx +++ b/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx @@ -1,5 +1,4 @@ import { FeedbackTranslations } from "../types"; - /** * {@includeCode ./defaultTranslations.tsx} */ diff --git a/packages/browser-sdk/src/feedback/ui/index.ts b/packages/browser-sdk/src/feedback/ui/index.ts index f1e02922..21ead923 100644 --- a/packages/browser-sdk/src/feedback/ui/index.ts +++ b/packages/browser-sdk/src/feedback/ui/index.ts @@ -1,10 +1,12 @@ import { h, render } from "preact"; -import { feedbackContainerId, propagatedEvents } from "./constants"; +import { feedbackContainerId, propagatedEvents } from "../../ui/constants"; +import { Position } from "../../ui/types"; + import { FeedbackDialog } from "./FeedbackDialog"; -import { FeedbackPosition, OpenFeedbackFormOptions } from "./types"; +import { OpenFeedbackFormOptions } from "./types"; -export const DEFAULT_POSITION: FeedbackPosition = { +export const DEFAULT_POSITION: Position = { type: "DIALOG", placement: "bottom-right", }; @@ -31,6 +33,11 @@ function attachDialogContainer() { return container.shadowRoot!; } +// this is a counter that increases every time the feedback form is opened +// and since it's passed as a key to the FeedbackDialog component, +// it forces a re-render on every form open +let openInstances = 0; + export function openFeedbackForm(options: OpenFeedbackFormOptions): void { const shadowRoot = attachDialogContainer(); const position = options.position || DEFAULT_POSITION; @@ -53,11 +60,10 @@ export function openFeedbackForm(options: OpenFeedbackFormOptions): void { } } - render(h(FeedbackDialog, { ...options, position }), shadowRoot); + openInstances++; - const dialog = shadowRoot.querySelector("dialog"); - - if (dialog && !dialog.hasAttribute("open")) { - dialog[position.type === "MODAL" ? "showModal" : "show"](); - } + render( + h(FeedbackDialog, { ...options, position, key: openInstances.toString() }), + shadowRoot, + ); } diff --git a/packages/browser-sdk/src/feedback/ui/types.ts b/packages/browser-sdk/src/feedback/ui/types.ts index c3b92a1b..79d7d591 100644 --- a/packages/browser-sdk/src/feedback/ui/types.ts +++ b/packages/browser-sdk/src/feedback/ui/types.ts @@ -1,26 +1,6 @@ -export type WithRequired = T & { [P in K]-?: T[P] }; - -export type FeedbackPlacement = - | "bottom-right" - | "bottom-left" - | "top-right" - | "top-left"; - -export type Offset = { - /** - * Offset from the nearest horizontal screen edge after placement is resolved - */ - x?: string | number; - /** - * Offset from the nearest vertical screen edge after placement is resolved - */ - y?: string | number; -}; +import { Position } from "../../ui/types"; -export type FeedbackPosition = - | { type: "MODAL" } - | { type: "DIALOG"; placement: FeedbackPlacement; offset?: Offset } - | { type: "POPOVER"; anchor: HTMLElement | null }; +export type WithRequired = T & { [P in K]-?: T[P] }; export interface FeedbackSubmission { question: string; @@ -46,7 +26,7 @@ export interface OpenFeedbackFormOptions { /** * Control the placement and behavior of the feedback form. */ - position?: FeedbackPosition; + position?: Position; /** * Add your own custom translations for the feedback form. @@ -67,7 +47,6 @@ export interface OpenFeedbackFormOptions { onClose?: () => void; onDismiss?: () => void; } - /** * You can use this to override text values in the feedback form * with desired language translation diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index f8cd11db..7b1186cb 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -1,4 +1,6 @@ -export type { Feature, InitOptions } from "./client"; +// import "preact/debug"; + +export type { Feature, InitOptions, ToolbarOptions } from "./client"; export { BucketClient } from "./client"; export type { BucketContext, CompanyContext, UserContext } from "./context"; export type { @@ -8,7 +10,6 @@ export type { RawFeatures, } from "./feature/features"; export type { - FeatureIdentifier, Feedback, FeedbackOptions, FeedbackPrompt, @@ -22,15 +23,12 @@ export type { UnassignedFeedback, } from "./feedback/feedback"; export type { DEFAULT_TRANSLATIONS } from "./feedback/ui/config/defaultTranslations"; -export { feedbackContainerId, propagatedEvents } from "./feedback/ui/constants"; export type { - FeedbackPlacement, - FeedbackPosition, FeedbackScoreSubmission, FeedbackSubmission, FeedbackTranslations, - Offset, OnScoreSubmitResult, OpenFeedbackFormOptions, } from "./feedback/ui/types"; export type { Logger } from "./logger"; +export { feedbackContainerId, propagatedEvents } from "./ui/constants"; diff --git a/packages/browser-sdk/src/toolbar/Features.css b/packages/browser-sdk/src/toolbar/Features.css new file mode 100644 index 00000000..ee17134f --- /dev/null +++ b/packages/browser-sdk/src/toolbar/Features.css @@ -0,0 +1,74 @@ +.search-input { + background: transparent; + border: none; + color: white; + width: 100%; + font-size: var(--text-size); + + &::placeholder { + color: var(--gray500); + } + + &::-webkit-search-cancel-button { + -webkit-appearance: none; + display: inline-block; + width: 8px; + height: 8px; + margin-left: 10px; + background: linear-gradient( + 45deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0) 43%, + #fff 45%, + #fff 55%, + rgba(0, 0, 0, 0) 57%, + rgba(0, 0, 0, 0) 100% + ), + linear-gradient( + 135deg, + transparent 0%, + transparent 43%, + #fff 45%, + #fff 55%, + transparent 57%, + transparent 100% + ); + cursor: pointer; + } +} + +.features-table { + width: 100%; + border-collapse: collapse; +} + +.feature-name-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: auto; + padding: 6px 6px 6px 0; +} + +.feature-link { + color: var(--text-color); + text-decoration: none; + &:hover { + text-decoration: underline; + } +} + +.feature-reset-cell { + width: 30px; + padding: 6px 0; + text-align: right; +} + +.reset { + color: var(--brand300); +} + +.feature-switch-cell { + padding: 6px 0 6px 6px; + width: 0; +} diff --git a/packages/browser-sdk/src/toolbar/Features.tsx b/packages/browser-sdk/src/toolbar/Features.tsx new file mode 100644 index 00000000..9867a57d --- /dev/null +++ b/packages/browser-sdk/src/toolbar/Features.tsx @@ -0,0 +1,113 @@ +import { h } from "preact"; + +import { Switch } from "./Switch"; +import { FeatureItem } from "./Toolbar"; + +export function FeaturesTable({ + features, + setEnabledOverride, + appBaseUrl, +}: { + features: FeatureItem[]; + setEnabledOverride: (key: string, value: boolean | null) => void; + appBaseUrl: string; +}) { + if (features.length === 0) { + return
No features found
; + } + return ( + + + {features.map((feature) => ( + + ))} + +
+ ); +} + +function FeatureRow({ + setEnabledOverride, + appBaseUrl, + feature, +}: { + feature: FeatureItem; + appBaseUrl: string; + setEnabledOverride: (key: string, value: boolean | null) => void; +}) { + return ( + + + + {feature.key} + + + + {feature.localOverride !== null ? ( + + ) : null} + + + { + const isChecked = e.currentTarget.checked; + const isOverridden = isChecked !== feature.isEnabled; + setEnabledOverride(feature.key, isOverridden ? isChecked : null); + }} + /> + + + ); +} + +export function FeatureSearch({ + onSearch, +}: { + onSearch: (val: string) => void; +}) { + return ( + onSearch(s.currentTarget.value)} + autoFocus + class="search-input" + /> + ); +} + +function Reset({ + setEnabledOverride, + featureKey, +}: { + setEnabledOverride: (key: string, value: boolean | null) => void; + featureKey: string; +}) { + return ( + { + e.preventDefault(); + setEnabledOverride(featureKey, null); + }} + > + reset + + ); +} diff --git a/packages/browser-sdk/src/toolbar/Switch.css b/packages/browser-sdk/src/toolbar/Switch.css new file mode 100644 index 00000000..335d1b71 --- /dev/null +++ b/packages/browser-sdk/src/toolbar/Switch.css @@ -0,0 +1,22 @@ +.switch { + cursor: pointer; +} + +.switch-track { + position: relative; + transition: background 0.1s ease; + background: #606476; +} + +.switch[data-enabled="true"] .switch-track { + background: #847cfb; +} + +.switch-dot { + background: white; + border-radius: 50%; + position: absolute; + top: 1px; + transition: transform 0.1s ease-in-out; + box-shadow: 0 0px 5px rgba(0, 0, 0, 0.2); +} diff --git a/packages/browser-sdk/src/toolbar/Switch.tsx b/packages/browser-sdk/src/toolbar/Switch.tsx new file mode 100644 index 00000000..deb212c9 --- /dev/null +++ b/packages/browser-sdk/src/toolbar/Switch.tsx @@ -0,0 +1,50 @@ +import { Fragment, h } from "preact"; + +interface SwitchProps extends h.JSX.HTMLAttributes { + checked: boolean; + width?: number; + height?: number; +} + +const gutter = 1; + +export function Switch({ + checked, + width = 24, + height = 14, + ...props +}: SwitchProps) { + return ( + <> +