diff --git a/docs.sh b/docs.sh index e67f36f5..d5ecc670 100755 --- a/docs.sh +++ b/docs.sh @@ -15,11 +15,46 @@ typedoc # We can fix this by removing the number at the end of the anchor. SEDCOMMAND='s/globals.md#(.*)-[0-9]+/globals.md#\1/g' -FILES=$(find dist/docs/@bucketco -name "globals.md") +# Find all markdown files including globals.md +FILES=$(find dist/docs/@bucketco -name "*.md") +echo "Processing markdown files..." for file in $FILES do - sed -r $SEDCOMMAND $file > $file.fixed - rm $file - mv $file.fixed $file + echo "Processing $file..." + + # Fix anchor links in globals.md files + if [[ "$file" == *"globals.md" ]]; then + sed -r "$SEDCOMMAND" "$file" > "$file.fixed" + rm "$file" + mv "$file.fixed" "$file" + fi + + # Create a temporary file for processing + tmp_file="${file}.tmp" + + # Process NOTE blocks - handle multi-line + awk ' + BEGIN { in_block = 0; content = ""; } + /^> \[!NOTE\]/ { in_block = 1; print "{% hint style=\"info\" %}"; next; } + /^> \[!TIP\]/ { in_block = 1; print "{% hint style=\"success\" %}"; next; } + /^> \[!IMPORTANT\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; } + /^> \[!WARNING\]/ { in_block = 1; print "{% hint style=\"warning\" %}"; next; } + /^> \[!CAUTION\]/ { in_block = 1; print "{% hint style=\"danger\" %}"; next; } + in_block && /^>/ { + content = content substr($0, 3) "\n"; + next; + } + in_block && !/^>/ { + printf "%s", content; + print "{% endhint %}"; + in_block = 0; + content = ""; + } + !in_block { print; } + ' "$file" > "$tmp_file" + + mv "$tmp_file" "$file" done + +echo "Processing complete!" diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index eec910a2..63e88c60 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -27,28 +27,19 @@ const bucketClient = new BucketClient({ publishableKey, user, company }); await bucketClient.initialize(); -const { - isEnabled, - config: { payload: question }, - track, - requestFeedback, -} = bucketClient.getFeature("huddle"); +const { isEnabled, track, requestFeedback } = bucketClient.getFeature("huddle"); if (isEnabled) { - // Show feature. When retrieving `isEnabled` the client automatically + // show feature. When retrieving `isEnabled` the client automatically // sends a "check" event for the "huddle" feature which is shown in the // Bucket UI. // On usage, call `track` to let Bucket know that a user interacted with the feature track(); - // The `payload` is a user-supplied JSON in Bucket that is dynamically picked - // out depending on the user/company. - const question = payload?.question ?? "Tell us what you think of Huddles"; - // Use `requestFeedback` to create "Send feedback" buttons easily for specific // features. This is not related to `track` and you can call them individually. - requestFeedback({ title: question }); + requestFeedback({ title: "Tell us what you think of Huddles" }); } // `track` just calls `bucketClient.track()` to send an event using the same feature key @@ -147,7 +138,6 @@ To retrieve features along with their targeting information, use `getFeature(key const huddle = bucketClient.getFeature("huddle"); // { // isEnabled: true, -// config: { key: "zoom", payload: { ... } }, // track: () => Promise // requestFeedback: (options: RequestFeedbackData) => void // } @@ -161,7 +151,6 @@ const features = bucketClient.getFeatures(); // huddle: { // isEnabled: true, // targetingVersion: 42, -// config: ... // } // } ``` @@ -170,35 +159,7 @@ const features = bucketClient.getFeatures(); by down-stream clients, like the React SDK. Note that accessing `isEnabled` on the object returned by `getFeatures` does not automatically -generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFeature`. - -### Remote config - -Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. -It is managed similar to the way access to features is managed, but instead of the binary `isEnabled` you can have -multiple configuration values which are given to different user/companies. - -```ts -const features = bucketClient.getFeatures(); -// { -// huddle: { -// isEnabled: true, -// targetingVersion: 42, -// config: { -// key: "gpt-3.5", -// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" } -// } -// } -// } -``` - -The `key` is always present while the `payload` is a optional JSON value for arbitrary configuration needs. -If feature has no configuration or, no configuration value was matched against the context, the `config` object -will be empty, thus, `key` will be `undefined`. Make sure to check against this case when trying to use the -configuration in your application. - -Just as `isEnabled`, accessing `config` on the object returned by `getFeatures` does not automatically -generate a `check` event, contrary to the `config` property on the object returned by `getFeature`. +generate a `check` event, contrary to the `isEnabled` property on the object return from `getFeature`. ### Tracking feature usage diff --git a/packages/browser-sdk/example/browser.html b/packages/browser-sdk/example/browser.html new file mode 100644 index 00000000..175497d1 --- /dev/null +++ b/packages/browser-sdk/example/browser.html @@ -0,0 +1,43 @@ + + + + + + Bucket feature management + + + Loading... + + + + + + + + diff --git a/packages/browser-sdk/example/typescript/app.ts b/packages/browser-sdk/example/typescript/app.ts deleted file mode 100644 index 72ee20cd..00000000 --- a/packages/browser-sdk/example/typescript/app.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index cec72d21..00000000 --- a/packages/browser-sdk/example/typescript/index.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - Bucket feature management - - - Loading... - - - - - diff --git a/packages/browser-sdk/index.html b/packages/browser-sdk/index.html deleted file mode 100644 index 597eadec..00000000 --- a/packages/browser-sdk/index.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - Bucket Browser SDK - - -
- Loading... - - - - - - - - diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index 734debca..10109b93 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/browser-sdk", - "version": "3.0.0-alpha.2", + "version": "2.5.2", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index a3d34a19..09e6ca50 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -14,12 +14,10 @@ import { RequestFeedbackOptions, } from "./feedback/feedback"; import * as feedbackLib from "./feedback/ui"; -import { ToolbarPosition } from "./toolbar/Toolbar"; -import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; +import { API_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 @@ -151,11 +149,6 @@ interface Config { */ apiBaseUrl: string; - /** - * Base URL of the Bucket web app. - */ - appBaseUrl: string; - /** * Base URL of Bucket servers for SSE connections used by AutoFeedback. */ @@ -167,16 +160,6 @@ interface Config { enableTracking: boolean; } -/** - * Toolbar options. - */ -export type ToolbarOptions = - | boolean - | { - show?: boolean; - position?: ToolbarPosition; - }; - /** * Feature definitions. */ @@ -229,11 +212,6 @@ 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. @@ -264,60 +242,26 @@ export interface InitOptions { * Whether to enable tracking. Defaults to `true`. */ 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, }; /** - * A remotely managed configuration value for a feature. - */ -export type FeatureRemoteConfig = - | { - /** - * The key of the matched configuration value. - */ - key: string; - - /** - * The optional user-supplied payload data. - */ - payload: any; - } - | { key: undefined; payload: undefined }; - -/** - * A feature. + * Represents a feature. */ export interface Feature { /** - * Result of feature flag evaluation. + * Result of feature flag evaluation */ isEnabled: boolean; - /* - * Optional user-defined configuration. - */ - config: FeatureRemoteConfig; - /** - * Function to send analytics events for this feature. + * Function to send analytics events for this feature + * */ track: () => Promise; @@ -328,33 +272,21 @@ 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. */ export class BucketClient { - private readonly publishableKey: string; - private readonly context: BucketContext; + private publishableKey: string; + private context: BucketContext; private config: Config; private requestFeedbackOptions: Partial; - private readonly httpClient: HttpClient; + private httpClient: HttpClient; - private readonly autoFeedback: AutoFeedback | undefined; + private autoFeedback: AutoFeedback | undefined; private autoFeedbackInit: Promise | undefined; - private readonly featuresClient: FeaturesClient; + private featuresClient: FeaturesClient; public readonly logger: Logger; - /** * Create a new BucketClient instance. */ @@ -370,10 +302,9 @@ 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); @@ -395,7 +326,6 @@ export class BucketClient { company: this.context.company, other: this.context.otherContext, }, - opts?.featureList || [], this.logger, opts?.features, ); @@ -421,15 +351,6 @@ 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, - }); - } } /** @@ -460,13 +381,6 @@ export class BucketClient { } } - /** - * Get the current configuration. - */ - getConfig() { - return this.config; - } - /** * Update the user context. * Performs a shallow merge with the existing user context. @@ -496,7 +410,7 @@ export class BucketClient { * Performs a shallow merge with the existing company context. * Attempting to update the company ID will log a warning and be ignored. * - * @param company The company details. + * @param company */ async updateCompany(company: { [key: string]: string | number | undefined }) { if (company.id && company.id !== this.context.company?.id) { @@ -518,8 +432,6 @@ export class BucketClient { * Update the company context. * Performs a shallow merge with the existing company context. * Updates to the company ID will be ignored. - * - * @param otherContext Additional context. */ async updateOtherContext(otherContext: { [key: string]: string | number | undefined; @@ -537,7 +449,7 @@ export class BucketClient { * * Calling `client.stop()` will remove all listeners added here. * - * @param cb The callback to call when the update completes. + * @param cb this will be called when the features are updated. */ onFeaturesUpdated(cb: () => void) { return this.featuresClient.onUpdated(cb); @@ -546,8 +458,8 @@ export class BucketClient { /** * Track an event in Bucket. * - * @param eventName The name of the event. - * @param attributes Any attributes you want to attach to the event. + * @param eventName The name of the event + * @param attributes Any attributes you want to attach to the event */ async track(eventName: string, attributes?: Record | null) { if (!this.context.user) { @@ -575,8 +487,7 @@ export class BucketClient { /** * Submit user feedback to Bucket. Must include either `score` or `comment`, or both. * - * @param payload The feedback details to submit. - * @returns The server response. + * @returns */ async feedback(payload: Feedback) { const userId = @@ -669,52 +580,36 @@ 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. + * @returns Map of features */ getFeatures(): RawFeatures { return this.featuresClient.getFeatures(); } /** - * Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event. - * @returns A feature. + * Return a feature. Accessing `isEnabled` will automatically send a `check` event. + * @returns A feature */ getFeature(key: string): Feature { const f = this.getFeatures()[key]; const fClient = this.featuresClient; - const value = f?.isEnabledOverride ?? f?.isEnabled ?? false; - const config = f?.config - ? { - key: f.config.key, - payload: f.config.payload, - } - : { key: undefined, payload: undefined }; - - function sendCheckEvent() { - fClient - .sendCheckEvent({ - key, - version: f?.targetingVersion, - value, - }) - .catch(() => { - // ignore - }); - } + const value = f?.isEnabled ?? false; return { get isEnabled() { - sendCheckEvent(); + fClient + .sendCheckEvent({ + key: key, + version: f?.targetingVersion, + value, + }) + .catch(() => { + // ignore + }); return value; }, - get config() { - sendCheckEvent(); - return config; - }, track: () => this.track(key), requestFeedback: ( options: Omit, @@ -727,14 +622,6 @@ 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/config.ts b/packages/browser-sdk/src/config.ts index fd116c7f..e1baeec7 100644 --- a/packages/browser-sdk/src/config.ts +++ b/packages/browser-sdk/src/config.ts @@ -1,7 +1,6 @@ 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/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index b4cb8ed8..1a66c441 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -1,4 +1,4 @@ -import { FetchedFeatures } from "./features"; +import { RawFeatures } from "./features"; interface StorageItem { get(): string | null; @@ -8,50 +8,45 @@ interface StorageItem { interface cacheEntry { expireAt: number; staleAt: number; - features: FetchedFeatures; + features: RawFeatures; } // Parse and validate an API feature response export function parseAPIFeaturesResponse( featuresInput: any, -): FetchedFeatures | undefined { +): RawFeatures | undefined { if (!isObject(featuresInput)) { return; } - const features: FetchedFeatures = {}; + const features: RawFeatures = {}; for (const key in featuresInput) { const feature = featuresInput[key]; - if ( typeof feature.isEnabled !== "boolean" || feature.key !== key || - typeof feature.targetingVersion !== "number" || - (feature.config && typeof feature.config !== "object") + typeof feature.targetingVersion !== "number" ) { return; } - features[key] = { isEnabled: feature.isEnabled, targetingVersion: feature.targetingVersion, key, - config: feature.config, }; } - return features; } export interface CacheResult { - features: FetchedFeatures; + features: RawFeatures; stale: boolean; } export class FeatureCache { private storage: StorageItem; - private readonly staleTimeMs: number; - private readonly expireTimeMs: number; + private staleTimeMs: number; + private expireTimeMs: number; constructor({ storage, @@ -72,7 +67,7 @@ export class FeatureCache { { features, }: { - features: FetchedFeatures; + features: RawFeatures; }, ) { let cacheData: CacheData = {}; diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 7c11420a..46d02a61 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -9,10 +9,7 @@ import { parseAPIFeaturesResponse, } from "./featureCache"; -/** - * A feature fetched from the server. - */ -export type FetchedFeature = { +export type RawFeature = { /** * Feature key */ @@ -27,91 +24,58 @@ export type FetchedFeature = { * Version of targeting rules */ targetingVersion?: number; - - /** - * Optional user-defined dynamic configuration. - */ - config?: { - /** - * The key of the matched configuration value. - */ - key: string; - - /** - * The version of the matched configuration value. - */ - version?: number; - - /** - * The optional user-supplied payload data. - */ - payload?: any; - }; }; const FEATURES_UPDATED_EVENT = "features-updated"; -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 FallbackFeatureOverride = - | { - key: string; - payload: any; - } - | true; +export type RawFeatures = Record; export type FeaturesOptions = { /** * Feature keys for which `isEnabled` should fallback to true - * if SDK fails to fetch features from Bucket servers. If a record - * is supplied instead of array, the values of each key represent the - * configuration values and `isEnabled` is assume `true`. + * if SDK fails to fetch features from Bucket servers. */ - fallbackFeatures?: string[] | Record; + fallbackFeatures?: string[]; /** - * Timeout in milliseconds when fetching features + * Timeout in miliseconds */ timeoutMs?: number; /** - * If set to true stale features will be returned while refetching features + * If set to true client will return cached value when its stale + * but refetching */ staleWhileRevalidate?: boolean; - - /** - * 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; + expireTimeMs?: number; }; type Config = { - fallbackFeatures: Record; + fallbackFeatures: string[]; timeoutMs: number; staleWhileRevalidate: boolean; }; export const DEFAULT_FEATURES_CONFIG: Config = { - fallbackFeatures: {}, + fallbackFeatures: [], timeoutMs: 5000, staleWhileRevalidate: false, }; +// Deep merge two objects. +export type FeaturesResponse = { + /** + * `true` if call was successful + */ + success: boolean; + + /** + * List of enabled features + */ + features: RawFeatures; +}; + export function validateFeaturesResponse(response: any) { if (!isObject(response)) { return; @@ -120,9 +84,7 @@ export function validateFeaturesResponse(response: any) { if (typeof response.success !== "boolean" || !isObject(response.features)) { return; } - const features = parseAPIFeaturesResponse(response.features); - if (!features) { return; } @@ -176,37 +138,14 @@ type context = { export const FEATURES_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days -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 { - const cachedOverrides = JSON.parse( - localStorage.getItem(localStorageOverridesKey) || "{}", - ); - - if (!isObject(cachedOverrides)) { - return {}; - } - - return cachedOverrides; -} +const localStorageCacheKey = `__bucket_features`; /** * @internal */ export class FeaturesClient { private cache: FeatureCache; - private fetchedFeatures: FetchedFeatures; - private featureOverrides: OverridesFeatures = {}; - - private features: RawFeatures = {}; - + private features: RawFeatures; private config: Config; private rateLimiter: RateLimiter; private readonly logger: Logger; @@ -217,63 +156,33 @@ export class FeaturesClient { constructor( private httpClient: HttpClient, private context: context, - private featureDefinitions: Readonly, logger: Logger, options?: FeaturesOptions & { cache?: FeatureCache; rateLimiter?: RateLimiter; }, ) { - this.fetchedFeatures = {}; + this.features = {}; this.logger = loggerWithPrefix(logger, "[Features]"); this.cache = options?.cache ? options.cache : new FeatureCache({ storage: { - get: () => localStorage.getItem(localStorageFetchedFeaturesKey), - set: (value) => - localStorage.setItem(localStorageFetchedFeaturesKey, value), + get: () => localStorage.getItem(localStorageCacheKey), + set: (value) => localStorage.setItem(localStorageCacheKey, value), }, staleTimeMs: options?.staleTimeMs ?? 0, expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS, }); - - let fallbackFeatures: Record; - - if (Array.isArray(options?.fallbackFeatures)) { - fallbackFeatures = options.fallbackFeatures.reduce( - (acc, key) => { - acc[key] = true; - return acc; - }, - {} as Record, - ); - } else { - fallbackFeatures = options?.fallbackFeatures ?? {}; - } - - this.config = { ...DEFAULT_FEATURES_CONFIG, ...options, fallbackFeatures }; - + this.config = { ...DEFAULT_FEATURES_CONFIG, ...options }; this.rateLimiter = options?.rateLimiter ?? new RateLimiter(FEATURE_EVENTS_PER_MIN, this.logger); - - 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() { const features = (await this.maybeFetchFeatures()) || {}; - this.setFetchedFeatures(features); + this.setFeatures(features); } async setContext(context: context) { @@ -313,11 +222,7 @@ export class FeaturesClient { return this.features; } - getFetchedFeatures(): FetchedFeatures { - return this.fetchedFeatures; - } - - public async fetchFeatures(): Promise { + public async fetchFeatures(): Promise { const params = this.fetchParams(); try { const res = await this.httpClient.get({ @@ -341,7 +246,6 @@ export class FeaturesClient { JSON.stringify(errorBody), ); } - const typeRes = validateFeaturesResponse(await res.json()); if (!typeRes || !typeRes.success) { throw new Error("unable to validate response"); @@ -387,41 +291,11 @@ export class FeaturesClient { return checkEvent.value; } - 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 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] ?? null, - }; - } - } - - this.features = mergedFeatures; - + private setFeatures(features: RawFeatures) { + this.features = features; 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); @@ -434,7 +308,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); @@ -451,7 +325,7 @@ export class FeaturesClient { this.cache.set(cacheKey, { features, }); - this.setFetchedFeatures(features); + this.setFeatures(features); }) .catch(() => { // we don't care about the result, we just want to re-fetch @@ -478,41 +352,12 @@ export class FeaturesClient { } // fetch failed, nothing cached => return fallbacks - return Object.entries(this.config.fallbackFeatures).reduce( - (acc, [key, override]) => { - acc[key] = { - key, - isEnabled: !!override, - config: - typeof override === "object" && "key" in override - ? { - key: override.key, - payload: override.payload, - } - : undefined, - }; - return acc; - }, - {} 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; + return this.config.fallbackFeatures.reduce((acc, key) => { + acc[key] = { + key, + isEnabled: true, + }; + return acc; + }, {} as RawFeatures); } } diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index c649b283..b83428a9 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?: Position; + position?: FeedbackPosition; /** * Add your own custom translations for the feedback form. @@ -69,7 +69,7 @@ export function handleDeprecatedFeedbackOptions( }; } -type FeatureIdentifier = +export type FeatureIdentifier = | { /** * Bucket feature ID. @@ -100,9 +100,6 @@ 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; @@ -297,7 +294,7 @@ export class AutoFeedback { private httpClient: HttpClient, private feedbackPromptHandler: FeedbackPromptHandler = createDefaultFeedbackPromptHandler(), private userId: string, - private position: Position = DEFAULT_POSITION, + private position: FeedbackPosition = 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 96cb6f02..91a99ec7 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css +++ b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.css @@ -1,3 +1,39 @@ +@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; @@ -33,8 +69,12 @@ } .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; @@ -94,3 +134,62 @@ .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 2893ebbe..8a0f771a 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx @@ -1,22 +1,33 @@ import { Fragment, FunctionComponent, h } from "preact"; -import { useCallback, useState } from "preact/hooks"; - -import { feedbackContainerId } from "../../ui/constants"; -import { Dialog, useDialog } from "../../ui/Dialog"; -import { Close } from "../../ui/icons/Close"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; 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" @@ -36,6 +47,97 @@ 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" @@ -62,54 +164,112 @@ 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, }); - const dismiss = useCallback(() => { - autoClose.stop(); - close(); - onDismiss?.(); - }, [autoClose, close, onDismiss]); + 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]); 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 f1b7b446..949ce658 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackForm.tsx @@ -1,9 +1,8 @@ import { FunctionComponent, h } from "preact"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; -import { Check } from "../../ui/icons/Check"; -import { CheckCircle } from "../../ui/icons/CheckCircle"; - +import { Check } from "./icons/Check"; +import { CheckCircle } from "./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 f315708f..dc8add02 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 "../../ui/icons/Logo"; +import { Logo } from "./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 e8e439a5..ffe6ec1a 100644 --- a/packages/browser-sdk/src/feedback/ui/StarRating.tsx +++ b/packages/browser-sdk/src/feedback/ui/StarRating.tsx @@ -1,17 +1,12 @@ import { Fragment, FunctionComponent, h } from "preact"; import { useRef } from "preact/hooks"; -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 { 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 { 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 c7edc9a2..9bf80413 100644 --- a/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx +++ b/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx @@ -1,4 +1,5 @@ import { FeedbackTranslations } from "../types"; + /** * {@includeCode ./defaultTranslations.tsx} */ diff --git a/packages/browser-sdk/src/ui/constants.ts b/packages/browser-sdk/src/feedback/ui/constants.ts similarity index 95% rename from packages/browser-sdk/src/ui/constants.ts rename to packages/browser-sdk/src/feedback/ui/constants.ts index 8d430e46..0dc6af35 100644 --- a/packages/browser-sdk/src/ui/constants.ts +++ b/packages/browser-sdk/src/feedback/ui/constants.ts @@ -2,7 +2,6 @@ * ID of HTML DIV element which contains the feedback dialog */ export const feedbackContainerId = "bucket-feedback-dialog-container"; -export const toolbarContainerId = "bucket-toolbar-dialog-container"; /** * These events will be propagated to the feedback dialog diff --git a/packages/browser-sdk/src/ui/icons/Check.tsx b/packages/browser-sdk/src/feedback/ui/icons/Check.tsx similarity index 100% rename from packages/browser-sdk/src/ui/icons/Check.tsx rename to packages/browser-sdk/src/feedback/ui/icons/Check.tsx diff --git a/packages/browser-sdk/src/ui/icons/CheckCircle.tsx b/packages/browser-sdk/src/feedback/ui/icons/CheckCircle.tsx similarity index 100% rename from packages/browser-sdk/src/ui/icons/CheckCircle.tsx rename to packages/browser-sdk/src/feedback/ui/icons/CheckCircle.tsx diff --git a/packages/browser-sdk/src/ui/icons/Close.tsx b/packages/browser-sdk/src/feedback/ui/icons/Close.tsx similarity index 100% rename from packages/browser-sdk/src/ui/icons/Close.tsx rename to packages/browser-sdk/src/feedback/ui/icons/Close.tsx diff --git a/packages/browser-sdk/src/ui/icons/Dissatisfied.tsx b/packages/browser-sdk/src/feedback/ui/icons/Dissatisfied.tsx similarity index 100% rename from packages/browser-sdk/src/ui/icons/Dissatisfied.tsx rename to packages/browser-sdk/src/feedback/ui/icons/Dissatisfied.tsx diff --git a/packages/browser-sdk/src/ui/icons/Logo.tsx b/packages/browser-sdk/src/feedback/ui/icons/Logo.tsx similarity index 98% rename from packages/browser-sdk/src/ui/icons/Logo.tsx rename to packages/browser-sdk/src/feedback/ui/icons/Logo.tsx index c520b8eb..2164fb89 100644 --- a/packages/browser-sdk/src/ui/icons/Logo.tsx +++ b/packages/browser-sdk/src/feedback/ui/icons/Logo.tsx @@ -1,9 +1,11 @@ import { FunctionComponent, h } from "preact"; export const Logo: FunctionComponent> = ( - props = { height: "10px", width: "10px" }, + props, ) => ( = 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; +}; + +export type FeedbackPosition = + | { type: "MODAL" } + | { type: "DIALOG"; placement: FeedbackPlacement; offset?: Offset } + | { type: "POPOVER"; anchor: HTMLElement | null }; + export interface FeedbackSubmission { question: string; feedbackId?: string; @@ -26,7 +46,7 @@ export interface OpenFeedbackFormOptions { /** * Control the placement and behavior of the feedback form. */ - position?: Position; + position?: FeedbackPosition; /** * Add your own custom translations for the feedback form. @@ -47,6 +67,7 @@ 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 2b4e7bf8..f8cd11db 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -1,16 +1,14 @@ -// import "preact/debug"; - -export type { Feature, InitOptions, ToolbarOptions } from "./client"; +export type { Feature, InitOptions } from "./client"; export { BucketClient } from "./client"; export type { BucketContext, CompanyContext, UserContext } from "./context"; export type { CheckEvent, - FallbackFeatureOverride, FeaturesOptions, RawFeature, RawFeatures, } from "./feature/features"; export type { + FeatureIdentifier, Feedback, FeedbackOptions, FeedbackPrompt, @@ -24,12 +22,15 @@ 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 deleted file mode 100644 index ee17134f..00000000 --- a/packages/browser-sdk/src/toolbar/Features.css +++ /dev/null @@ -1,74 +0,0 @@ -.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 deleted file mode 100644 index 9867a57d..00000000 --- a/packages/browser-sdk/src/toolbar/Features.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index 335d1b71..00000000 --- a/packages/browser-sdk/src/toolbar/Switch.css +++ /dev/null @@ -1,22 +0,0 @@ -.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 deleted file mode 100644 index deb212c9..00000000 --- a/packages/browser-sdk/src/toolbar/Switch.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 ( - <> -