Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,20 @@ Replace your-workspace-id with your actual workspace ID. You can find your works

> `environmentId` is still accepted for backward compatibility but is deprecated and will be removed in a future version. Prefer `workspaceId`.

### Dark mode

Surveys render light by default. Pass `appearance` to `setup()`, or change it at any time with
`setAppearance()`: `"light"`, `"dark"`, or `"system"` to follow the visitor's browser setting. An open
survey switches in place. Requires Formbricks 6.1 or later.

```javascript
formbricks.setup({
workspaceId: "your-workspace-id",
appUrl: "https://app.formbricks.com",
appearance: "system",
});

formbricks.setAppearance("dark");
```

For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
16 changes: 16 additions & 0 deletions packages/js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,20 @@ Replace your-workspace-id with your actual workspace ID. You can find your works

> `environmentId` is still accepted for backward compatibility but is deprecated and will be removed in a future version. Prefer `workspaceId`.

### Dark mode

Surveys render light by default. Pass `appearance` to `setup()`, or change it at any time with
`setAppearance()`: `"light"`, `"dark"`, or `"system"` to follow the visitor's browser setting. An open
survey switches in place. Requires Formbricks 6.1 or later.

```javascript
formbricks.setup({
workspaceId: "your-workspace-id",
appUrl: "https://app.formbricks.com",
appearance: "system",
});

formbricks.setAppearance("dark");
```

For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
2 changes: 1 addition & 1 deletion packages/js/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "5.1.0",
"version": "5.2.0",
"description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
Expand Down
10 changes: 10 additions & 0 deletions packages/js/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("./lib/load-formbricks", () => ({
setup: vi.fn().mockResolvedValue(undefined),
callMethod: vi.fn().mockResolvedValue(undefined),
setAppearance: vi.fn().mockResolvedValue(undefined),
}));

import formbricks from "./index";
import * as loadFormbricksModule from "./lib/load-formbricks";

const mockSetup = vi.mocked(loadFormbricksModule.setup);
const mockCallMethod = vi.mocked(loadFormbricksModule.callMethod);
const mockSetAppearance = vi.mocked(loadFormbricksModule.setAppearance);

describe("formbricks", () => {
beforeEach(() => {
Expand Down Expand Up @@ -136,6 +138,13 @@ describe("formbricks", () => {
expect(mockCallMethod).toHaveBeenCalledWith("registerRouteChange");
});

test("should delegate setAppearance to the loader's setAppearance", async () => {
await formbricks.setAppearance("dark");

expect(mockSetAppearance).toHaveBeenCalledWith("dark");
expect(mockCallMethod).not.toHaveBeenCalled();
});

test("should delegate setNonce to callMethod", async () => {
const nonce = "abc123";

Expand Down Expand Up @@ -203,6 +212,7 @@ describe("method signatures", () => {
expect(typeof formbricks.setLanguage).toBe("function");
expect(typeof formbricks.setUserId).toBe("function");
expect(typeof formbricks.setNonce).toBe("function");
expect(typeof formbricks.setAppearance).toBe("function");
expect(typeof formbricks.logout).toBe("function");
expect(typeof formbricks.registerRouteChange).toBe("function");
expect(typeof formbricks.setEmbeddedData).toBe("function");
Expand Down
10 changes: 9 additions & 1 deletion packages/js/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { callMethod, off, on, setup } from "./lib/load-formbricks";
import {
callMethod,
off,
on,
setAppearance,
setup,
} from "./lib/load-formbricks";
import type { TFormbricks } from "./types/formbricks";

declare global {
Expand All @@ -15,6 +21,7 @@ const formbricks: TFormbricks = {
setLanguage: (language) => callMethod("setLanguage", language),
setUserId: (userId) => callMethod("setUserId", userId),
setNonce: (nonce) => callMethod("setNonce", nonce),
setAppearance: (appearance) => setAppearance(appearance),
track: (code, properties) => callMethod("track", code, properties),
logout: () => callMethod("logout"),
registerRouteChange: () => callMethod("registerRouteChange"),
Expand All @@ -27,6 +34,7 @@ const formbricks: TFormbricks = {
};

export type {
TAppearance,
TEmbeddedDataInput,
TFormbricksEventName,
TFormbricksEventPayloads,
Expand Down
74 changes: 74 additions & 0 deletions packages/js/src/lib/load-formbricks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ let setup: (config: TSetupConfig) => Promise<void>;
let callMethod: (method: string, ...args: unknown[]) => Promise<void>;
let on: typeof import("./load-formbricks").on;
let off: typeof import("./load-formbricks").off;
let setAppearance: typeof import("./load-formbricks").setAppearance;

// Mock the globalThis formbricks object
const mockFormbricks = {
Expand Down Expand Up @@ -122,6 +123,7 @@ describe("load-formbricks", () => {
callMethod = module.callMethod;
on = module.on;
off = module.off;
setAppearance = module.setAppearance;
});

afterEach(() => {
Expand Down Expand Up @@ -730,4 +732,76 @@ describe("load-formbricks", () => {
expect(() => unsubscribe()).not.toThrow();
});
});

describe("appearance", () => {
const setupArgs = {
workspaceId: "ws123",
appUrl: "https://app.formbricks.com",
};

test("should forward appearance from the setup config to the SDK", async () => {
vi.spyOn(document.head, "appendChild").mockImplementation(
createSuccessfulScriptMock(),
);

await setup({ ...setupArgs, appearance: "dark" });

expect(mockFormbricks.setup).toHaveBeenCalledWith({
...setupArgs,
appearance: "dark",
});
});

test("should apply a setAppearance made before load ahead of setup", async () => {
const calls: string[] = [];
const sdkSetAppearance = vi.fn(() => {
calls.push("setAppearance");
});
mockFormbricks.setup.mockImplementation(() => {
calls.push("setup");
});
Object.assign(mockFormbricks, { setAppearance: sdkSetAppearance });
vi.spyOn(document.head, "appendChild").mockImplementation(
createSuccessfulScriptMock(),
);

await setAppearance("system");
await setup(setupArgs);

expect(sdkSetAppearance).toHaveBeenCalledWith("system");
expect(calls).toEqual(["setAppearance", "setup"]);
delete (mockFormbricks as Record<string, unknown>).setAppearance;
mockFormbricks.setup.mockReset();
});

test("should call the SDK directly once setup has run", async () => {
const sdkSetAppearance = vi.fn();
Object.assign(mockFormbricks, { setAppearance: sdkSetAppearance });
vi.spyOn(document.head, "appendChild").mockImplementation(
createSuccessfulScriptMock(),
);

await setup(setupArgs);
await setAppearance("dark");

expect(sdkSetAppearance).toHaveBeenCalledTimes(1);
expect(sdkSetAppearance).toHaveBeenCalledWith("dark");
delete (mockFormbricks as Record<string, unknown>).setAppearance;
});

test("should log instead of throwing when the SDK predates setAppearance", async () => {
const consoleSpy = createConsoleErrorSpy();
vi.spyOn(document.head, "appendChild").mockImplementation(
createSuccessfulScriptMock(),
);

await setAppearance("dark");
await setup(setupArgs);

expect(consoleSpy).toHaveBeenCalledWith(
"🧱 Formbricks - Error: Method setAppearance does not exist on formbricks",
);
expect(mockFormbricks.setup).toHaveBeenCalledWith(setupArgs);
});
});
});
38 changes: 37 additions & 1 deletion packages/js/src/lib/load-formbricks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
TAppearance,
TFormbricks,
TFormbricksEventName,
TFormbricksEventPayloads,
Expand All @@ -11,6 +12,14 @@
let isInitializing = false;
const queue: { method: string; args: unknown[] }[] = [];

/**
* The latest `setAppearance()` made before the SDK script loaded. js-core applies appearance
* outside its command queue and before setup, so this is handed over right after the script loads,
* before `setup()` runs — queuing it behind setup would show a survey triggered during setup in the
* wrong theme first.
*/
let pendingAppearance: TAppearance | undefined;

/**
* Subscriptions made before the SDK script has loaded. Unlike the method queue below, these are
* forwarded to the SDK BEFORE `setup()` runs — a `formbricks_setup_successful` handler queued
Expand Down Expand Up @@ -166,7 +175,7 @@
};

const validateSetupArgs = (config: TSetupConfig): TSetupConfig | null => {
const { appUrl, environmentId, workspaceId } = config;
const { appUrl, environmentId, workspaceId, appearance } = config;

Check warning on line 178 in packages/js/src/lib/load-formbricks.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'environmentId' is deprecated.

See more on https://sonarcloud.io/project/issues?id=formbricks_js&issues=AaFUHu9y0ZuTgmiw0D3t&open=AaFUHu9y0ZuTgmiw0D3t&pullRequest=59

if (!appUrl) {
console.error("🧱 Formbricks - Error: appUrl is required");
Expand All @@ -191,17 +200,22 @@
? appUrl.slice(0, -1)
: appUrl;

// Only forwarded when set, so setup() calls without it reach the SDK exactly as before.
const appearanceArg = appearance === undefined ? {} : { appearance };

if (workspaceId) {
return {
appUrl: appUrlWithoutTrailingSlash,
workspaceId,
...(environmentId ? { environmentId } : {}),
...appearanceArg,
};
}

return {
appUrl: appUrlWithoutTrailingSlash,
environmentId: environmentId as string,
...appearanceArg,
};
};

Expand Down Expand Up @@ -229,6 +243,27 @@
}
};

const applyPendingAppearanceTo = (instance: TFormbricks): void => {
if (pendingAppearance === undefined) return;
const appearance = pendingAppearance;
pendingAppearance = undefined;
if (typeof instance.setAppearance !== "function") {
console.error(
"🧱 Formbricks - Error: Method setAppearance does not exist on formbricks",
);
return;
}
void instance.setAppearance(appearance);
};

export const setAppearance = async (appearance: TAppearance): Promise<void> => {
if (coreInstance) {
await callMethod("setAppearance", appearance);
return;
}
pendingAppearance = appearance;
};

export const setup = async (config: TSetupConfig): Promise<void> => {
if (isInitializing) {
console.warn(
Expand All @@ -255,6 +290,7 @@
// formbricks_setup_successful. The method queue stays after setup — those calls need a
// set-up SDK, subscriptions need the opposite order.
flushSubscriptionsTo(instance);
applyPendingAppearanceTo(instance);
await instance.setup({ ...validatedArgs });
coreInstance = instance;
processQueue();
Expand Down
25 changes: 23 additions & 2 deletions packages/js/src/types/formbricks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export interface TFormbricksEventPayloads {

export type TFormbricksEventName = keyof TFormbricksEventPayloads;

/**
* How surveys render. Mirrors js-core's `TAppearance` (packages/js-core/src/lib/common/appearance.ts
* in the formbricks monorepo). "system" follows the browser's color scheme.
*/
export type TAppearance = "light" | "dark" | "system";

/**
* What `setEmbeddedData` accepts. Mirrors js-core's `TEmbeddedDataInput`
* (packages/js-core/src/lib/survey/embedded-data.ts in the formbricks monorepo): `null` removes a
Expand Down Expand Up @@ -71,6 +77,14 @@ export interface TFormbricks {
*/
setNonce: (nonce: string | undefined) => Promise<void>;

/**
* @description Sets how surveys render: "light" (default), "dark", or "system" to follow the
* browser. Works before setup() too, and an open survey switches in place. Needs Formbricks 6.1
* or later; older instances log an error and keep rendering light.
* @param appearance - "light" | "dark" | "system"
*/
setAppearance: (appearance: TAppearance) => Promise<void>;

/**
* @description Tracks an event.
* @param code - The code of the event.
Expand Down Expand Up @@ -129,7 +143,7 @@ export interface TFormbricks {
) => void;
}

export type TSetupConfig =
export type TSetupConfig = (
| {
workspaceId: string;
/**
Expand All @@ -145,4 +159,11 @@ export type TSetupConfig =
*/
environmentId: string;
appUrl: string;
};
}
) & {
/**
* How surveys render. Defaults to light; can be changed later with `setAppearance()`. Needs
* Formbricks 6.1 or later; older instances ignore it.
*/
appearance?: TAppearance;
};
Loading